feat(ws): add initial workspace creation wizard frontend (#227)

* feat(ws): add initial workspace creation wizard frontend

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>

* feat(ws): add initial workspace creation wizard frontend

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>

* card view style fixes (#2)

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

* fix(ws): fix scroll behavior with PageGroup

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

* feat(ws): add initial workspace creation wizard frontend

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>

* fix(ws): Apply flex-grow: 0 to page section

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

---------

Signed-off-by: paulovmr <832830+paulovmr@users.noreply.github.com>
Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>
Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>
This commit is contained in:
Paulo Rego 2025-03-19 17:44:42 -03:00 committed by GitHub
parent 19eca50561
commit 2bc10ecc20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 508 additions and 86 deletions

View File

@ -138,6 +138,16 @@ module.exports = (env) => {
'sass-loader',
],
},
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader',
],
include: [
path.resolve(relativeDir, 'node_modules/@patternfly/react-catalog-view-extension/dist/css/react-catalog-view-extension.css'),
]
},
],
},
output: {

View File

@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"@patternfly/react-catalog-view-extension": "^6.0.0",
"@patternfly/react-code-editor": "^6.0.0",
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-icons": "^6.0.0",
@ -4039,6 +4040,20 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@patternfly/react-catalog-view-extension": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-catalog-view-extension/-/react-catalog-view-extension-6.0.0.tgz",
"integrity": "sha512-AfvfJXjADXX1YZnWNZLe0mLgBepSnn4Xn9SQsvTeS+PLFFGgJQEsbhRyFXdGVJLVU/19+a/YOpHgZZeI2dus0A==",
"license": "MIT",
"dependencies": {
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-styles": "^6.0.0"
},
"peerDependencies": {
"react": "^17 || ^18",
"react-dom": "^17 || ^18"
}
},
"node_modules/@patternfly/react-code-editor": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@patternfly/react-code-editor/-/react-code-editor-6.1.0.tgz",

View File

