feat(ws): add workspace creation image step frontend (#241)

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>
This commit is contained in:
Paulo Rego 2025-03-26 17:50:18 -03:00 committed by GitHub
parent f0638441d6
commit 657ac9f56f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 397 additions and 49 deletions

View File

@ -16,6 +16,7 @@ import { WorkspaceCreationImageSelection } from '~/app/pages/Workspaces/Creation
import { WorkspaceCreationKindSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindSelection'; import { WorkspaceCreationKindSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindSelection';
import { WorkspaceCreationPropertiesSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPropertiesSelection'; import { WorkspaceCreationPropertiesSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPropertiesSelection';
import { WorkspaceCreationPodConfigSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPodConfigSelection'; import { WorkspaceCreationPodConfigSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPodConfigSelection';
import { WorkspaceImage, WorkspaceKind } from '~/shared/types';
enum WorkspaceCreationSteps { enum WorkspaceCreationSteps {
KindSelection, KindSelection,
@ -28,6 +29,8 @@ const WorkspaceCreation: React.FunctionComponent = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(WorkspaceCreationSteps.KindSelection); const [currentStep, setCurrentStep] = useState(WorkspaceCreationSteps.KindSelection);
const [selectedKind, setSelectedKind] = useState<WorkspaceKind>();
const [selectedImage, setSelectedImage] = useState<WorkspaceImage>();
const getStepVariant = useCallback( const getStepVariant = useCallback(
(step: WorkspaceCreationSteps) => { (step: WorkspaceCreationSteps) => {
@ -54,6 +57,11 @@ const WorkspaceCreation: React.FunctionComponent = () => {
navigate('/workspaces'); navigate('/workspaces');
}, [navigate]); }, [navigate]);
const onSelectWorkspaceKind = useCallback((newWorkspaceKind: WorkspaceKind) => {
setSelectedKind(newWorkspaceKind);
setSelectedImage(undefined);
}, []);
return ( return (
<> <>
<PageGroup stickyOnBreakpoint={{ default: 'top' }}> <PageGroup stickyOnBreakpoint={{ default: 'top' }}>
@ -124,9 +132,18 @@ const WorkspaceCreation: React.FunctionComponent = () => {
</PageSection> </PageSection>
</PageGroup> </PageGroup>
<PageSection isFilled> <PageSection isFilled>
{currentStep === WorkspaceCreationSteps.KindSelection && <WorkspaceCreationKindSelection />} {currentStep === WorkspaceCreationSteps.KindSelection && (
<WorkspaceCreationKindSelection
selectedKind={selectedKind}
onSelect={onSelectWorkspaceKind}
/>
)}
{currentStep === WorkspaceCreationSteps.ImageSelection && ( {currentStep === WorkspaceCreationSteps.ImageSelection && (
<WorkspaceCreationImageSelection /> <WorkspaceCreationImageSelection
selectedImage={selectedImage}
images={selectedKind?.podTemplate.options.imageConfig.values ?? []}
onSelect={setSelectedImage}
/>
)} )}
{currentStep === WorkspaceCreationSteps.PodConfigSelection && ( {currentStep === WorkspaceCreationSteps.PodConfigSelection && (
<WorkspaceCreationPodConfigSelection /> <WorkspaceCreationPodConfigSelection />

View File

@ -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 && <p>Select an image to view its details here.</p>}
{workspaceImage && (
<>
<Title headingLevel="h6">Image</Title>
<Title headingLevel="h3">{workspaceImage.displayName}</Title>
{Object.keys(workspaceImage.labels).map((labelKey) => (
<p key={labelKey}>
{labelKey}={workspaceImage.labels[labelKey]}
</p>
))}
</>
)}
</>
);

View File

@ -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<string, Set<string>>;
onSelect: (labels: Map<string, Set<string>>) => void;
};
export const WorkspaceCreationImageFilter: React.FunctionComponent<
WorkspaceCreationImageFilterProps
> = ({ images, selectedLabels, onSelect }) => {
const filterMap = useMemo(() => {
const labelsMap = new Map<string, Set<string>>();
images.forEach((image) => {
Object.keys(image.labels).forEach((labelKey) => {
const labelValue = image.labels[labelKey];
if (!labelsMap.has(labelKey)) {
labelsMap.set(labelKey, new Set<string>());
}
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<string, Set<string>> = new Map(selectedLabels);
if (checked) {
if (!newSelectedLabels.has(labelKey)) {
newSelectedLabels.set(labelKey, new Set<string>());
}
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 (
<FilterSidePanel id="filter-panel">
{[...filterMap.keys()].map((label) => (
<FilterSidePanelCategory key={label} title={label}>
{Array.from(filterMap.get(label).values()).map((labelValue) => (
<FilterSidePanelCategoryItem
key={`${label}|||${labelValue}`}
checked={isChecked(label, labelValue)}
onClick={(e) => onChange(label, labelValue, e)}
>
{labelValue}
</FilterSidePanelCategoryItem>
))}
</FilterSidePanelCategory>
))}
</FilterSidePanel>
);
};

View File

@ -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<string, Set<string>>;
selectedImage: WorkspaceImage | undefined;
onSelect: (workspaceImage: WorkspaceImage) => void;
};
export const WorkspaceCreationImageList: React.FunctionComponent<
WorkspaceCreationImageListProps
> = ({ images, selectedLabels, selectedImage, onSelect }) => {
const [workspaceImages, setWorkspaceImages] = useState<WorkspaceImage[]>(images);
const [filters, setFilters] = useState<FilteredColumn[]>([]);
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<HTMLInputElement>) => {
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 (
<>
<PageSection>
<Toolbar id="toolbar-group-types">
<ToolbarContent>
<Filter
id="filter-workspace-images"
onFilter={setFilters}
columnNames={filterableColumns}
/>
</ToolbarContent>
</Toolbar>
</PageSection>
<PageSection isFilled>
{workspaceImages.length === 0 && (
<EmptyState titleText="No results found" headingLevel="h4" icon={SearchIcon}>
<EmptyStateBody>
No results match the filter criteria. Clear all filters and try again.
</EmptyStateBody>
</EmptyState>
)}
{workspaceImages.length > 0 && (
<Gallery hasGutter aria-label="Selectable card container">
{workspaceImages.map((image) => (
<Card
isCompact
isSelectable
key={image.id}
id={image.id.replace(/ /g, '-')}
isSelected={image.id === selectedImage?.id}
>
<CardHeader
selectableActions={{
selectableActionId: `selectable-actions-item-${image.id.replace(/ /g, '-')}`,
selectableActionAriaLabelledby: image.displayName.replace(/ /g, '-'),
name: image.displayName,
variant: 'single',
onChange,
}}
>
<CardTitle>{image.displayName}</CardTitle>
<CardBody>{image.id}</CardBody>
</CardHeader>
</Card>
))}
</Gallery>
)}
</PageSection>
</>
);
};

View File

@ -1,10 +1,56 @@
import * as React from 'react'; 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 = () => ( interface WorkspaceCreationImageSelectionProps {
<Content> images: WorkspaceImage[];
<p>Select a workspace image and image version to use for the workspace.</p> selectedImage: WorkspaceImage | undefined;
</Content> onSelect: (image: WorkspaceImage) => void;
); }
const WorkspaceCreationImageSelection: React.FunctionComponent<
WorkspaceCreationImageSelectionProps
> = ({ images, selectedImage, onSelect }) => {
const [selectedLabels, setSelectedLabels] = useState<Map<string, Set<string>>>(new Map());
const imageFilterContent = useMemo(
() => (
<WorkspaceCreationImageFilter
images={images}
selectedLabels={selectedLabels}
onSelect={setSelectedLabels}
/>
),
[images, selectedLabels, setSelectedLabels],
);
const imageDetailsContent = useMemo(
() => <WorkspaceCreationImageDetails workspaceImage={selectedImage} />,
[selectedImage],
);
return (
<Content style={{ height: '100%' }}>
<p>Select a workspace image and image version to use for the workspace.</p>
<Divider />
<Split hasGutter>
<SplitItem style={{ minWidth: '200px' }}>{imageFilterContent}</SplitItem>
<SplitItem isFilled>
<WorkspaceCreationImageList
images={images}
selectedLabels={selectedLabels}
selectedImage={selectedImage}
onSelect={onSelect}
/>
</SplitItem>
<SplitItem style={{ minWidth: '200px' }}>{imageDetailsContent}</SplitItem>
</Split>
</Content>
);
};
export { WorkspaceCreationImageSelection }; export { WorkspaceCreationImageSelection };

View File

@ -17,15 +17,16 @@ import Filter, { FilteredColumn } from '~/shared/components/Filter';
type WorkspaceCreationKindListProps = { type WorkspaceCreationKindListProps = {
allWorkspaceKinds: WorkspaceKind[]; allWorkspaceKinds: WorkspaceKind[];
selectedKind: WorkspaceKind | undefined;
onSelect: (workspaceKind: WorkspaceKind) => void; onSelect: (workspaceKind: WorkspaceKind) => void;
}; };
export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreationKindListProps> = ({ export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreationKindListProps> = ({
allWorkspaceKinds, allWorkspaceKinds,
selectedKind,
onSelect, onSelect,
}) => { }) => {
const [workspaceKinds, setWorkspaceKinds] = useState<WorkspaceKind[]>(allWorkspaceKinds); const [workspaceKinds, setWorkspaceKinds] = useState<WorkspaceKind[]>(allWorkspaceKinds);
const [selectedWorkspaceKind, setSelectedWorkspaceKind] = useState<WorkspaceKind>();
const filterableColumns = useMemo( const filterableColumns = useMemo(
() => ({ () => ({
@ -71,7 +72,6 @@ export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreatio
const newSelectedWorkspaceKind = workspaceKinds.find( const newSelectedWorkspaceKind = workspaceKinds.find(
(kind) => kind.name === event.currentTarget.name, (kind) => kind.name === event.currentTarget.name,
); );
setSelectedWorkspaceKind(newSelectedWorkspaceKind);
onSelect(newSelectedWorkspaceKind); onSelect(newSelectedWorkspaceKind);
}, },
[workspaceKinds, onSelect], [workspaceKinds, onSelect],
@ -106,11 +106,11 @@ export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreatio
isSelectable isSelectable
key={kind.name} key={kind.name}
id={kind.name.replace(/ /g, '-')} id={kind.name.replace(/ /g, '-')}
isSelected={kind.name === selectedWorkspaceKind?.name} isSelected={kind.name === selectedKind?.name}
> >
<CardHeader <CardHeader
selectableActions={{ selectableActions={{
selectableActionId: `selectable-actions-item-${kind.name}`, selectableActionId: `selectable-actions-item-${kind.name.replace(/ /g, '-')}`,
selectableActionAriaLabelledby: kind.name.replace(/ /g, '-'), selectableActionAriaLabelledby: kind.name.replace(/ /g, '-'),
name: kind.name, name: kind.name,
variant: 'single', variant: 'single',

View File

@ -1,11 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { Content, Divider, Split, SplitItem } from '@patternfly/react-core'; import { Content, Divider, Split, SplitItem } from '@patternfly/react-core';
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { WorkspaceKind } from '~/shared/types'; import { WorkspaceKind } from '~/shared/types';
import { WorkspaceCreationKindDetails } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindDetails'; import { WorkspaceCreationKindDetails } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindDetails';
import { WorkspaceCreationKindList } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindList'; import { WorkspaceCreationKindList } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindList';
const WorkspaceCreationKindSelection: React.FunctionComponent = () => { interface WorkspaceCreationKindSelectionProps {
selectedKind: WorkspaceKind | undefined;
onSelect: (kind: WorkspaceKind) => void;
}
const WorkspaceCreationKindSelection: React.FunctionComponent<
WorkspaceCreationKindSelectionProps
> = ({ selectedKind, onSelect }) => {
/* Replace mocks below for BFF call */ /* Replace mocks below for BFF call */
const mockedWorkspaceKind: WorkspaceKind = useMemo( const mockedWorkspaceKind: WorkspaceKind = useMemo(
() => ({ () => ({
@ -47,7 +54,7 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => {
{ {
id: 'jupyterlab_scipy_190', id: 'jupyterlab_scipy_190',
displayName: 'jupyter-scipy:v1.9.0', displayName: 'jupyter-scipy:v1.9.0',
labels: { pythonVersion: '3.11' }, labels: { pythonVersion: '3.12' },
hidden: true, hidden: true,
redirect: { redirect: {
to: 'jupyterlab_scipy_200', 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: { podConfig: {
@ -104,8 +137,6 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => {
return kinds; return kinds;
}, [mockedWorkspaceKind]); }, [mockedWorkspaceKind]);
const [selectedKind, setSelectedKind] = useState<WorkspaceKind>();
const kindDetailsContent = useMemo( const kindDetailsContent = useMemo(
() => <WorkspaceCreationKindDetails workspaceKind={selectedKind} />, () => <WorkspaceCreationKindDetails workspaceKind={selectedKind} />,
[selectedKind], [selectedKind],
@ -119,7 +150,8 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => {
<SplitItem isFilled> <SplitItem isFilled>
<WorkspaceCreationKindList <WorkspaceCreationKindList
allWorkspaceKinds={allWorkspaceKinds} allWorkspaceKinds={allWorkspaceKinds}
onSelect={(workspaceKind) => setSelectedKind(workspaceKind)} selectedKind={selectedKind}
onSelect={onSelect}
/> />
</SplitItem> </SplitItem>
<SplitItem style={{ minWidth: '200px' }}>{kindDetailsContent}</SplitItem> <SplitItem style={{ minWidth: '200px' }}>{kindDetailsContent}</SplitItem>

View File

@ -6,6 +6,39 @@ export interface WorkspaceLogo {
url: string; 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 { export interface WorkspaceKind {
name: string; name: string;
displayName: string; displayName: string;
@ -30,40 +63,11 @@ export interface WorkspaceKind {
options: { options: {
imageConfig: { imageConfig: {
default: string; default: string;
values: { values: WorkspaceImage[];
id: string;
displayName: string;
labels: {
pythonVersion: string;
};
hidden: boolean;
redirect?: {
to: string;
message: {
text: string;
level: string;
};
};
}[];
}; };
podConfig: { podConfig: {
default: string; default: string;
values: { values: WorkspacePodConfig[];
id: string;
displayName: string;
description: string;
labels: {
cpu: string;
memory: string;
};
redirect?: {
to: string;
message: {
text: string;
level: string;
};
};
}[];
}; };
}; };
}; };