Skip to content

Commit cbedbff

Browse files
authored
feat(ws): prepare frontend for validation errors during WorkspaceKind creation (#471)
* feat(ws): prepare frontend for validation errors during WorkspaceKind creation Signed-off-by: Guilherme Caponetto <[email protected]> * feat(ws): extract validation alert to its own component Signed-off-by: Guilherme Caponetto <[email protected]> * fix(ws): use error icon for helper text Signed-off-by: Guilherme Caponetto <[email protected]> --------- Signed-off-by: Guilherme Caponetto <[email protected]>
1 parent d38b24c commit cbedbff

File tree

14 files changed

+273
-152
lines changed

14 files changed

+273
-152
lines changed

workspaces/frontend/src/app/NavSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => {
1717
const location = useTypedLocation();
1818

1919
// With the redirect in place, we can now use a simple path comparison.
20-
const isActive = location.pathname === item.path;
20+
const isActive = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
2121

2222
return (
2323
<NavItem isActive={isActive} key={item.label} data-id={item.label} itemId={item.label}>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import { Alert, List, ListItem } from '@patternfly/react-core';
3+
import { ValidationError } from '~/shared/api/backendApiTypes';
4+
5+
interface ValidationErrorAlertProps {
6+
title: string;
7+
errors: ValidationError[];
8+
}
9+
10+
export const ValidationErrorAlert: React.FC<ValidationErrorAlertProps> = ({ title, errors }) => {
11+
if (errors.length === 0) {
12+
return null;
13+
}
14+
15+
return (
16+
<Alert variant="danger" title={title} isInline>
17+
<List>
18+
{errors.map((error, index) => (
19+
<ListItem key={index}>{error.message}</ListItem>
20+
))}
21+
</List>
22+
</Alert>
23+
);
24+
};

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import {
88
PageGroup,
99
PageSection,
1010
Stack,
11+
StackItem,
1112
} from '@patternfly/react-core';
13+
import { t_global_spacer_sm as SmallPadding } from '@patternfly/react-tokens';
14+
import { ValidationErrorAlert } from '~/app/components/ValidationErrorAlert';
1215
import { useTypedNavigate } from '~/app/routerHelper';
1316
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
1417
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
1518
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
1619
import { WorkspaceKindFormData } from '~/app/types';
20+
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
21+
import { ValidationError } from '~/shared/api/backendApiTypes';
1722
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
1823
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
1924
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
@@ -34,6 +39,8 @@ export const WorkspaceKindForm: React.FC = () => {
3439
const [isSubmitting, setIsSubmitting] = useState(false);
3540
const [validated, setValidated] = useState<ValidationStatus>('default');
3641
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
42+
const [specErrors, setSpecErrors] = useState<ValidationError[]>([]);
43+
3744
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
3845
properties: {
3946
displayName: '',
@@ -60,14 +67,24 @@ export const WorkspaceKindForm: React.FC = () => {
6067
try {
6168
if (mode === 'create') {
6269
const newWorkspaceKind = await api.createWorkspaceKind({}, yamlValue);
70+
// TODO: alert user about success
6371
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
72+
navigate('workspaceKinds');
6473
}
6574
} catch (err) {
75+
if (err instanceof ErrorEnvelopeException) {
76+
const validationErrors = err.envelope.error?.cause?.validation_errors;
77+
if (validationErrors && validationErrors.length > 0) {
78+
setSpecErrors(validationErrors);
79+
setValidated('error');
80+
return;
81+
}
82+
}
83+
// TODO: alert user about error
6684
console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`);
6785
} finally {
6886
setIsSubmitting(false);
6987
}
70-
navigate('workspaceKinds');
7188
}, [navigate, mode, api, yamlValue]);
7289

7390
const canSubmit = useMemo(
@@ -102,13 +119,25 @@ export const WorkspaceKindForm: React.FC = () => {
102119
</PageGroup>
103120
<PageSection isFilled>
104121
{mode === 'create' && (
105-
<WorkspaceKindFileUpload
106-
resetData={resetData}
107-
value={yamlValue}
108-
setValue={setYamlValue}
109-
validated={validated}
110-
setValidated={setValidated}
111-
/>
122+
<Stack>
123+
{specErrors.length > 0 && (
124+
<StackItem style={{ padding: SmallPadding.value }}>
125+
<ValidationErrorAlert title="Error creating workspace kind" errors={specErrors} />
126+
</StackItem>
127+
)}
128+
<StackItem style={{ height: '100%' }}>
129+
<WorkspaceKindFileUpload
130+
resetData={resetData}
131+
value={yamlValue}
132+
setValue={setYamlValue}
133+
validated={validated}
134+
setValidated={setValidated}
135+
onClear={() => {
136+
setSpecErrors([]);
137+
}}
138+
/>
139+
</StackItem>
140+
</Stack>
112141
)}
113142
{mode === 'edit' && (
114143
<>

workspaces/frontend/src/app/pages/WorkspaceKinds/Form/fileUpload/WorkspaceKindFileUpload.tsx

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useRef, useState } from 'react';
1+
import React, { useCallback, useState } from 'react';
22
import yaml, { YAMLException } from 'js-yaml';
33
import {
44
FileUpload,
@@ -7,6 +7,7 @@ import {
77
HelperText,
88
HelperTextItem,
99
Content,
10+
DropzoneErrorCode,
1011
} from '@patternfly/react-core';
1112
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
1213
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
@@ -17,60 +18,52 @@ interface WorkspaceKindFileUploadProps {
1718
resetData: () => void;
1819
validated: ValidationStatus;
1920
setValidated: (type: ValidationStatus) => void;
21+
onClear: () => void;
2022
}
2123

24+
const YAML_MIME_TYPE = 'application/x-yaml';
25+
const YAML_EXTENSIONS = ['.yml', '.yaml'];
26+
2227
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
2328
resetData,
2429
value,
2530
setValue,
2631
validated,
2732
setValidated,
33+
onClear,
2834
}) => {
29-
const isYamlFileRef = useRef(false);
3035
const [filename, setFilename] = useState('');
3136
const [isLoading, setIsLoading] = useState(false);
32-
const [fileUploadHelperText, setFileUploadHelperText] = useState<string>('');
37+
const [fileUploadHelperText, setFileUploadHelperText] = useState<string | undefined>();
3338

3439
const handleFileInputChange = useCallback(
3540
(_: unknown, file: File) => {
36-
const fileName = file.name;
41+
onClear();
3742
setFilename(file.name);
38-
// if extension is not yaml or yml, raise a flag
39-
const ext = fileName.split('.').pop();
40-
const isYaml = ext === 'yml' || ext === 'yaml';
41-
isYamlFileRef.current = isYaml;
42-
if (!isYaml) {
43-
setFileUploadHelperText('Invalid file. Only YAML files are allowed.');
44-
resetData();
45-
setValidated('error');
46-
} else {
47-
setFileUploadHelperText('');
48-
setValidated('success');
49-
}
43+
setFileUploadHelperText(undefined);
44+
setValidated('success');
5045
},
51-
[resetData, setValidated],
46+
[setValidated, onClear],
5247
);
5348

5449
// TODO: Use zod or another TS type coercion/schema for file upload
5550
const handleDataChange = useCallback(
5651
(_: DropEvent, v: string) => {
5752
setValue(v);
58-
if (isYamlFileRef.current) {
59-
try {
60-
const parsed = yaml.load(v);
61-
if (!isValidWorkspaceKindYaml(parsed)) {
62-
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
63-
setValidated('error');
64-
resetData();
65-
} else {
66-
setValidated('success');
67-
setFileUploadHelperText('');
68-
}
69-
} catch (e) {
70-
console.error('Error parsing YAML:', e);
71-
setFileUploadHelperText(`Error parsing YAML: ${e as YAMLException['reason']}`);
53+
try {
54+
const parsed = yaml.load(v);
55+
if (!isValidWorkspaceKindYaml(parsed)) {
56+
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
7257
setValidated('error');
58+
resetData();
59+
} else {
60+
setValidated('success');
61+
setFileUploadHelperText('');
7362
}
63+
} catch (e) {
64+
console.error('Error parsing YAML:', e);
65+
setFileUploadHelperText(`Error parsing YAML: ${e as YAMLException['reason']}`);
66+
setValidated('error');
7467
}
7568
},
7669
[setValue, setValidated, resetData],
@@ -82,7 +75,8 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
8275
setFileUploadHelperText('');
8376
setValidated('default');
8477
resetData();
85-
}, [resetData, setValidated, setValue]);
78+
onClear();
79+
}, [resetData, setValidated, setValue, onClear]);
8680

8781
const handleFileReadStarted = useCallback(() => {
8882
setIsLoading(true);
@@ -110,14 +104,27 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
110104
validated={validated}
111105
allowEditingUploadedText={false}
112106
browseButtonText="Choose File"
107+
dropzoneProps={{
108+
accept: { [YAML_MIME_TYPE]: YAML_EXTENSIONS },
109+
onDropRejected: (rejections) => {
110+
const error = rejections[0]?.errors?.[0] ?? {};
111+
setFileUploadHelperText(
112+
error.code === DropzoneErrorCode.FileInvalidType
113+
? 'Invalid file. Only YAML files are allowed.'
114+
: error.message,
115+
);
116+
},
117+
}}
113118
>
114-
<FileUploadHelperText>
115-
<HelperText>
116-
<HelperTextItem id="helper-text-example-helpText">
117-
{fileUploadHelperText}
118-
</HelperTextItem>
119-
</HelperText>
120-
</FileUploadHelperText>
119+
{fileUploadHelperText && (
120+
<FileUploadHelperText>
121+
<HelperText>
122+
<HelperTextItem id="helper-text-example-helpText" variant="error">
123+
{fileUploadHelperText}
124+
</HelperTextItem>
125+
</HelperText>
126+
</FileUploadHelperText>
127+
)}
121128
</FileUpload>
122129
</Content>
123130
);

workspaces/frontend/src/shared/api/__tests__/errorUtils.spec.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { NotReadyError } from '~/shared/utilities/useFetchState';
2-
import { APIError } from '~/shared/api/types';
3-
import { handleRestFailures } from '~/shared/api/errorUtils';
41
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
52
import { mockBFFResponse } from '~/__mocks__/utils';
3+
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
4+
import { ErrorEnvelope } from '~/shared/api/backendApiTypes';
5+
import { handleRestFailures } from '~/shared/api/errorUtils';
6+
import { NotReadyError } from '~/shared/utilities/useFetchState';
67

78
describe('handleRestFailures', () => {
89
it('should successfully return namespaces', async () => {
@@ -11,14 +12,14 @@ describe('handleRestFailures', () => {
1112
});
1213

1314
it('should handle and throw notebook errors', async () => {
14-
const statusMock: APIError = {
15+
const errorEnvelope: ErrorEnvelope = {
1516
error: {
16-
code: '',
17-
message: 'error',
17+
code: '<error_code>',
18+
message: '<error_message>',
1819
},
1920
};
20-
21-
await expect(handleRestFailures(Promise.resolve(statusMock))).rejects.toThrow('error');
21+
const expectedError = new ErrorEnvelopeException(errorEnvelope);
22+
await expect(handleRestFailures(Promise.reject(errorEnvelope))).rejects.toThrow(expectedError);
2223
});
2324

2425
it('should handle common state errors ', async () => {
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
import { BFF_API_VERSION } from '~/app/const';
2-
import { restGET } from '~/shared/api/apiUtils';
3-
import { handleRestFailures } from '~/shared/api/errorUtils';
2+
import { restGET, wrapRequest } from '~/shared/api/apiUtils';
43
import { listNamespaces } from '~/shared/api/notebookService';
54

6-
const mockRestPromise = Promise.resolve({ data: {} });
7-
const mockRestResponse = {};
5+
const mockRestResponse = { data: {} };
6+
const mockRestPromise = Promise.resolve(mockRestResponse);
87

98
jest.mock('~/shared/api/apiUtils', () => ({
109
restCREATE: jest.fn(() => mockRestPromise),
1110
restGET: jest.fn(() => mockRestPromise),
1211
restPATCH: jest.fn(() => mockRestPromise),
1312
isNotebookResponse: jest.fn(() => true),
1413
extractNotebookResponse: jest.fn(() => mockRestResponse),
14+
wrapRequest: jest.fn(() => mockRestPromise),
1515
}));
1616

17-
jest.mock('~/shared/api/errorUtils', () => ({
18-
handleRestFailures: jest.fn(() => mockRestPromise),
19-
}));
20-
21-
const handleRestFailuresMock = jest.mocked(handleRestFailures);
17+
const wrapRequestMock = jest.mocked(wrapRequest);
2218
const restGETMock = jest.mocked(restGET);
2319
const APIOptionsMock = {};
2420

@@ -33,7 +29,7 @@ describe('getNamespaces', () => {
3329
{},
3430
APIOptionsMock,
3531
);
36-
expect(handleRestFailuresMock).toHaveBeenCalledTimes(1);
37-
expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise);
32+
expect(wrapRequestMock).toHaveBeenCalledTimes(1);
33+
expect(wrapRequestMock).toHaveBeenCalledWith(mockRestPromise);
3834
});
3935
});

workspaces/frontend/src/shared/api/apiUtils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ErrorEnvelope } from '~/shared/api/backendApiTypes';
2+
import { handleRestFailures } from '~/shared/api/errorUtils';
13
import { APIOptions, ResponseBody } from '~/shared/api/types';
24
import { EitherOrNone } from '~/shared/typeHelpers';
35
import { AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const';
@@ -222,9 +224,48 @@ export const isNotebookResponse = <T>(response: unknown): response is ResponseBo
222224
return false;
223225
};
224226

227+
export const isErrorEnvelope = (e: unknown): e is ErrorEnvelope =>
228+
typeof e === 'object' &&
229+
e !== null &&
230+
'error' in e &&
231+
typeof (e as Record<string, unknown>).error === 'object' &&
232+
(e as { error: unknown }).error !== null &&
233+
typeof (e as { error: { message: unknown } }).error.message === 'string';
234+
225235
export function extractNotebookResponse<T>(response: unknown): T {
226236
if (isNotebookResponse<T>(response)) {
227237
return response.data;
228238
}
229239
throw new Error('Invalid response format');
230240
}
241+
242+
export function extractErrorEnvelope(error: unknown): ErrorEnvelope {
243+
if (isErrorEnvelope(error)) {
244+
return error;
245+
}
246+
247+
const message =
248+
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unexpected error';
249+
250+
return {
251+
error: {
252+
message,
253+
code: 'UNKNOWN_ERROR',
254+
},
255+
};
256+
}
257+
258+
export async function wrapRequest<T>(promise: Promise<T>, extractData = true): Promise<T> {
259+
try {
260+
const res = await handleRestFailures<T>(promise);
261+
return extractData ? extractNotebookResponse<T>(res) : res;
262+
} catch (error) {
263+
throw new ErrorEnvelopeException(extractErrorEnvelope(error));
264+
}
265+
}
266+
267+
export class ErrorEnvelopeException extends Error {
268+
constructor(public envelope: ErrorEnvelope) {
269+
super(envelope.error?.message ?? 'Unknown error');
270+
}
271+
}

0 commit comments

Comments
 (0)