@ -95,6 +95,7 @@
},
"dependencies": {
"@patternfly/react-code-editor": "^6.0.0",
"@patternfly/react-catalog-view-extension": "^6.0.0",
"@patternfly/react-core": "^6.0.0",
"@patternfly/react-icons": "^6.0.0",
"@patternfly/react-styles": "^6.0.0",

View File

@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { NotFound } from './pages/notFound/NotFound';
import { Debug } from './pages/Debug/Debug';
import { Workspaces } from './pages/Workspaces/Workspaces';
import { WorkspaceCreation } from './pages/Workspaces/Creation/WorkspaceCreation';
import '~/shared/style/MUI-theme.scss';
export const isNavDataGroup = (navItem: NavDataItem): navItem is NavDataGroup =>
@ -43,7 +44,7 @@ export const useAdminDebugSettings = (): NavDataItem[] => {
export const useNavData = (): NavDataItem[] => [
{
label: 'Notebooks',
path: '/',
path: '/workspaces',
},
...useAdminDebugSettings(),
];
@ -53,6 +54,8 @@ const AppRoutes: React.FC = () => {
return (
<Routes>
<Route path="/workspaces/create" element={<WorkspaceCreation />} />
<Route path="/workspaces" element={<Workspaces />} />
<Route path="/" element={<Workspaces />} />
<Route path="*" element={<NotFound />} />
{

View File

@ -0,0 +1,132 @@
import * as React from 'react';
import {
Button,
Content,
Flex,
FlexItem,
PageGroup,
PageSection,
ProgressStep,
ProgressStepper,
} from '@patternfly/react-core';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { WorkspaceCreationImageSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationImageSelection';
import { WorkspaceCreationKindSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationKindSelection';
import { WorkspaceCreationPropertiesSelection } from '~/app/pages/Workspaces/Creation/WorkspaceCreationPropertiesSelection';
enum WorkspaceCreationSteps {
KindSelection,
ImageSelection,
Properties,
}
const WorkspaceCreation: React.FunctionComponent = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(WorkspaceCreationSteps.KindSelection);
const getStepVariant = useCallback(
(step: WorkspaceCreationSteps) => {
if (step > currentStep) {
return 'pending';
}
if (step < currentStep) {
return 'success';
}
return 'info';
},
[currentStep],
);
const previousStep = useCallback(() => {
setCurrentStep(currentStep - 1);
}, [currentStep]);
const nextStep = useCallback(() => {
setCurrentStep(currentStep + 1);
}, [currentStep]);
const cancel = useCallback(() => {
navigate('/workspaces');
}, [navigate]);
return (
<>
<PageGroup stickyOnBreakpoint={{ default: 'top' }}>
<PageSection isFilled={false}>
<Content>
<h1>Create workspace</h1>
</Content>
<ProgressStepper aria-label="Workspace creation stepper">
<ProgressStep
variant={getStepVariant(WorkspaceCreationSteps.KindSelection)}
id="kind-selection-step"
titleId="kind-selection-step-title"
aria-label="Kind selection step"
>
Kind selection
</ProgressStep>
<ProgressStep
variant={getStepVariant(WorkspaceCreationSteps.ImageSelection)}
isCurrent
id="image-selection-step"
titleId="image-selection-step-title"
aria-label="Image selection step"
>
Image selection
</ProgressStep>
<ProgressStep
variant={getStepVariant(WorkspaceCreationSteps.Properties)}
id="properties-step"
titleId="properties-step-title"
aria-label="Properties step"
>
Properties
</ProgressStep>
</ProgressStepper>
</PageSection>
</PageGroup>
<PageSection isFilled>
{currentStep === WorkspaceCreationSteps.KindSelection && <WorkspaceCreationKindSelection />}
{currentStep === WorkspaceCreationSteps.ImageSelection && (
<WorkspaceCreationImageSelection />
)}
{currentStep === WorkspaceCreationSteps.Properties && (
<WorkspaceCreationPropertiesSelection />
)}
</PageSection>
<PageSection isFilled={false} stickyOnBreakpoint={{ default: 'bottom' }}>
<Flex>
<FlexItem>
<Button
variant="primary"
ouiaId="Primary"
onClick={previousStep}
isDisabled={currentStep === 0}
>
Previous
</Button>
</FlexItem>
<FlexItem>
<Button
variant="primary"
ouiaId="Primary"
onClick={nextStep}
isDisabled={currentStep === Object.keys(WorkspaceCreationSteps).length / 2 - 1}
>
Next
</Button>
</FlexItem>
<FlexItem>
<Button variant="link" isInline onClick={cancel}>
Cancel
</Button>
</FlexItem>
</Flex>
</PageSection>
</>
);
};
export { WorkspaceCreation };

View File

@ -0,0 +1,10 @@
import * as React from 'react';
import { Content } from '@patternfly/react-core';
const WorkspaceCreationImageSelection: React.FunctionComponent = () => (
<Content>
<p>Select a workspace image and image version to use for the workspace.</p>
</Content>
);
export { WorkspaceCreationImageSelection };

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Title } from '@patternfly/react-core';
import { WorkspaceKind } from '~/shared/types';
type WorkspaceCreationKindDetailsProps = {
workspaceKind?: WorkspaceKind;
};
export const WorkspaceCreationKindDetails: React.FunctionComponent<
WorkspaceCreationKindDetailsProps
> = ({ workspaceKind }) => (
<>
{!workspaceKind && <p>Select a workspace kind to view its details here.</p>}
{workspaceKind && (
<>
<Title headingLevel="h6">Workspace kind</Title>
<Title headingLevel="h3">{workspaceKind.name}</Title>
<p>{workspaceKind.description}</p>
</>
)}
</>
);

View File

@ -0,0 +1,131 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
CardBody,
CardTitle,
Gallery,
PageSection,
Toolbar,
ToolbarContent,
Card,
CardHeader,
EmptyState,
EmptyStateBody,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon';
import { WorkspaceKind } from '~/shared/types';
import Filter, { FilteredColumn } from '~/shared/components/Filter';
type WorkspaceCreationKindListProps = {
allWorkspaceKinds: WorkspaceKind[];
onSelect: (workspaceKind: WorkspaceKind) => void;
};
export const WorkspaceCreationKindList: React.FunctionComponent<WorkspaceCreationKindListProps> = ({
allWorkspaceKinds,
onSelect,
}) => {
const [workspaceKinds, setWorkspaceKinds] = useState<WorkspaceKind[]>(allWorkspaceKinds);
const [selectedWorkspaceKind, setSelectedWorkspaceKind] = useState<WorkspaceKind>();
const filterableColumns = useMemo(
() => ({
name: 'Name',
}),
[],
);
const onFilter = useCallback(
(filters: FilteredColumn[]) => {
// Search name with search value
let filteredWorkspaceKinds = allWorkspaceKinds;
filters.forEach((filter) => {
let searchValueInput: RegExp;
try {
searchValueInput = new RegExp(filter.value, 'i');
} catch {
searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
}
filteredWorkspaceKinds = filteredWorkspaceKinds.filter((kind) => {
if (filter.value === '') {
return true;
}
switch (filter.columnName) {
case filterableColumns.name:
return (
kind.name.search(searchValueInput) >= 0 ||
kind.displayName.search(searchValueInput) >= 0
);
default:
return true;
}
});
});
setWorkspaceKinds(filteredWorkspaceKinds);
},
[filterableColumns, allWorkspaceKinds],
);
const onChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
const newSelectedWorkspaceKind = workspaceKinds.find(
(kind) => kind.name === event.currentTarget.name,
);
setSelectedWorkspaceKind(newSelectedWorkspaceKind);
onSelect(newSelectedWorkspaceKind);
},
[workspaceKinds, onSelect],
);
return (
<>
<PageSection>
<Toolbar id="toolbar-group-types">
<ToolbarContent>
<Filter
id="filter-workspace-kinds"
onFilter={onFilter}
columnNames={filterableColumns}
/>
</ToolbarContent>
</Toolbar>
</PageSection>
<PageSection isFilled>
{workspaceKinds.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>
)}
{workspaceKinds.length > 0 && (
<Gallery hasGutter aria-label="Selectable card container">
{workspaceKinds.map((kind) => (
<Card
isCompact
isSelectable
key={kind.name}
id={kind.name.replace(/ /g, '-')}
isSelected={kind.name === selectedWorkspaceKind?.name}
>
<CardHeader
selectableActions={{
selectableActionId: `selectable-actions-item-${kind.name}`,
selectableActionAriaLabelledby: kind.name.replace(/ /g, '-'),
name: kind.name,
variant: 'single',
onChange,
}}
>
<img src={kind.icon.url} alt={`${kind.name} icon`} style={{ maxWidth: '60px' }} />
<CardTitle>{kind.displayName}</CardTitle>
</CardHeader>
<CardBody>{kind.description}</CardBody>
</Card>
))}
</Gallery>
)}
</PageSection>
</>
);
};

View File

@ -0,0 +1,131 @@
import * as React from 'react';
import { Content, Divider, Split, SplitItem } from '@patternfly/react-core';
import { useMemo, useState } 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 = () => {
/* Replace mocks below for BFF call */
const mockedWorkspaceKind: WorkspaceKind = useMemo(
() => ({
name: 'jupyter-lab1',
displayName: 'JupyterLab Notebook',
description: 'A Workspace which runs JupyterLab in a Pod',
deprecated: false,
deprecationMessage: '',
hidden: false,
icon: {
url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png',
},
logo: {
url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg',
},
podTemplate: {
podMetadata: {
labels: { myWorkspaceKindLabel: 'my-value' },
annotations: { myWorkspaceKindAnnotation: 'my-value' },
},
volumeMounts: { home: '/home/jovyan' },
options: {
imageConfig: {
default: 'jupyterlab_scipy_190',
values: [
{
id: 'jupyterlab_scipy_180',
displayName: 'jupyter-scipy:v1.8.0',
labels: { pythonVersion: '3.11' },
hidden: true,
redirect: {
to: 'jupyterlab_scipy_190',
message: {
text: 'This update will change...',
level: 'Info',
},
},
},
{
id: 'jupyterlab_scipy_190',
displayName: 'jupyter-scipy:v1.9.0',
labels: { pythonVersion: '3.11' },
hidden: true,
redirect: {
to: 'jupyterlab_scipy_200',
message: {
text: 'This update will change...',
level: 'Warning',
},
},
},
],
},
podConfig: {
default: 'tiny_cpu',
values: [
{
id: 'tiny_cpu',
displayName: 'Tiny CPU',
description: 'Pod with 0.1 CPU, 128 Mb RAM',
labels: { cpu: '100m', memory: '128Mi' },
redirect: {
to: 'small_cpu',
message: {
text: 'This update will change...',
level: 'Danger',
},
},
},
],
},
},
},
}),
[],
);
/* Replace mocks below for BFF call */
const allWorkspaceKinds = useMemo(() => {
const kinds: WorkspaceKind[] = [];
for (let i = 1; i <= 15; i++) {
const kind = { ...mockedWorkspaceKind };
kind.name += i;
kind.displayName += ` ${i}`;
kind.podTemplate = { ...mockedWorkspaceKind.podTemplate };
kind.podTemplate.podMetadata = { ...mockedWorkspaceKind.podTemplate.podMetadata };
kind.podTemplate.podMetadata.labels = {
...mockedWorkspaceKind.podTemplate.podMetadata.labels,
};
kind.podTemplate.podMetadata.labels[`my-label-key-${Math.ceil(i / 4)}`] =
`my-label-value-${Math.ceil(i)}`;
kinds.push(kind);
}
return kinds;
}, [mockedWorkspaceKind]);
const [selectedKind, setSelectedKind] = useState<WorkspaceKind>();
const kindDetailsContent = useMemo(
() => <WorkspaceCreationKindDetails workspaceKind={selectedKind} />,
[selectedKind],
);
return (
<Content style={{ height: '100%' }}>
<p>Select a workspace kind to use for the workspace.</p>
<Divider />
<Split hasGutter>
<SplitItem isFilled>
<WorkspaceCreationKindList
allWorkspaceKinds={allWorkspaceKinds}
onSelect={(workspaceKind) => setSelectedKind(workspaceKind)}
/>
</SplitItem>
<SplitItem style={{ minWidth: '200px' }}>{kindDetailsContent}</SplitItem>
</Split>
</Content>
);
};
export { WorkspaceCreationKindSelection };

View File

@ -0,0 +1,10 @@
import * as React from 'react';
import { Content } from '@patternfly/react-core';
const WorkspaceCreationPropertiesSelection: React.FunctionComponent = () => (
<Content>
<p>Configure properties for your workspace.</p>
</Content>
);
export { WorkspaceCreationPropertiesSelection };

View File

@ -32,7 +32,8 @@ import {
QuestionCircleIcon,
CodeIcon,
} from '@patternfly/react-icons';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Workspace, WorkspacesColumnNames, WorkspaceState } from '~/shared/types';
import { WorkspaceDetails } from '~/app/pages/Workspaces/Details/WorkspaceDetails';
import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow';
@ -182,6 +183,11 @@ export const Workspaces: React.FunctionComponent = () => {
},
];
const navigate = useNavigate();
const createWorkspace = useCallback(() => {
navigate('/workspaces/create');
}, [navigate]);
const [workspaceKinds] = useWorkspaceKinds();
let kindLogoDict: Record<string, string> = {};
kindLogoDict = buildKindLogoDictionary(workspaceKinds);
@ -554,7 +560,7 @@ export const Workspaces: React.FunctionComponent = () => {
<br />
<Content style={{ display: 'flex', alignItems: 'flex-start', columnGap: '20px' }}>
<Filter id="filter-workspaces" onFilter={onFilter} columnNames={filterableColumns} />
<Button variant="primary" ouiaId="Primary">
<Button variant="primary" ouiaId="Primary" onClick={createWorkspace}>
Create Workspace
</Button>
</Content>

View File

@ -104,7 +104,6 @@
--pf-t--global--border--width--box--status--default: 1px;
--pf-t--global--border--radius--pill: var(--mui-shape-borderRadius);
--pf-t--global--border--radius--medium: var(--mui-shape-borderRadius);
--pf-t--global--text--color--brand--default: var(--mui-palette-primary-main);
--pf-t--global--color--nonstatus--blue--default: var(--mui-palette-primary-main);
@ -545,48 +544,6 @@
row-gap: none;
}
.mui-theme .pf-v6-radio {
--pf-v6-c-radio--AccentColor: var(--mui-palette-primary-main);
}
.mui-theme .pf-v6-c-radio {
display: flex;
align-items: center;
margin: var(--mui-radio--margin);
}
.mui-theme .pf-v6-c-radio__input {
/* Hide default radio button */
display: none;
}
.mui-theme .pf-v6-c-radio__label {
--pf-v6-c-radio__label--FontSize: 16px;
padding-left: var(--mui-radio-PaddingLeft);
position: relative;
cursor: pointer;
user-select: none;
}
/* Custom radio circle */
.mui-theme .pf-v6-c-radio__label::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: var(--mui-radio__label--Width);
height: var(--mui-radio__label--Height);
border: 2px solid var(--mui-palette-primary-main);
border-radius: 50%;
background: var(--mui-palette-common-white);
}
/* When radio is checked */
.mui-theme .pf-v6-c-radio__input:checked+.pf-v6-c-radio__label::before {
background: var(--mui-palette-common-white);
border-color: var(--mui-palette-primary-main);
}
.mui-theme .pf-v6-c-page__sidebar {
--pf-v6-c-page__sidebar--BackgroundColor: var(--kf-central-primary-background-color);
@ -598,20 +555,6 @@
}
/* Inner dot for checked state */
.mui-theme .pf-v6-c-radio__input:checked+.pf-v6-c-radio__label::after {
content: '';
position: absolute;
left: 5px;
/* Center the dot */
top: 50%;
transform: translateY(-50%);
width: var(--mui-radio__input--Width);
/* Size of inner dot */
height: var(--mui-radio__input--Height);
border-radius: 50%;
background: var(--mui-palette-primary-main);
}
.mui-theme .pf-v6-c-table {
--pf-v6-c-table__sort--m-selected__button--Color: var(--mui-palette-text-primary);
@ -789,6 +732,10 @@
box-shadow: var(--mui-shadows-1);
}
.mui-theme .pf-v6-c-page__main-group.pf-m-sticky-top {
flex-grow: 0;
}
.mui-theme .pf-v6-c-pagination {
--pf-v6-c-pagination__total-items--Display: block;
}

View File

@ -30,37 +30,40 @@ export interface WorkspaceKind {
options: {
imageConfig: {
default: string;
values: [
{
id: string;
displayName: string;
labels: {
pythonVersion: string;
values: {
id: string;
displayName: string;
labels: {
pythonVersion: string;
};
hidden: boolean;
redirect?: {
to: string;
message: {
text: string;
level: string;
};
hidden: true;
redirect?: {
to: string;
message: {
text: string;
level: string;
};
};
},
];
};
}[];
};
podConfig: {
default: string;
values: [
{
id: string;
displayName: string;
description: string;
labels: {
cpu: string;
memory: string;
values: {
id: string;
displayName: string;
description: string;
labels: {
cpu: string;
memory: string;
};
redirect?: {
to: string;
message: {
text: string;
level: string;
};
},
];
};
}[];
};
};
};