Skip to content

Commit fb0e74a

Browse files
authored
feat(ws): Add advanced pod configurations in Workspace Edit (#468)
Signed-off-by: Charles Thao <[email protected]>
1 parent 023f84b commit fb0e74a

18 files changed

+639
-206
lines changed

workspaces/frontend/src/app/components/ValidationErrorAlert.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from 'react';
22
import { Alert, List, ListItem } from '@patternfly/react-core';
33
import { ValidationError } from '~/shared/api/backendApiTypes';
4+
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
45

56
interface ValidationErrorAlertProps {
67
title: string;
7-
errors: ValidationError[];
8+
errors: (ValidationError | ErrorEnvelopeException)[];
89
}
910

1011
export const ValidationErrorAlert: React.FC<ValidationErrorAlertProps> = ({ title, errors }) => {

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/EditableLabels.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,18 @@ type ColumnNames<T> = { [K in keyof T]: string };
7171
interface EditableLabelsProps {
7272
rows: WorkspaceOptionLabel[];
7373
setRows: (value: WorkspaceOptionLabel[]) => void;
74+
title?: string;
75+
description?: string;
76+
buttonLabel?: string;
7477
}
7578

76-
export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows }) => {
79+
export const EditableLabels: React.FC<EditableLabelsProps> = ({
80+
rows,
81+
setRows,
82+
title = 'Labels',
83+
description,
84+
buttonLabel = 'Label',
85+
}) => {
7786
const columnNames: ColumnNames<WorkspaceOptionLabel> = {
7887
key: 'Key',
7988
value: 'Value',
@@ -86,12 +95,15 @@ export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows })
8695
header={
8796
<FormFieldGroupHeader
8897
titleText={{
89-
text: 'Labels',
90-
id: 'workspace-kind-image-ports',
98+
text: title,
99+
id: `${title}-labels`,
91100
}}
92101
titleDescription={
93102
<>
94-
<div>Labels are key/value pairs that are attached to Kubernetes objects.</div>
103+
<div>
104+
{description ||
105+
'Labels are key/value pairs that are attached to Kubernetes objects.'}
106+
</div>
95107
<div className="pf-u-font-size-sm">
96108
<strong>{rows.length} added</strong>
97109
</div>
@@ -141,7 +153,7 @@ export const EditableLabels: React.FC<EditableLabelsProps> = ({ rows, setRows })
141153
]);
142154
}}
143155
>
144-
Add Label
156+
{`Add ${buttonLabel}`}
145157
</Button>
146158
</FormFieldGroupExpandable>
147159
);

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/WorkspaceKindForm.tsx

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import React, { useCallback, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import {
33
Button,
44
Content,
55
ContentVariants,
6+
EmptyState,
7+
EmptyStateBody,
68
Flex,
79
FlexItem,
810
PageGroup,
@@ -11,25 +13,42 @@ import {
1113
StackItem,
1214
} from '@patternfly/react-core';
1315
import { t_global_spacer_sm as SmallPadding } from '@patternfly/react-tokens';
16+
import { ExclamationCircleIcon } from '@patternfly/react-icons';
1417
import { ValidationErrorAlert } from '~/app/components/ValidationErrorAlert';
15-
import { useTypedNavigate } from '~/app/routerHelper';
18+
import useWorkspaceKindByName from '~/app/hooks/useWorkspaceKindByName';
19+
import { WorkspaceKind, ValidationError } from '~/shared/api/backendApiTypes';
20+
import { useTypedNavigate, useTypedParams } from '~/app/routerHelper';
1621
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
1722
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
1823
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
1924
import { WorkspaceKindFormData } from '~/app/types';
2025
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
21-
import { ValidationError } from '~/shared/api/backendApiTypes';
2226
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
2327
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
2428
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
2529
import { WorkspaceKindFormPodConfig } from './podConfig/WorkspaceKindFormPodConfig';
30+
import { WorkspaceKindFormPodTemplate } from './podTemplate/WorkspaceKindFormPodTemplate';
31+
import { EMPTY_WORKSPACE_KIND_FORM_DATA } from './helpers';
2632

2733
export enum WorkspaceKindFormView {
2834
Form,
2935
FileUpload,
3036
}
3137

3238
export type ValidationStatus = 'success' | 'error' | 'default';
39+
export type FormMode = 'edit' | 'create';
40+
41+
const convertToFormData = (initialData: WorkspaceKind): WorkspaceKindFormData => {
42+
const { podTemplate, ...properties } = initialData;
43+
const { options, ...spec } = podTemplate;
44+
const { podConfig, imageConfig } = options;
45+
return {
46+
properties,
47+
podConfig,
48+
imageConfig,
49+
podTemplate: spec,
50+
};
51+
};
3352

3453
export const WorkspaceKindForm: React.FC = () => {
3554
const navigate = useTypedNavigate();
@@ -38,28 +57,23 @@ export const WorkspaceKindForm: React.FC = () => {
3857
const [yamlValue, setYamlValue] = useState('');
3958
const [isSubmitting, setIsSubmitting] = useState(false);
4059
const [validated, setValidated] = useState<ValidationStatus>('default');
41-
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
42-
const [specErrors, setSpecErrors] = useState<ValidationError[]>([]);
60+
const mode: FormMode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
61+
const [specErrors, setSpecErrors] = useState<(ValidationError | ErrorEnvelopeException)[]>([]);
62+
63+
const { kind } = useTypedParams<'workspaceKindEdit'>();
64+
const [initialFormData, initialFormDataLoaded, initialFormDataError] =
65+
useWorkspaceKindByName(kind);
66+
67+
const [data, setData, resetData, replaceData] = useGenericObjectState<WorkspaceKindFormData>(
68+
initialFormData ? convertToFormData(initialFormData) : EMPTY_WORKSPACE_KIND_FORM_DATA,
69+
);
4370

44-
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
45-
properties: {
46-
displayName: '',
47-
description: '',
48-
deprecated: false,
49-
deprecationMessage: '',
50-
hidden: false,
51-
icon: { url: '' },
52-
logo: { url: '' },
53-
},
54-
imageConfig: {
55-
default: '',
56-
values: [],
57-
},
58-
podConfig: {
59-
default: '',
60-
values: [],
61-
},
62-
});
71+
useEffect(() => {
72+
if (!initialFormDataLoaded || initialFormData === null || mode === 'create') {
73+
return;
74+
}
75+
replaceData(convertToFormData(initialFormData));
76+
}, [initialFormData, initialFormDataLoaded, mode, replaceData]);
6377

6478
const handleSubmit = useCallback(async () => {
6579
setIsSubmitting(true);
@@ -71,14 +85,20 @@ export const WorkspaceKindForm: React.FC = () => {
7185
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
7286
navigate('workspaceKinds');
7387
}
88+
// TODO: Finish when WSKind API is finalized
89+
// const updatedWorkspace = await api.updateWorkspaceKind({}, kind, { data: {} });
90+
// console.info('Workspace Kind updated:', JSON.stringify(updatedWorkspace));
91+
// navigate('workspaceKinds');
7492
} catch (err) {
7593
if (err instanceof ErrorEnvelopeException) {
7694
const validationErrors = err.envelope.error?.cause?.validation_errors;
7795
if (validationErrors && validationErrors.length > 0) {
78-
setSpecErrors(validationErrors);
96+
setSpecErrors((prev) => [...prev, ...validationErrors]);
7997
setValidated('error');
8098
return;
8199
}
100+
setSpecErrors((prev) => [...prev, err]);
101+
setValidated('error');
82102
}
83103
// TODO: alert user about error
84104
console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`);
@@ -88,14 +108,26 @@ export const WorkspaceKindForm: React.FC = () => {
88108
}, [navigate, mode, api, yamlValue]);
89109

90110
const canSubmit = useMemo(
91-
() => !isSubmitting && yamlValue.length > 0 && validated === 'success',
92-
[yamlValue, isSubmitting, validated],
111+
() => !isSubmitting && validated === 'success',
112+
[isSubmitting, validated],
93113
);
94114

95115
const cancel = useCallback(() => {
96116
navigate('workspaceKinds');
97117
}, [navigate]);
98118

119+
if (mode === 'edit' && initialFormDataError) {
120+
return (
121+
<EmptyState
122+
titleText="Error loading Workspace Kind data"
123+
headingLevel="h4"
124+
icon={ExclamationCircleIcon}
125+
status="danger"
126+
>
127+
<EmptyStateBody>{initialFormDataError.message}</EmptyStateBody>
128+
</EmptyState>
129+
);
130+
}
99131
return (
100132
<>
101133
<PageGroup isFilled={false} stickyOnBreakpoint={{ default: 'top' }}>
@@ -159,6 +191,12 @@ export const WorkspaceKindForm: React.FC = () => {
159191
setData('podConfig', podConfig);
160192
}}
161193
/>
194+
<WorkspaceKindFormPodTemplate
195+
podTemplate={data.podTemplate}
196+
updatePodTemplate={(podTemplate) => {
197+
setData('podTemplate', podTemplate);
198+
}}
199+
/>
162200
</>
163201
)}
164202
</PageSection>
@@ -169,9 +207,10 @@ export const WorkspaceKindForm: React.FC = () => {
169207
variant="primary"
170208
ouiaId="Primary"
171209
onClick={handleSubmit}
172-
isDisabled={!canSubmit}
210+
// TODO: button is always disabled on edit mode. Need to modify when WorkspaceKind edit is finalized
211+
isDisabled={!canSubmit || mode === 'edit'}
173212
>
174-
{mode === 'create' ? 'Create' : 'Edit'}
213+
{mode === 'create' ? 'Create' : 'Save'}
175214
</Button>
176215
</FlexItem>
177216
<FlexItem>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React, { useMemo, useState } from 'react';
2+
import { Table, Thead, Tr, Td, Tbody, Th } from '@patternfly/react-table';
3+
import {
4+
Dropdown,
5+
DropdownItem,
6+
getUniqueId,
7+
Label,
8+
MenuToggle,
9+
PageSection,
10+
Pagination,
11+
PaginationVariant,
12+
Radio,
13+
} from '@patternfly/react-core';
14+
import { EllipsisVIcon } from '@patternfly/react-icons';
15+
16+
import { WorkspaceKindImageConfigValue } from '~/app/types';
17+
import { WorkspacePodConfigValue } from '~/shared/api/backendApiTypes';
18+
19+
interface PaginatedTableProps {
20+
rows: WorkspaceKindImageConfigValue[] | WorkspacePodConfigValue[];
21+
defaultId: string;
22+
setDefaultId: (id: string) => void;
23+
handleEdit: (index: number) => void;
24+
openDeleteModal: (index: number) => void;
25+
ariaLabel: string;
26+
}
27+
28+
export const WorkspaceKindFormPaginatedTable: React.FC<PaginatedTableProps> = ({
29+
rows,
30+
defaultId,
31+
setDefaultId,
32+
handleEdit,
33+
openDeleteModal,
34+
ariaLabel,
35+
}) => {
36+
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
37+
const [page, setPage] = useState(1);
38+
const [perPage, setPerPage] = useState(10);
39+
const rowPages = useMemo(() => {
40+
const pages = [];
41+
for (let i = 0; i < rows.length; i += perPage) {
42+
pages.push(rows.slice(i, i + perPage));
43+
}
44+
return pages;
45+
}, [perPage, rows]);
46+
47+
const onSetPage = (
48+
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
49+
newPage: number,
50+
) => {
51+
setPage(newPage);
52+
};
53+
54+
const onPerPageSelect = (
55+
_event: React.MouseEvent | React.KeyboardEvent | MouseEvent,
56+
newPerPage: number,
57+
newPage: number,
58+
) => {
59+
setPerPage(newPerPage);
60+
setPage(newPage);
61+
};
62+
return (
63+
<PageSection>
64+
<Table aria-label={ariaLabel}>
65+
<Thead>
66+
<Tr>
67+
<Th>Display Name</Th>
68+
<Th>ID</Th>
69+
<Th screenReaderText="Row select">Default</Th>
70+
<Th>Labels</Th>
71+
<Th aria-label="Actions" />
72+
</Tr>
73+
</Thead>
74+
<Tbody>
75+
{rowPages[page - 1].map((row, index) => (
76+
<Tr key={row.id}>
77+
<Td>{row.displayName}</Td>
78+
<Td>{row.id}</Td>
79+
<Td>
80+
<Radio
81+
className="workspace-kind-form-radio"
82+
id={`default-${ariaLabel}-${index}`}
83+
name={`default-${ariaLabel}-${index}-radio`}
84+
isChecked={defaultId === row.id}
85+
onChange={() => {
86+
console.log(row.id);
87+
setDefaultId(row.id);
88+
}}
89+
aria-label={`Select ${row.id} as default`}
90+
/>
91+
</Td>
92+
<Td>
93+
{row.labels.length > 0 &&
94+
row.labels.map((label) => (
95+
<Label
96+
style={{ marginRight: '4px', marginTop: '4px' }}
97+
key={getUniqueId()}
98+
>{`${label.key}: ${label.value}`}</Label>
99+
))}
100+
</Td>
101+
<Td isActionCell>
102+
<Dropdown
103+
toggle={(toggleRef) => (
104+
<MenuToggle
105+
ref={toggleRef}
106+
isExpanded={dropdownOpen === index}
107+
onClick={() => setDropdownOpen(dropdownOpen === index ? null : index)}
108+
variant="plain"
109+
aria-label="plain kebab"
110+
>
111+
<EllipsisVIcon />
112+
</MenuToggle>
113+
)}
114+
isOpen={dropdownOpen === index}
115+
onSelect={() => setDropdownOpen(null)}
116+
popperProps={{ position: 'right' }}
117+
>
118+
<DropdownItem onClick={() => handleEdit(perPage * (page - 1) + index)}>
119+
Edit
120+
</DropdownItem>
121+
<DropdownItem onClick={() => openDeleteModal(perPage * (page - 1) + index)}>
122+
Remove
123+
</DropdownItem>
124+
</Dropdown>
125+
</Td>
126+
</Tr>
127+
))}
128+
</Tbody>
129+
</Table>
130+
<Pagination
131+
itemCount={rows.length}
132+
widgetId="pagination-bottom"
133+
perPage={perPage}
134+
page={page}
135+
variant={PaginationVariant.bottom}
136+
isCompact
137+
onSetPage={onSetPage}
138+
onPerPageSelect={onPerPageSelect}
139+
/>
140+
</PageSection>
141+
);
142+
};

0 commit comments

Comments
 (0)