diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreation.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreation.tsx index 806b6b4..92606cb 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreation.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreation.tsx @@ -16,6 +16,7 @@ import { WorkspaceCreationImageSelection } from '~/app/pages/Workspaces/Creation import { WorkspaceCreationKindSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindSelection'; import { WorkspaceCreationPropertiesSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPropertiesSelection'; import { WorkspaceCreationPodConfigSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPodConfigSelection'; +import { WorkspaceImage, WorkspaceKind } from '~/shared/types'; enum WorkspaceCreationSteps { KindSelection, @@ -28,6 +29,8 @@ const WorkspaceCreation: React.FunctionComponent = () => { const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(WorkspaceCreationSteps.KindSelection); + const [selectedKind, setSelectedKind] = useState(); + const [selectedImage, setSelectedImage] = useState(); const getStepVariant = useCallback( (step: WorkspaceCreationSteps) => { @@ -54,6 +57,11 @@ const WorkspaceCreation: React.FunctionComponent = () => { navigate('/workspaces'); }, [navigate]); + const onSelectWorkspaceKind = useCallback((newWorkspaceKind: WorkspaceKind) => { + setSelectedKind(newWorkspaceKind); + setSelectedImage(undefined); + }, []); + return ( <> @@ -124,9 +132,18 @@ const WorkspaceCreation: React.FunctionComponent = () => { - {currentStep === WorkspaceCreationSteps.KindSelection && } + {currentStep === WorkspaceCreationSteps.KindSelection && ( + + )} {currentStep === WorkspaceCreationSteps.ImageSelection && ( - + )} {currentStep === WorkspaceCreationSteps.PodConfigSelection && ( diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageDetails.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageDetails.tsx new file mode 100644 index 0000000..30fcdae --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageDetails.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Title } from '@patternfly/react-core'; +import { WorkspaceImage } from '~/shared/types'; + +type WorkspaceCreationImageDetailsProps = { + workspaceImage?: WorkspaceImage; +}; + +export const WorkspaceCreationImageDetails: React.FunctionComponent< + WorkspaceCreationImageDetailsProps +> = ({ workspaceImage }) => ( + <> + {!workspaceImage &&

Select an image to view its details here.

} + + {workspaceImage && ( + <> + Image + {workspaceImage.displayName} + {Object.keys(workspaceImage.labels).map((labelKey) => ( +

+ {labelKey}={workspaceImage.labels[labelKey]} +

+ ))} + + )} + +); diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageFilter.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageFilter.tsx new file mode 100644 index 0000000..8ee2713 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageFilter.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useMemo } from 'react'; +import { + FilterSidePanel, + FilterSidePanelCategory, + FilterSidePanelCategoryItem, +} from '@patternfly/react-catalog-view-extension'; +import { WorkspaceImage } from '~/shared/types'; +import '@patternfly/react-catalog-view-extension/dist/css/react-catalog-view-extension.css'; + +type WorkspaceCreationImageFilterProps = { + images: WorkspaceImage[]; + selectedLabels: Map>; + onSelect: (labels: Map>) => void; +}; + +export const WorkspaceCreationImageFilter: React.FunctionComponent< + WorkspaceCreationImageFilterProps +> = ({ images, selectedLabels, onSelect }) => { + const filterMap = useMemo(() => { + const labelsMap = new Map>(); + images.forEach((image) => { + Object.keys(image.labels).forEach((labelKey) => { + const labelValue = image.labels[labelKey]; + if (!labelsMap.has(labelKey)) { + labelsMap.set(labelKey, new Set()); + } + labelsMap.get(labelKey).add(labelValue); + }); + }); + return labelsMap; + }, [images]); + + const isChecked = useCallback( + (label, labelValue) => selectedLabels.get(label)?.has(labelValue), + [selectedLabels], + ); + + const onChange = useCallback( + (labelKey, labelValue, event) => { + const { checked } = event.currentTarget; + const newSelectedLabels: Map> = new Map(selectedLabels); + + if (checked) { + if (!newSelectedLabels.has(labelKey)) { + newSelectedLabels.set(labelKey, new Set()); + } + newSelectedLabels.get(labelKey).add(labelValue); + } else { + const labelValues = newSelectedLabels.get(labelKey); + labelValues.delete(labelValue); + if (labelValues.size === 0) { + newSelectedLabels.delete(labelKey); + } + } + + onSelect(newSelectedLabels); + console.error(newSelectedLabels); + }, + [selectedLabels, onSelect], + ); + + return ( + + {[...filterMap.keys()].map((label) => ( + + {Array.from(filterMap.get(label).values()).map((labelValue) => ( + onChange(label, labelValue, e)} + > + {labelValue} + + ))} + + ))} + + ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageList.tsx new file mode 100644 index 0000000..dffae37 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageList.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + CardTitle, + Gallery, + PageSection, + Toolbar, + ToolbarContent, + Card, + CardHeader, + EmptyState, + EmptyStateBody, + CardBody, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import { WorkspaceImage } from '~/shared/types'; +import Filter, { FilteredColumn } from '~/shared/components/Filter'; + +type WorkspaceCreationImageListProps = { + images: WorkspaceImage[]; + selectedLabels: Map>; + selectedImage: WorkspaceImage | undefined; + onSelect: (workspaceImage: WorkspaceImage) => void; +}; + +export const WorkspaceCreationImageList: React.FunctionComponent< + WorkspaceCreationImageListProps +> = ({ images, selectedLabels, selectedImage, onSelect }) => { + const [workspaceImages, setWorkspaceImages] = useState(images); + const [filters, setFilters] = useState([]); + + const filterableColumns = useMemo( + () => ({ + name: 'Name', + }), + [], + ); + + const getFilteredWorkspaceImagesByLabels = useCallback( + (unfilteredImages: WorkspaceImage[]) => + unfilteredImages.filter((image) => + Object.keys(image.labels).reduce((accumulator, labelKey) => { + const labelValue = image.labels[labelKey]; + if (selectedLabels.has(labelKey)) { + return accumulator && selectedLabels.get(labelKey).has(labelValue); + } + return accumulator; + }, true), + ), + [selectedLabels], + ); + + const onChange = useCallback( + (event: React.FormEvent) => { + const newSelectedWorkspaceImage = workspaceImages.find( + (image) => image.displayName === event.currentTarget.name, + ); + onSelect(newSelectedWorkspaceImage); + }, + [workspaceImages, onSelect], + ); + + useEffect(() => { + // Search name with search value + let filteredWorkspaceImages = images; + + filters.forEach((filter) => { + let searchValueInput: RegExp; + try { + searchValueInput = new RegExp(filter.value, 'i'); + } catch { + searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + } + + filteredWorkspaceImages = filteredWorkspaceImages.filter((image) => { + if (filter.value === '') { + return true; + } + switch (filter.columnName) { + case filterableColumns.name: + return ( + image.id.search(searchValueInput) >= 0 || + image.displayName.search(searchValueInput) >= 0 + ); + default: + return true; + } + }); + }); + + setWorkspaceImages(getFilteredWorkspaceImagesByLabels(filteredWorkspaceImages)); + }, [filterableColumns, filters, images, selectedLabels, getFilteredWorkspaceImagesByLabels]); + + return ( + <> + + + + + + + + + {workspaceImages.length === 0 && ( + + + No results match the filter criteria. Clear all filters and try again. + + + )} + {workspaceImages.length > 0 && ( + + {workspaceImages.map((image) => ( + + + {image.displayName} + {image.id} + + + ))} + + )} + + + ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageSelection.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageSelection.tsx index 43a5654..14379b3 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageSelection.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationImageSelection.tsx @@ -1,10 +1,56 @@ import * as React from 'react'; -import { Content } from '@patternfly/react-core'; +import { Content, Divider, Split, SplitItem } from '@patternfly/react-core'; +import { useMemo, useState } from 'react'; +import { WorkspaceImage } from '~/shared/types'; +import { WorkspaceCreationImageDetails } from '~/app/pages/Workspaces/Creation/WorkspaceCreationImageDetails'; +import { WorkspaceCreationImageList } from '~/app/pages/Workspaces/Creation/WorkspaceCreationImageList'; +import { WorkspaceCreationImageFilter } from '~/app/pages/Workspaces/Creation/WorkspaceCreationImageFilter'; -const WorkspaceCreationImageSelection: React.FunctionComponent = () => ( - -

Select a workspace image and image version to use for the workspace.

-
-); +interface WorkspaceCreationImageSelectionProps { + images: WorkspaceImage[]; + selectedImage: WorkspaceImage | undefined; + onSelect: (image: WorkspaceImage) => void; +} + +const WorkspaceCreationImageSelection: React.FunctionComponent< + WorkspaceCreationImageSelectionProps +> = ({ images, selectedImage, onSelect }) => { + const [selectedLabels, setSelectedLabels] = useState>>(new Map()); + + const imageFilterContent = useMemo( + () => ( + + ), + [images, selectedLabels, setSelectedLabels], + ); + + const imageDetailsContent = useMemo( + () => , + [selectedImage], + ); + + return ( + +

Select a workspace image and image version to use for the workspace.

+ + + {imageFilterContent} + + + + {imageDetailsContent} + +
+ ); +}; export { WorkspaceCreationImageSelection }; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationKindList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationKindList.tsx index 914d5e5..1de856c 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationKindList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/WorkspaceCreationKindList.tsx @@ -17,15 +17,16 @@ import Filter, { FilteredColumn } from '~/shared/components/Filter'; type WorkspaceCreationKindListProps = { allWorkspaceKinds: WorkspaceKind[]; + selectedKind: WorkspaceKind | undefined; onSelect: (workspaceKind: WorkspaceKind) => void; }; export const WorkspaceCreationKindList: React.FunctionComponent = ({ allWorkspaceKinds, + selectedKind, onSelect, }) => { const [workspaceKinds, setWorkspaceKinds] = useState(allWorkspaceKinds); - const [selectedWorkspaceKind, setSelectedWorkspaceKind] = useState(); const filterableColumns = useMemo( () => ({ @@ -71,7 +72,6 @@ export const WorkspaceCreationKindList: React.FunctionComponent kind.name === event.currentTarget.name, ); - setSelectedWorkspaceKind(newSelectedWorkspaceKind); onSelect(newSelectedWorkspaceKind); }, [workspaceKinds, onSelect], @@ -106,11 +106,11 @@ export const WorkspaceCreationKindList: React.FunctionComponent { +interface WorkspaceCreationKindSelectionProps { + selectedKind: WorkspaceKind | undefined; + onSelect: (kind: WorkspaceKind) => void; +} + +const WorkspaceCreationKindSelection: React.FunctionComponent< + WorkspaceCreationKindSelectionProps +> = ({ selectedKind, onSelect }) => { /* Replace mocks below for BFF call */ const mockedWorkspaceKind: WorkspaceKind = useMemo( () => ({ @@ -47,7 +54,7 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => { { id: 'jupyterlab_scipy_190', displayName: 'jupyter-scipy:v1.9.0', - labels: { pythonVersion: '3.11' }, + labels: { pythonVersion: '3.12' }, hidden: true, redirect: { to: 'jupyterlab_scipy_200', @@ -57,6 +64,32 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => { }, }, }, + { + id: 'jupyterlab_scipy_200', + displayName: 'jupyter-scipy:v2.0.0', + labels: { pythonVersion: '3.12' }, + hidden: true, + redirect: { + to: 'jupyterlab_scipy_210', + message: { + text: 'This update will change...', + level: 'Warning', + }, + }, + }, + { + id: 'jupyterlab_scipy_210', + displayName: 'jupyter-scipy:v2.1.0', + labels: { pythonVersion: '3.13' }, + hidden: true, + redirect: { + to: 'jupyterlab_scipy_220', + message: { + text: 'This update will change...', + level: 'Warning', + }, + }, + }, ], }, podConfig: { @@ -104,8 +137,6 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => { return kinds; }, [mockedWorkspaceKind]); - const [selectedKind, setSelectedKind] = useState(); - const kindDetailsContent = useMemo( () => , [selectedKind], @@ -119,7 +150,8 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => { setSelectedKind(workspaceKind)} + selectedKind={selectedKind} + onSelect={onSelect} /> {kindDetailsContent} diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index ecce05b..b87a09e 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -6,6 +6,39 @@ export interface WorkspaceLogo { url: string; } +export interface WorkspaceImage { + id: string; + displayName: string; + labels: { + pythonVersion: string; + }; + hidden: boolean; + redirect?: { + to: string; + message: { + text: string; + level: string; + }; + }; +} + +export interface WorkspacePodConfig { + id: string; + displayName: string; + description: string; + labels: { + cpu: string; + memory: string; + }; + redirect?: { + to: string; + message: { + text: string; + level: string; + }; + }; +} + export interface WorkspaceKind { name: string; displayName: string; @@ -30,40 +63,11 @@ export interface WorkspaceKind { options: { imageConfig: { default: string; - values: { - id: string; - displayName: string; - labels: { - pythonVersion: string; - }; - hidden: boolean; - redirect?: { - to: string; - message: { - text: string; - level: string; - }; - }; - }[]; + values: WorkspaceImage[]; }; podConfig: { default: string; - values: { - id: string; - displayName: string; - description: string; - labels: { - cpu: string; - memory: string; - }; - redirect?: { - to: string; - message: { - text: string; - level: string; - }; - }; - }[]; + values: WorkspacePodConfig[]; }; }; };