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 <638737+caponetto@users.noreply.github.com> * feat(ws): extract validation alert to its own component Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com> * fix(ws): use error icon for helper text Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com> --------- Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com>
This commit is contained in:
parent
d38b24c76c
commit
cbedbfff58
|
@ -17,7 +17,7 @@ const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => {
|
||||||
const location = useTypedLocation();
|
const location = useTypedLocation();
|
||||||
|
|
||||||
// With the redirect in place, we can now use a simple path comparison.
|
// With the redirect in place, we can now use a simple path comparison.
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavItem isActive={isActive} key={item.label} data-id={item.label} itemId={item.label}>
|
<NavItem isActive={isActive} key={item.label} data-id={item.label} itemId={item.label}>
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert, List, ListItem } from '@patternfly/react-core';
|
||||||
|
import { ValidationError } from '~/shared/api/backendApiTypes';
|
||||||
|
|
||||||
|
interface ValidationErrorAlertProps {
|
||||||
|
title: string;
|
||||||
|
errors: ValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationErrorAlert: React.FC<ValidationErrorAlertProps> = ({ title, errors }) => {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="danger" title={title} isInline>
|
||||||
|
<List>
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<ListItem key={index}>{error.message}</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,12 +8,17 @@ import {
|
||||||
PageGroup,
|
PageGroup,
|
||||||
PageSection,
|
PageSection,
|
||||||
Stack,
|
Stack,
|
||||||
|
StackItem,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
import { t_global_spacer_sm as SmallPadding } from '@patternfly/react-tokens';
|
||||||
|
import { ValidationErrorAlert } from '~/app/components/ValidationErrorAlert';
|
||||||
import { useTypedNavigate } from '~/app/routerHelper';
|
import { useTypedNavigate } from '~/app/routerHelper';
|
||||||
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
|
import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
|
||||||
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
|
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
|
||||||
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
|
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
|
||||||
import { WorkspaceKindFormData } from '~/app/types';
|
import { WorkspaceKindFormData } from '~/app/types';
|
||||||
|
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
|
||||||
|
import { ValidationError } from '~/shared/api/backendApiTypes';
|
||||||
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
|
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
|
||||||
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
|
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
|
||||||
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
|
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
|
||||||
|
@ -34,6 +39,8 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [validated, setValidated] = useState<ValidationStatus>('default');
|
const [validated, setValidated] = useState<ValidationStatus>('default');
|
||||||
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
|
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
|
||||||
|
const [specErrors, setSpecErrors] = useState<ValidationError[]>([]);
|
||||||
|
|
||||||
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
|
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
|
||||||
properties: {
|
properties: {
|
||||||
displayName: '',
|
displayName: '',
|
||||||
|
@ -60,14 +67,24 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const newWorkspaceKind = await api.createWorkspaceKind({}, yamlValue);
|
const newWorkspaceKind = await api.createWorkspaceKind({}, yamlValue);
|
||||||
|
// TODO: alert user about success
|
||||||
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
|
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
|
||||||
|
navigate('workspaceKinds');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ErrorEnvelopeException) {
|
||||||
|
const validationErrors = err.envelope.error?.cause?.validation_errors;
|
||||||
|
if (validationErrors && validationErrors.length > 0) {
|
||||||
|
setSpecErrors(validationErrors);
|
||||||
|
setValidated('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: alert user about error
|
||||||
console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`);
|
console.error(`Error ${mode === 'edit' ? 'editing' : 'creating'} workspace kind: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
navigate('workspaceKinds');
|
|
||||||
}, [navigate, mode, api, yamlValue]);
|
}, [navigate, mode, api, yamlValue]);
|
||||||
|
|
||||||
const canSubmit = useMemo(
|
const canSubmit = useMemo(
|
||||||
|
@ -102,13 +119,25 @@ export const WorkspaceKindForm: React.FC = () => {
|
||||||
</PageGroup>
|
</PageGroup>
|
||||||
<PageSection isFilled>
|
<PageSection isFilled>
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
<WorkspaceKindFileUpload
|
<Stack>
|
||||||
resetData={resetData}
|
{specErrors.length > 0 && (
|
||||||
value={yamlValue}
|
<StackItem style={{ padding: SmallPadding.value }}>
|
||||||
setValue={setYamlValue}
|
<ValidationErrorAlert title="Error creating workspace kind" errors={specErrors} />
|
||||||
validated={validated}
|
</StackItem>
|
||||||
setValidated={setValidated}
|
)}
|
||||||
/>
|
<StackItem style={{ height: '100%' }}>
|
||||||
|
<WorkspaceKindFileUpload
|
||||||
|
resetData={resetData}
|
||||||
|
value={yamlValue}
|
||||||
|
setValue={setYamlValue}
|
||||||
|
validated={validated}
|
||||||
|
setValidated={setValidated}
|
||||||
|
onClear={() => {
|
||||||
|
setSpecErrors([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
{mode === 'edit' && (
|
{mode === 'edit' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import yaml, { YAMLException } from 'js-yaml';
|
import yaml, { YAMLException } from 'js-yaml';
|
||||||
import {
|
import {
|
||||||
FileUpload,
|
FileUpload,
|
||||||
|
@ -7,6 +7,7 @@ import {
|
||||||
HelperText,
|
HelperText,
|
||||||
HelperTextItem,
|
HelperTextItem,
|
||||||
Content,
|
Content,
|
||||||
|
DropzoneErrorCode,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
|
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
|
||||||
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
|
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
|
||||||
|
@ -17,60 +18,52 @@ interface WorkspaceKindFileUploadProps {
|
||||||
resetData: () => void;
|
resetData: () => void;
|
||||||
validated: ValidationStatus;
|
validated: ValidationStatus;
|
||||||
setValidated: (type: ValidationStatus) => void;
|
setValidated: (type: ValidationStatus) => void;
|
||||||
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const YAML_MIME_TYPE = 'application/x-yaml';
|
||||||
|
const YAML_EXTENSIONS = ['.yml', '.yaml'];
|
||||||
|
|
||||||
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
|
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
|
||||||
resetData,
|
resetData,
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
validated,
|
validated,
|
||||||
setValidated,
|
setValidated,
|
||||||
|
onClear,
|
||||||
}) => {
|
}) => {
|
||||||
const isYamlFileRef = useRef(false);
|
|
||||||
const [filename, setFilename] = useState('');
|
const [filename, setFilename] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [fileUploadHelperText, setFileUploadHelperText] = useState<string>('');
|
const [fileUploadHelperText, setFileUploadHelperText] = useState<string | undefined>();
|
||||||
|
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
(_: unknown, file: File) => {
|
(_: unknown, file: File) => {
|
||||||
const fileName = file.name;
|
onClear();
|
||||||
setFilename(file.name);
|
setFilename(file.name);
|
||||||
// if extension is not yaml or yml, raise a flag
|
setFileUploadHelperText(undefined);
|
||||||
const ext = fileName.split('.').pop();
|
setValidated('success');
|
||||||
const isYaml = ext === 'yml' || ext === 'yaml';
|
|
||||||
isYamlFileRef.current = isYaml;
|
|
||||||
if (!isYaml) {
|
|
||||||
setFileUploadHelperText('Invalid file. Only YAML files are allowed.');
|
|
||||||
resetData();
|
|
||||||
setValidated('error');
|
|
||||||
} else {
|
|
||||||
setFileUploadHelperText('');
|
|
||||||
setValidated('success');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[resetData, setValidated],
|
[setValidated, onClear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Use zod or another TS type coercion/schema for file upload
|
// TODO: Use zod or another TS type coercion/schema for file upload
|
||||||
const handleDataChange = useCallback(
|
const handleDataChange = useCallback(
|
||||||
(_: DropEvent, v: string) => {
|
(_: DropEvent, v: string) => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
if (isYamlFileRef.current) {
|
try {
|
||||||
try {
|
const parsed = yaml.load(v);
|
||||||
const parsed = yaml.load(v);
|
if (!isValidWorkspaceKindYaml(parsed)) {
|
||||||
if (!isValidWorkspaceKindYaml(parsed)) {
|
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
|
||||||
setFileUploadHelperText('YAML is invalid: must follow WorkspaceKind format.');
|
|
||||||
setValidated('error');
|
|
||||||
resetData();
|
|
||||||
} else {
|
|
||||||
setValidated('success');
|
|
||||||
setFileUploadHelperText('');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing YAML:', e);
|
|
||||||
setFileUploadHelperText(`Error parsing YAML: ${e as YAMLException['reason']}`);
|
|
||||||
setValidated('error');
|
setValidated('error');
|
||||||
|
resetData();
|
||||||
|
} else {
|
||||||
|
setValidated('success');
|
||||||
|
setFileUploadHelperText('');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing YAML:', e);
|
||||||
|
setFileUploadHelperText(`Error parsing YAML: ${e as YAMLException['reason']}`);
|
||||||
|
setValidated('error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setValue, setValidated, resetData],
|
[setValue, setValidated, resetData],
|
||||||
|
@ -82,7 +75,8 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
|
||||||
setFileUploadHelperText('');
|
setFileUploadHelperText('');
|
||||||
setValidated('default');
|
setValidated('default');
|
||||||
resetData();
|
resetData();
|
||||||
}, [resetData, setValidated, setValue]);
|
onClear();
|
||||||
|
}, [resetData, setValidated, setValue, onClear]);
|
||||||
|
|
||||||
const handleFileReadStarted = useCallback(() => {
|
const handleFileReadStarted = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
@ -110,14 +104,27 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
|
||||||
validated={validated}
|
validated={validated}
|
||||||
allowEditingUploadedText={false}
|
allowEditingUploadedText={false}
|
||||||
browseButtonText="Choose File"
|
browseButtonText="Choose File"
|
||||||
|
dropzoneProps={{
|
||||||
|
accept: { [YAML_MIME_TYPE]: YAML_EXTENSIONS },
|
||||||
|
onDropRejected: (rejections) => {
|
||||||
|
const error = rejections[0]?.errors?.[0] ?? {};
|
||||||
|
setFileUploadHelperText(
|
||||||
|
error.code === DropzoneErrorCode.FileInvalidType
|
||||||
|
? 'Invalid file. Only YAML files are allowed.'
|
||||||
|
: error.message,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FileUploadHelperText>
|
{fileUploadHelperText && (
|
||||||
<HelperText>
|
<FileUploadHelperText>
|
||||||
<HelperTextItem id="helper-text-example-helpText">
|
<HelperText>
|
||||||
{fileUploadHelperText}
|
<HelperTextItem id="helper-text-example-helpText" variant="error">
|
||||||
</HelperTextItem>
|
{fileUploadHelperText}
|
||||||
</HelperText>
|
</HelperTextItem>
|
||||||
</FileUploadHelperText>
|
</HelperText>
|
||||||
|
</FileUploadHelperText>
|
||||||
|
)}
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
</Content>
|
</Content>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { NotReadyError } from '~/shared/utilities/useFetchState';
|
|
||||||
import { APIError } from '~/shared/api/types';
|
|
||||||
import { handleRestFailures } from '~/shared/api/errorUtils';
|
|
||||||
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
|
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
|
||||||
import { mockBFFResponse } from '~/__mocks__/utils';
|
import { mockBFFResponse } from '~/__mocks__/utils';
|
||||||
|
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
|
||||||
|
import { ErrorEnvelope } from '~/shared/api/backendApiTypes';
|
||||||
|
import { handleRestFailures } from '~/shared/api/errorUtils';
|
||||||
|
import { NotReadyError } from '~/shared/utilities/useFetchState';
|
||||||
|
|
||||||
describe('handleRestFailures', () => {
|
describe('handleRestFailures', () => {
|
||||||
it('should successfully return namespaces', async () => {
|
it('should successfully return namespaces', async () => {
|
||||||
|
@ -11,14 +12,14 @@ describe('handleRestFailures', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle and throw notebook errors', async () => {
|
it('should handle and throw notebook errors', async () => {
|
||||||
const statusMock: APIError = {
|
const errorEnvelope: ErrorEnvelope = {
|
||||||
error: {
|
error: {
|
||||||
code: '',
|
code: '<error_code>',
|
||||||
message: 'error',
|
message: '<error_message>',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const expectedError = new ErrorEnvelopeException(errorEnvelope);
|
||||||
await expect(handleRestFailures(Promise.resolve(statusMock))).rejects.toThrow('error');
|
await expect(handleRestFailures(Promise.reject(errorEnvelope))).rejects.toThrow(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle common state errors ', async () => {
|
it('should handle common state errors ', async () => {
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { BFF_API_VERSION } from '~/app/const';
|
import { BFF_API_VERSION } from '~/app/const';
|
||||||
import { restGET } from '~/shared/api/apiUtils';
|
import { restGET, wrapRequest } from '~/shared/api/apiUtils';
|
||||||
import { handleRestFailures } from '~/shared/api/errorUtils';
|
|
||||||
import { listNamespaces } from '~/shared/api/notebookService';
|
import { listNamespaces } from '~/shared/api/notebookService';
|
||||||
|
|
||||||
const mockRestPromise = Promise.resolve({ data: {} });
|
const mockRestResponse = { data: {} };
|
||||||
const mockRestResponse = {};
|
const mockRestPromise = Promise.resolve(mockRestResponse);
|
||||||
|
|
||||||
jest.mock('~/shared/api/apiUtils', () => ({
|
jest.mock('~/shared/api/apiUtils', () => ({
|
||||||
restCREATE: jest.fn(() => mockRestPromise),
|
restCREATE: jest.fn(() => mockRestPromise),
|
||||||
|
@ -12,13 +11,10 @@ jest.mock('~/shared/api/apiUtils', () => ({
|
||||||
restPATCH: jest.fn(() => mockRestPromise),
|
restPATCH: jest.fn(() => mockRestPromise),
|
||||||
isNotebookResponse: jest.fn(() => true),
|
isNotebookResponse: jest.fn(() => true),
|
||||||
extractNotebookResponse: jest.fn(() => mockRestResponse),
|
extractNotebookResponse: jest.fn(() => mockRestResponse),
|
||||||
|
wrapRequest: jest.fn(() => mockRestPromise),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/shared/api/errorUtils', () => ({
|
const wrapRequestMock = jest.mocked(wrapRequest);
|
||||||
handleRestFailures: jest.fn(() => mockRestPromise),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleRestFailuresMock = jest.mocked(handleRestFailures);
|
|
||||||
const restGETMock = jest.mocked(restGET);
|
const restGETMock = jest.mocked(restGET);
|
||||||
const APIOptionsMock = {};
|
const APIOptionsMock = {};
|
||||||
|
|
||||||
|
@ -33,7 +29,7 @@ describe('getNamespaces', () => {
|
||||||
{},
|
{},
|
||||||
APIOptionsMock,
|
APIOptionsMock,
|
||||||
);
|
);
|
||||||
expect(handleRestFailuresMock).toHaveBeenCalledTimes(1);
|
expect(wrapRequestMock).toHaveBeenCalledTimes(1);
|
||||||
expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise);
|
expect(wrapRequestMock).toHaveBeenCalledWith(mockRestPromise);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ErrorEnvelope } from '~/shared/api/backendApiTypes';
|
||||||
|
import { handleRestFailures } from '~/shared/api/errorUtils';
|
||||||
import { APIOptions, ResponseBody } from '~/shared/api/types';
|
import { APIOptions, ResponseBody } from '~/shared/api/types';
|
||||||
import { EitherOrNone } from '~/shared/typeHelpers';
|
import { EitherOrNone } from '~/shared/typeHelpers';
|
||||||
import { AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const';
|
import { AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const';
|
||||||
|
@ -222,9 +224,48 @@ export const isNotebookResponse = <T>(response: unknown): response is ResponseBo
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isErrorEnvelope = (e: unknown): e is ErrorEnvelope =>
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
'error' in e &&
|
||||||
|
typeof (e as Record<string, unknown>).error === 'object' &&
|
||||||
|
(e as { error: unknown }).error !== null &&
|
||||||
|
typeof (e as { error: { message: unknown } }).error.message === 'string';
|
||||||
|
|
||||||
export function extractNotebookResponse<T>(response: unknown): T {
|
export function extractNotebookResponse<T>(response: unknown): T {
|
||||||
if (isNotebookResponse<T>(response)) {
|
if (isNotebookResponse<T>(response)) {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
throw new Error('Invalid response format');
|
throw new Error('Invalid response format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractErrorEnvelope(error: unknown): ErrorEnvelope {
|
||||||
|
if (isErrorEnvelope(error)) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unexpected error';
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: 'UNKNOWN_ERROR',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrapRequest<T>(promise: Promise<T>, extractData = true): Promise<T> {
|
||||||
|
try {
|
||||||
|
const res = await handleRestFailures<T>(promise);
|
||||||
|
return extractData ? extractNotebookResponse<T>(res) : res;
|
||||||
|
} catch (error) {
|
||||||
|
throw new ErrorEnvelopeException(extractErrorEnvelope(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorEnvelopeException extends Error {
|
||||||
|
constructor(public envelope: ErrorEnvelope) {
|
||||||
|
super(envelope.error?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -282,3 +282,36 @@ export interface WorkspacePauseState {
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum FieldErrorType {
|
||||||
|
FieldValueRequired = 'FieldValueRequired',
|
||||||
|
FieldValueInvalid = 'FieldValueInvalid',
|
||||||
|
FieldValueNotSupported = 'FieldValueNotSupported',
|
||||||
|
FieldValueDuplicate = 'FieldValueDuplicate',
|
||||||
|
FieldValueTooLong = 'FieldValueTooLong',
|
||||||
|
FieldValueForbidden = 'FieldValueForbidden',
|
||||||
|
FieldValueNotFound = 'FieldValueNotFound',
|
||||||
|
FieldValueConflict = 'FieldValueConflict',
|
||||||
|
FieldValueTooShort = 'FieldValueTooShort',
|
||||||
|
FieldValueUnknown = 'FieldValueUnknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
type: FieldErrorType;
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorCause {
|
||||||
|
validation_errors?: ValidationError[]; // TODO: backend is not using camelCase for this field
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HTTPError = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
cause?: ErrorCause;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorEnvelope = {
|
||||||
|
error: HTTPError | null;
|
||||||
|
};
|
||||||
|
|
|
@ -1,25 +1,16 @@
|
||||||
import { APIError } from '~/shared/api/types';
|
import { ErrorEnvelopeException, isErrorEnvelope } from '~/shared/api//apiUtils';
|
||||||
import { isCommonStateError } from '~/shared/utilities/useFetchState';
|
import { isCommonStateError } from '~/shared/utilities/useFetchState';
|
||||||
|
|
||||||
const isError = (e: unknown): e is APIError => typeof e === 'object' && e !== null && 'error' in e;
|
|
||||||
|
|
||||||
export const handleRestFailures = <T>(promise: Promise<T>): Promise<T> =>
|
export const handleRestFailures = <T>(promise: Promise<T>): Promise<T> =>
|
||||||
promise
|
promise.catch((e) => {
|
||||||
.then((result) => {
|
if (isErrorEnvelope(e)) {
|
||||||
if (isError(result)) {
|
throw new ErrorEnvelopeException(e);
|
||||||
throw result;
|
}
|
||||||
}
|
if (isCommonStateError(e)) {
|
||||||
return result;
|
// Common state errors are handled by useFetchState at storage level, let them deal with it
|
||||||
})
|
throw e;
|
||||||
.catch((e) => {
|
}
|
||||||
if (isError(e)) {
|
// eslint-disable-next-line no-console
|
||||||
throw new Error(e.error.message);
|
console.error('Unknown API error', e);
|
||||||
}
|
throw new Error('Error communicating with server');
|
||||||
if (isCommonStateError(e)) {
|
});
|
||||||
// Common state errors are handled by useFetchState at storage level, let them deal with it
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('Unknown API error', e);
|
|
||||||
throw new Error('Error communicating with server');
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,19 +1,12 @@
|
||||||
import {
|
import {
|
||||||
extractNotebookResponse,
|
|
||||||
restCREATE,
|
restCREATE,
|
||||||
restDELETE,
|
restDELETE,
|
||||||
restGET,
|
restGET,
|
||||||
restPATCH,
|
restPATCH,
|
||||||
restUPDATE,
|
restUPDATE,
|
||||||
restYAML,
|
restYAML,
|
||||||
|
wrapRequest,
|
||||||
} from '~/shared/api/apiUtils';
|
} from '~/shared/api/apiUtils';
|
||||||
import { handleRestFailures } from '~/shared/api/errorUtils';
|
|
||||||
import {
|
|
||||||
Namespace,
|
|
||||||
Workspace,
|
|
||||||
WorkspaceKind,
|
|
||||||
WorkspacePauseState,
|
|
||||||
} from '~/shared/api/backendApiTypes';
|
|
||||||
import {
|
import {
|
||||||
CreateWorkspaceAPI,
|
CreateWorkspaceAPI,
|
||||||
CreateWorkspaceKindAPI,
|
CreateWorkspaceKindAPI,
|
||||||
|
@ -32,86 +25,60 @@ import {
|
||||||
StartWorkspaceAPI,
|
StartWorkspaceAPI,
|
||||||
UpdateWorkspaceAPI,
|
UpdateWorkspaceAPI,
|
||||||
UpdateWorkspaceKindAPI,
|
UpdateWorkspaceKindAPI,
|
||||||
} from './callTypes';
|
} from '~/shared/api/callTypes';
|
||||||
|
|
||||||
export const getHealthCheck: GetHealthCheckAPI = (hostPath) => (opts) =>
|
export const getHealthCheck: GetHealthCheckAPI = (hostPath) => (opts) =>
|
||||||
handleRestFailures(restGET(hostPath, `/healthcheck`, {}, opts));
|
wrapRequest(restGET(hostPath, `/healthcheck`, {}, opts), false);
|
||||||
|
|
||||||
export const listNamespaces: ListNamespacesAPI = (hostPath) => (opts) =>
|
export const listNamespaces: ListNamespacesAPI = (hostPath) => (opts) =>
|
||||||
handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) =>
|
wrapRequest(restGET(hostPath, `/namespaces`, {}, opts));
|
||||||
extractNotebookResponse<Namespace[]>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const listAllWorkspaces: ListAllWorkspacesAPI = (hostPath) => (opts) =>
|
export const listAllWorkspaces: ListAllWorkspacesAPI = (hostPath) => (opts) =>
|
||||||
handleRestFailures(restGET(hostPath, `/workspaces`, {}, opts)).then((response) =>
|
wrapRequest(restGET(hostPath, `/workspaces`, {}, opts));
|
||||||
extractNotebookResponse<Workspace[]>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const listWorkspaces: ListWorkspacesAPI = (hostPath) => (opts, namespace) =>
|
export const listWorkspaces: ListWorkspacesAPI = (hostPath) => (opts, namespace) =>
|
||||||
handleRestFailures(restGET(hostPath, `/workspaces/${namespace}`, {}, opts)).then((response) =>
|
wrapRequest(restGET(hostPath, `/workspaces/${namespace}`, {}, opts));
|
||||||
extractNotebookResponse<Workspace[]>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getWorkspace: GetWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
export const getWorkspace: GetWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
||||||
handleRestFailures(restGET(hostPath, `/workspaces/${namespace}/${workspace}`, {}, opts)).then(
|
wrapRequest(restGET(hostPath, `/workspaces/${namespace}/${workspace}`, {}, opts));
|
||||||
(response) => extractNotebookResponse<Workspace>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const createWorkspace: CreateWorkspaceAPI = (hostPath) => (opts, namespace, data) =>
|
export const createWorkspace: CreateWorkspaceAPI = (hostPath) => (opts, namespace, data) =>
|
||||||
handleRestFailures(restCREATE(hostPath, `/workspaces/${namespace}`, data, {}, opts)).then(
|
wrapRequest(restCREATE(hostPath, `/workspaces/${namespace}`, data, {}, opts));
|
||||||
(response) => extractNotebookResponse<Workspace>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateWorkspace: UpdateWorkspaceAPI =
|
export const updateWorkspace: UpdateWorkspaceAPI =
|
||||||
(hostPath) => (opts, namespace, workspace, data) =>
|
(hostPath) => (opts, namespace, workspace, data) =>
|
||||||
handleRestFailures(
|
wrapRequest(restUPDATE(hostPath, `/workspaces/${namespace}/${workspace}`, data, {}, opts));
|
||||||
restUPDATE(hostPath, `/workspaces/${namespace}/${workspace}`, data, {}, opts),
|
|
||||||
).then((response) => extractNotebookResponse<Workspace>(response));
|
|
||||||
|
|
||||||
export const patchWorkspace: PatchWorkspaceAPI = (hostPath) => (opts, namespace, workspace, data) =>
|
export const patchWorkspace: PatchWorkspaceAPI = (hostPath) => (opts, namespace, workspace, data) =>
|
||||||
handleRestFailures(restPATCH(hostPath, `/workspaces/${namespace}/${workspace}`, data, opts)).then(
|
wrapRequest(restPATCH(hostPath, `/workspaces/${namespace}/${workspace}`, data, opts));
|
||||||
(response) => extractNotebookResponse<Workspace>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteWorkspace: DeleteWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
export const deleteWorkspace: DeleteWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
||||||
handleRestFailures(restDELETE(hostPath, `/workspaces/${namespace}/${workspace}`, {}, {}, opts));
|
wrapRequest(restDELETE(hostPath, `/workspaces/${namespace}/${workspace}`, {}, {}, opts), false);
|
||||||
|
|
||||||
export const pauseWorkspace: PauseWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
export const pauseWorkspace: PauseWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
||||||
handleRestFailures(
|
wrapRequest(
|
||||||
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/pause`, {}, opts),
|
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/pause`, {}, opts),
|
||||||
).then((response) => extractNotebookResponse<WorkspacePauseState>(response));
|
);
|
||||||
|
|
||||||
export const startWorkspace: StartWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
export const startWorkspace: StartWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
|
||||||
handleRestFailures(
|
wrapRequest(
|
||||||
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/start`, {}, opts),
|
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/start`, {}, opts),
|
||||||
).then((response) => extractNotebookResponse<WorkspacePauseState>(response));
|
);
|
||||||
|
|
||||||
export const listWorkspaceKinds: ListWorkspaceKindsAPI = (hostPath) => (opts) =>
|
export const listWorkspaceKinds: ListWorkspaceKindsAPI = (hostPath) => (opts) =>
|
||||||
handleRestFailures(restGET(hostPath, `/workspacekinds`, {}, opts)).then((response) =>
|
wrapRequest(restGET(hostPath, `/workspacekinds`, {}, opts));
|
||||||
extractNotebookResponse<WorkspaceKind[]>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getWorkspaceKind: GetWorkspaceKindAPI = (hostPath) => (opts, kind) =>
|
export const getWorkspaceKind: GetWorkspaceKindAPI = (hostPath) => (opts, kind) =>
|
||||||
handleRestFailures(restGET(hostPath, `/workspacekinds/${kind}`, {}, opts)).then((response) =>
|
wrapRequest(restGET(hostPath, `/workspacekinds/${kind}`, {}, opts));
|
||||||
extractNotebookResponse<WorkspaceKind>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const createWorkspaceKind: CreateWorkspaceKindAPI = (hostPath) => (opts, data) =>
|
export const createWorkspaceKind: CreateWorkspaceKindAPI = (hostPath) => (opts, data) =>
|
||||||
handleRestFailures(restYAML(hostPath, `/workspacekinds`, data, {}, opts)).then((response) =>
|
wrapRequest(restYAML(hostPath, `/workspacekinds`, data, {}, opts));
|
||||||
extractNotebookResponse<WorkspaceKind>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateWorkspaceKind: UpdateWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
|
export const updateWorkspaceKind: UpdateWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
|
||||||
handleRestFailures(restUPDATE(hostPath, `/workspacekinds/${kind}`, data, {}, opts)).then(
|
wrapRequest(restUPDATE(hostPath, `/workspacekinds/${kind}`, data, {}, opts));
|
||||||
(response) => extractNotebookResponse<WorkspaceKind>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const patchWorkspaceKind: PatchWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
|
export const patchWorkspaceKind: PatchWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
|
||||||
handleRestFailures(restPATCH(hostPath, `/workspacekinds/${kind}`, data, opts)).then((response) =>
|
wrapRequest(restPATCH(hostPath, `/workspacekinds/${kind}`, data, opts));
|
||||||
extractNotebookResponse<WorkspaceKind>(response),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteWorkspaceKind: DeleteWorkspaceKindAPI = (hostPath) => (opts, kind) =>
|
export const deleteWorkspaceKind: DeleteWorkspaceKindAPI = (hostPath) => (opts, kind) =>
|
||||||
handleRestFailures(restDELETE(hostPath, `/workspacekinds/${kind}`, {}, {}, opts)).then(
|
wrapRequest(restDELETE(hostPath, `/workspacekinds/${kind}`, {}, {}, opts), false);
|
||||||
(response) => extractNotebookResponse<void>(response),
|
|
||||||
);
|
|
||||||
|
|
|
@ -5,13 +5,6 @@ export type APIOptions = {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type APIError = {
|
|
||||||
error: {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type APIState<T> = {
|
export type APIState<T> = {
|
||||||
/** If API will successfully call */
|
/** If API will successfully call */
|
||||||
apiAvailable: boolean;
|
apiAvailable: boolean;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
|
||||||
|
import { FieldErrorType } from '~/shared/api/backendApiTypes';
|
||||||
import {
|
import {
|
||||||
CreateWorkspaceAPI,
|
CreateWorkspaceAPI,
|
||||||
CreateWorkspaceKindAPI,
|
CreateWorkspaceKindAPI,
|
||||||
|
@ -27,6 +29,7 @@ import {
|
||||||
mockWorkspaceKind1,
|
mockWorkspaceKind1,
|
||||||
mockWorkspaceKinds,
|
mockWorkspaceKinds,
|
||||||
} from '~/shared/mock/mockNotebookServiceData';
|
} from '~/shared/mock/mockNotebookServiceData';
|
||||||
|
import { isInvalidYaml } from '~/shared/mock/mockUtils';
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
|
@ -70,7 +73,37 @@ export const mockListWorkspaceKinds: ListWorkspaceKindsAPI = () => async () => m
|
||||||
export const mockGetWorkspaceKind: GetWorkspaceKindAPI = () => async (_opts, kind) =>
|
export const mockGetWorkspaceKind: GetWorkspaceKindAPI = () => async (_opts, kind) =>
|
||||||
mockWorkspaceKinds.find((w) => w.name === kind)!;
|
mockWorkspaceKinds.find((w) => w.name === kind)!;
|
||||||
|
|
||||||
export const mockCreateWorkspaceKind: CreateWorkspaceKindAPI = () => async () => mockWorkspaceKind1;
|
export const mockCreateWorkspaceKind: CreateWorkspaceKindAPI = () => async (_opts, data) => {
|
||||||
|
if (isInvalidYaml(data)) {
|
||||||
|
throw new ErrorEnvelopeException({
|
||||||
|
error: {
|
||||||
|
code: 'invalid_yaml',
|
||||||
|
message: 'Invalid YAML provided',
|
||||||
|
cause: {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
validation_errors: [
|
||||||
|
{
|
||||||
|
type: FieldErrorType.FieldValueRequired,
|
||||||
|
field: 'spec.spawner.displayName',
|
||||||
|
message: "Missing required 'spec.spawner.displayName' property",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldErrorType.FieldValueUnknown,
|
||||||
|
field: 'spec.spawner.xyz',
|
||||||
|
message: "Unknown property 'spec.spawner.xyz'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldErrorType.FieldValueNotSupported,
|
||||||
|
field: 'spec.spawner.hidden',
|
||||||
|
message: "Invalid data type for 'spec.spawner.hidden', expected 'boolean'",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mockWorkspaceKind1;
|
||||||
|
};
|
||||||
|
|
||||||
export const mockUpdateWorkspaceKind: UpdateWorkspaceKindAPI = () => async () => mockWorkspaceKind1;
|
export const mockUpdateWorkspaceKind: UpdateWorkspaceKindAPI = () => async () => mockWorkspaceKind1;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
// For testing purposes, a YAML string is considered invalid if it contains a specific pattern in the metadata name.
|
||||||
|
export function isInvalidYaml(yamlString: string): boolean {
|
||||||
|
const parsed = yaml.load(yamlString) as { metadata?: { name?: string } };
|
||||||
|
return parsed.metadata?.name?.includes('-invalid') ?? false;
|
||||||
|
}
|
|
@ -124,7 +124,6 @@
|
||||||
.mui-theme .pf-v6-c-alert {
|
.mui-theme .pf-v6-c-alert {
|
||||||
--pf-v6-c-alert--m-warning__title--Color: var(--pf-t--global--text--color--status--warning--default);
|
--pf-v6-c-alert--m-warning__title--Color: var(--pf-t--global--text--color--status--warning--default);
|
||||||
--pf-v6-c-alert__icon--MarginInlineEnd: var(--mui-alert__icon--MarginInlineEnd);
|
--pf-v6-c-alert__icon--MarginInlineEnd: var(--mui-alert__icon--MarginInlineEnd);
|
||||||
--pf-v6-c-alert__title--FontWeight: var(--mui-alert-warning-font-weight);
|
|
||||||
--pf-v6-c-alert__icon--MarginBlockStart: var(--mui-alert__icon--MarginBlockStart);
|
--pf-v6-c-alert__icon--MarginBlockStart: var(--mui-alert__icon--MarginBlockStart);
|
||||||
--pf-v6-c-alert__icon--FontSize: var(--mui-alert__icon--FontSize);
|
--pf-v6-c-alert__icon--FontSize: var(--mui-alert__icon--FontSize);
|
||||||
--pf-v6-c-alert--BoxShadow: var(--mui-alert--BoxShadow);
|
--pf-v6-c-alert--BoxShadow: var(--mui-alert--BoxShadow);
|
||||||
|
|
Loading…
Reference in New Issue