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 { 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<WorkspaceKind>();
const [selectedImage, setSelectedImage] = useState<WorkspaceImage>();
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 (
<>
<PageGroup stickyOnBreakpoint={{ default: 'top' }}>
@ -124,9 +132,18 @@ const WorkspaceCreation: React.FunctionComponent = () => {
</PageSection>
</PageGroup>
<PageSection isFilled>
{currentStep === WorkspaceCreationSteps.KindSelection && <WorkspaceCreationKindSelection />}
{currentStep === WorkspaceCreationSteps.KindSelection && (
<WorkspaceCreationKindSelection
selectedKind={selectedKind}
onSelect={onSelectWorkspaceKind}
/>
)}
{currentStep === WorkspaceCreationSteps.ImageSelection && (
<WorkspaceCreationImageSelection />
<WorkspaceCreationImageSelection
selectedImage={selectedImage}
images={selectedKind?.podTemplate.options.imageConfig.values ?? []}
onSelect={setSelectedImage}
/>
)}
{currentStep === WorkspaceCreationSteps.PodConfigSelection && (
<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 { 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 = () => (
<Content>
<p>Select a workspace image and image version to use for the workspace.</p>
</Content>
);
interface WorkspaceCreationImageSelectionProps {
images: WorkspaceImage[];
selectedImage: WorkspaceImage | undefined;
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 };

View File

@ -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<WorkspaceCreationKindListProps> = ({
allWorkspaceKinds,
selectedKind,
onSelect,
}) => {
const [workspaceKinds, setWorkspaceKinds] = useState<WorkspaceKind[]>(allWorkspaceKinds);
const [selectedWorkspaceKind, setSelectedWorkspaceKind] = useState<WorkspaceKind>();
const filterableColumns = useMemo(
() => ({
@ -71,7 +72,6 @@ export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreatio
const newSelectedWorkspaceKind = workspaceKinds.find(
(kind) => kind.name === event.currentTarget.name,
);
setSelectedWorkspaceKind(newSelectedWorkspaceKind);
onSelect(newSelectedWorkspaceKind);
},
[workspaceKinds, onSelect],
@ -106,11 +106,11 @@ export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreatio
isSelectable
key={kind.name}
id={kind.name.replace(/ /g, '-')}
isSelected={kind.name === selectedWorkspaceKind?.name}
isSelected={kind.name === selectedKind?.name}
>
<CardHeader
selectableActions={{
selectableActionId: `selectable-actions-item-${kind.name}`,
selectableActionId: `selectable-actions-item-${kind.name.replace(/ /g, '-')}`,
selectableActionAriaLabelledby: kind.name.replace(/ /g, '-'),
name: kind.name,
variant: 'single',

View File

@ -1,11 +1,18 @@
import * as React from 'react';
import { Content, Divider, Split, SplitItem } from '@patternfly/react-core';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { WorkspaceKind } from '~/shared/types';
import { WorkspaceCreationKindDetails } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindDetails';
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 */
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<WorkspaceKind>();
const kindDetailsContent = useMemo(
() => <WorkspaceCreationKindDetails workspaceKind={selectedKind} />,
[selectedKind],
@ -119,7 +150,8 @@ const WorkspaceCreationKindSelection: React.FunctionComponent = () => {
<SplitItem isFilled>
<WorkspaceCreationKindList
allWorkspaceKinds={allWorkspaceKinds}
onSelect={(workspaceKind) => setSelectedKind(workspaceKind)}
selectedKind={selectedKind}
onSelect={onSelect}
/>
</SplitItem>
<SplitItem style={{ minWidth: '200px' }}>{kindDetailsContent}</SplitItem>

View File

@ -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[];
};
};
};