diff --git a/workspaces/frontend/src/app/AppRoutes.tsx b/workspaces/frontend/src/app/AppRoutes.tsx index c26d7ed2..0251d74e 100644 --- a/workspaces/frontend/src/app/AppRoutes.tsx +++ b/workspaces/frontend/src/app/AppRoutes.tsx @@ -68,6 +68,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> { diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceFormLocationData.ts b/workspaces/frontend/src/app/hooks/useWorkspaceFormLocationData.ts index 21be1f53..43fa5351 100644 --- a/workspaces/frontend/src/app/hooks/useWorkspaceFormLocationData.ts +++ b/workspaces/frontend/src/app/hooks/useWorkspaceFormLocationData.ts @@ -3,12 +3,17 @@ import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey'; import { useTypedLocation } from '~/app/routerHelper'; import { AppRouteKey, RouteStateMap } from '~/app/routes'; -type WorkspaceFormLocationState = RouteStateMap['workspaceEdit'] | RouteStateMap['workspaceCreate']; +type WorkspaceFormLocationState = + | RouteStateMap['workspaceEdit'] + | RouteStateMap['workspaceCreate'] + | RouteStateMap['workspaceKindEdit'] + | RouteStateMap['workspaceKindCreate']; interface WorkspaceFormLocationData { mode: 'edit' | 'create'; namespace: string; workspaceName?: string; + workspaceKindName?: string; } function getRouteStateIfMatch( @@ -25,10 +30,13 @@ function getRouteStateIfMatch( export function useWorkspaceFormLocationData(): WorkspaceFormLocationData { const { selectedNamespace } = useNamespaceContext(); - const location = useTypedLocation<'workspaceEdit' | 'workspaceCreate'>(); + const location = useTypedLocation< + 'workspaceEdit' | 'workspaceCreate' | 'workspaceKindEdit' | 'workspaceKindCreate' + >(); const routeKey = useCurrentRouteKey(); const rawState = location.state as WorkspaceFormLocationState | undefined; + // Workspace Edit Mode if (routeKey === 'workspaceEdit') { const editState = getRouteStateIfMatch('workspaceEdit', routeKey, rawState); const namespace = editState?.namespace ?? selectedNamespace; @@ -45,6 +53,7 @@ export function useWorkspaceFormLocationData(): WorkspaceFormLocationData { }; } + // Workspace Create Mode if (routeKey === 'workspaceCreate') { const createState = getRouteStateIfMatch('workspaceCreate', routeKey, rawState); const namespace = createState?.namespace ?? selectedNamespace; @@ -55,5 +64,35 @@ export function useWorkspaceFormLocationData(): WorkspaceFormLocationData { }; } + // Workspace Kind Edit Mode + if (routeKey === 'workspaceKindEdit') { + const editState = getRouteStateIfMatch('workspaceKindEdit', routeKey, rawState); + const namespace = editState?.namespace ?? selectedNamespace; + // TODO: remove default jupyterlab from workspace + const workspaceKindName = editState?.workspaceKindName || 'jupyterlab'; + + if (!workspaceKindName) { + throw new Error('Workspace kind name is required for edit mode'); + } + + return { + mode: 'edit', + namespace, + workspaceKindName, + }; + } + + // Workspace Kind Create Mode + if (routeKey === 'workspaceKindCreate') { + const createState = getRouteStateIfMatch('workspaceKindCreate', routeKey, rawState); + const namespace = createState?.namespace ?? selectedNamespace; + + return { + mode: 'create', + namespace, + // formType: 'workspaceKind', + }; + } + throw new Error('Unknown workspace form route'); } diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx index 8f1f3f92..d7eb9c88 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx @@ -8,13 +8,12 @@ import { PageGroup, PageSection, Stack, - ToggleGroup, - ToggleGroupItem, } from '@patternfly/react-core'; import { useTypedNavigate } from '~/app/routerHelper'; import useGenericObjectState from '~/app/hooks/useGenericObjectState'; import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; import { WorkspaceKindFormData } from '~/app/types'; +import { useWorkspaceFormLocationData } from '~/app/hooks/useWorkspaceFormLocationData'; import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload'; import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties'; import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage'; @@ -31,12 +30,10 @@ export const WorkspaceKindForm: React.FC = () => { const navigate = useTypedNavigate(); const { api } = useNotebookAPI(); // TODO: Detect mode by route - const [mode] = useState('create'); + const { mode } = useWorkspaceFormLocationData(); const [yamlValue, setYamlValue] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [view, setView] = useState(WorkspaceKindFormView.FileUpload); const [validated, setValidated] = useState('default'); - const workspaceKindFileUploadId = 'workspace-kind-form-fileupload-view'; const [data, setData, resetData] = useGenericObjectState({ properties: { @@ -58,18 +55,6 @@ export const WorkspaceKindForm: React.FC = () => { }, }); - const handleViewClick = useCallback( - (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { - const { id } = event.currentTarget as HTMLElement; - setView( - id === workspaceKindFileUploadId - ? WorkspaceKindFormView.FileUpload - : WorkspaceKindFormView.Form, - ); - }, - [], - ); - const handleSubmit = useCallback(async () => { setIsSubmitting(true); // TODO: Complete handleCreate with API call to create a new WS kind @@ -106,37 +91,18 @@ export const WorkspaceKindForm: React.FC = () => { {`${mode === 'create' ? 'Create' : 'Edit'} workspace kind`} - {view === WorkspaceKindFormView.FileUpload + {mode === 'create' ? `Please upload or drag and drop a Workspace Kind YAML file.` : `View and edit the Workspace Kind's information. Some fields may not be represented in this form`} - {mode === 'edit' && ( - - - - - - - )} - {view === WorkspaceKindFormView.FileUpload && ( + {mode === 'create' && ( { setValidated={setValidated} /> )} - {view === WorkspaceKindFormView.Form && ( + {mode === 'edit' && ( <> = ({ value, onChange, - min = 0, + min = 1, max, step = DEFAULT_STEP, type, @@ -44,37 +50,23 @@ export const ResourceInputWrapper: React.FC = ({ const [unit, setUnit] = useState(''); useEffect(() => { - if (type === 'memory') { - // Extract numeric value and unit from memory string (e.g., "512Mi" -> "512" and "Mi") - const match = value.match(/^(\d+)([MGTP]i)?$/i); - if (match) { - setInputValue(match[1]); - setUnit(match[2] || 'Mi'); - } else { - setInputValue(''); - setUnit('Mi'); - } - } else if (type === 'cpu') { - const match = value.match(/^(\d+)([m])?$/i); - if (match) { - setInputValue(match[1]); - setUnit(match[2] || ''); - } else { - setInputValue(''); - setUnit(''); - } - } else { + if (type === 'custom') { setInputValue(value); + return; } + const numericValue = extractNumericValue(value, type); + const extractedUnit = extractUnit(value, type); + setInputValue(numericValue); + setUnit(extractedUnit || DEFAULT_UNITS[type]); }, [value, type]); const handleInputChange = useCallback( (newValue: string) => { setInputValue(newValue); - if (type === 'memory' || type === 'cpu') { - onChange(newValue ? `${newValue}${unit}` : ''); - } else { + if (type === 'custom') { onChange(newValue); + } else { + onChange(newValue ? `${newValue}${unit}` : ''); } }, [onChange, type, unit], @@ -110,7 +102,6 @@ export const ResourceInputWrapper: React.FC = ({ [handleInputChange], ); - // Memoize the unit options to prevent unnecessary re-renders const unitOptions = useMemo( () => type !== 'custom' @@ -123,7 +114,7 @@ export const ResourceInputWrapper: React.FC = ({ = ({ custom, }) => { // State for tracking limit toggles - const [cpuRequestEnabled, setCpuRequestEnabled] = useState(cpu.request.length > 0); - const [memoryRequestEnabled, setMemoryRequestEnabled] = useState( - memory.request.length > 0, - ); - const [cpuLimitEnabled, setCpuLimitEnabled] = useState(cpu.limit.length > 0); - const [memoryLimitEnabled, setMemoryLimitEnabled] = useState(memory.limit.length > 0); + const [cpuRequestEnabled, setCpuRequestEnabled] = useState(cpu.request.length > 0); + const [memoryRequestEnabled, setMemoryRequestEnabled] = useState(memory.request.length > 0); + const [cpuLimitEnabled, setCpuLimitEnabled] = useState(cpu.limit.length > 0); + const [memoryLimitEnabled, setMemoryLimitEnabled] = useState(memory.limit.length > 0); const [customLimitsEnabled, setCustomLimitsEnabled] = useState>(() => { const customToggles: Record = {}; custom.forEach((res, idx) => { @@ -67,7 +67,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ const handleAddCustom = useCallback(() => { setResources((resources: PodResourceEntry[]) => [ ...resources, - { type: '', request: '', limit: '' }, + { type: '', request: '1', limit: '' }, ]); }, [setResources]); @@ -97,16 +97,18 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ const handleCpuLimitToggle = useCallback( (enabled: boolean) => { setCpuLimitEnabled(enabled); + handleChange('cpu', 'limit', cpu.request); if (!enabled) { handleChange('cpu', 'limit', ''); } }, - [handleChange], + [cpu.request, handleChange], ); const handleCpuRequestToggle = useCallback( (enabled: boolean) => { setCpuRequestEnabled(enabled); + handleChange('cpu', 'request', '1'); if (!enabled) { handleChange('cpu', 'request', ''); handleCpuLimitToggle(enabled); @@ -118,16 +120,18 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ const handleMemoryLimitToggle = useCallback( (enabled: boolean) => { setMemoryLimitEnabled(enabled); + handleChange('memory', 'limit', memory.request); if (!enabled) { handleChange('memory', 'limit', ''); } }, - [handleChange], + [handleChange, memory.request], ); const handleMemoryRequestToggle = useCallback( (enabled: boolean) => { setMemoryRequestEnabled(enabled); + handleChange('memory', 'request', '1Mi'); if (!enabled) { handleChange('memory', 'request', ''); handleMemoryLimitToggle(enabled); @@ -159,9 +163,9 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ id: 'workspace-kind-podconfig-resource', }} titleDescription={ -

- Optional: Configure k8s Pod Resource Requests & Limits -

+ + Optional: Configure k8s Pod Resource Requests & Limits. + } /> } @@ -190,7 +194,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={cpu.request} onChange={(value) => handleChange('cpu', 'request', value)} placeholder="e.g. 1" - min={0} + min={1} aria-label="CPU request" isDisabled={!cpuRequestEnabled} /> @@ -201,7 +205,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={memory.request} onChange={(value) => handleChange('memory', 'request', value)} placeholder="e.g. 512Mi" - min={0} + min={1} aria-label="Memory request" isDisabled={!memoryRequestEnabled} /> @@ -232,7 +236,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={cpu.limit} onChange={(value) => handleChange('cpu', 'limit', value)} placeholder="e.g. 2" - min={0} + min={parseFloat(cpu.request)} step={1} aria-label="CPU limit" isDisabled={!cpuRequestEnabled || !cpuLimitEnabled} @@ -244,7 +248,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={memory.limit} onChange={(value) => handleChange('memory', 'limit', value)} placeholder="e.g. 1Gi" - min={0} + min={parseFloat(memory.request)} aria-label="Memory limit" isDisabled={!memoryRequestEnabled || !memoryLimitEnabled} /> @@ -278,7 +282,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={res.request} onChange={(value) => handleChange(res.type, 'request', value)} placeholder="Request" - min={0} + min={1} aria-label="Custom resource request" /> @@ -287,7 +291,10 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ id={`custom-limit-switch-${idx}`} label="Set Limit" isChecked={customLimitsEnabled[idx] || false} - onChange={(_event, checked) => handleCustomLimitToggle(idx, checked)} + onChange={(_event, checked) => { + handleChange(res.type, 'limit', res.request); + handleCustomLimitToggle(idx, checked); + }} aria-label={`Enable limit for ${res.type || 'custom resource'}`} /> @@ -297,7 +304,7 @@ export const WorkspaceKindFormPodConfigResource: React.FC = ({ value={res.limit} onChange={(value) => handleChange(res.type, 'limit', value)} placeholder="Limit" - min={0} + min={parseFloat(res.request)} isDisabled={!customLimitsEnabled[idx]} aria-label={`${res.type || 'Custom resource'} limit`} /> diff --git a/workspaces/frontend/src/app/routes.ts b/workspaces/frontend/src/app/routes.ts index ee0cf4d5..146b6686 100644 --- a/workspaces/frontend/src/app/routes.ts +++ b/workspaces/frontend/src/app/routes.ts @@ -6,6 +6,7 @@ export const AppRoutePaths = { workspaceKinds: '/workspacekinds', workspaceKindSummary: '/workspacekinds/:kind/summary', workspaceKindCreate: '/workspacekinds/create', + workspaceKindEdit: '/workspacekinds/edit', } satisfies Record; export type AppRoute = (typeof AppRoutePaths)[keyof typeof AppRoutePaths]; @@ -31,6 +32,7 @@ export type RouteParamsMap = { kind: string; }; workspaceKindCreate: undefined; + workspaceKindEdit: undefined; }; /** @@ -62,6 +64,10 @@ export type RouteStateMap = { workspaceKindCreate: { namespace: string; }; + workspaceKindEdit: { + namespace: string; + workspaceKindName: string; + }; }; /** @@ -82,4 +88,5 @@ export type RouteSearchParamsMap = { workspaceKinds: undefined; workspaceKindSummary: undefined; workspaceKindCreate: undefined; + workspaceKindEdit: undefined; }; diff --git a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts index a732b4af..bf90b549 100644 --- a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts +++ b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts @@ -13,6 +13,30 @@ export enum YesNoValue { No = 'No', } +const RESOURCE_UNIT_CONFIG = { + cpu: CPU_UNITS, + memory: MEMORY_UNITS_FOR_PARSING, + gpu: OTHER, +}; + +export const parseResourceValue = ( + value: string, + resourceType: ResourceType, +): [number | undefined, { name: string; unit: string } | undefined] => { + const units = RESOURCE_UNIT_CONFIG[resourceType]; + return splitValueUnit(value, units); +}; + +export const extractNumericValue = (value: string, resourceType: ResourceType): string => { + const [numericValue] = parseResourceValue(value, resourceType); + return String(numericValue || ''); +}; + +export const extractUnit = (value: string, resourceType: ResourceType): string => { + const [, unit] = parseResourceValue(value, resourceType); + return unit?.unit || ''; +}; + export const extractResourceValue = ( workspace: Workspace, resourceType: ResourceType, @@ -24,18 +48,13 @@ export const formatResourceValue = (v: string | undefined, resourceType?: Resour if (v === undefined) { return '-'; } - switch (resourceType) { - case 'cpu': { - const [cpuValue, cpuUnit] = splitValueUnit(v, CPU_UNITS); - return `${cpuValue ?? ''} ${cpuUnit.name}`; - } - case 'memory': { - const [memoryValue, memoryUnit] = splitValueUnit(v, MEMORY_UNITS_FOR_PARSING); - return `${memoryValue ?? ''} ${memoryUnit.name}`; - } - default: - return v; + + if (!resourceType) { + return v; } + + const [value, unit] = parseResourceValue(v, resourceType); + return `${value || ''} ${unit?.name || ''}`.trim(); }; export const formatResourceFromWorkspace = (