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:
Guilherme Caponetto 2025-07-07 08:14:21 -03:00 committed by GitHub
parent d38b24c76c
commit cbedbfff58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 273 additions and 152 deletions

View File

@ -17,7 +17,7 @@ const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => {
const location = useTypedLocation();
// 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 (
<NavItem isActive={isActive} key={item.label} data-id={item.label} itemId={item.label}>

View File

@ -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>
);
};

View File

@ -8,12 +8,17 @@ import {
PageGroup,
PageSection,
Stack,
StackItem,
} 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 { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey';
import useGenericObjectState from '~/app/hooks/useGenericObjectState';
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
import { WorkspaceKindFormData } from '~/app/types';
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
import { ValidationError } from '~/shared/api/backendApiTypes';
import { WorkspaceKindFileUpload } from './fileUpload/WorkspaceKindFileUpload';
import { WorkspaceKindFormProperties } from './properties/WorkspaceKindFormProperties';
import { WorkspaceKindFormImage } from './image/WorkspaceKindFormImage';
@ -34,6 +39,8 @@ export const WorkspaceKindForm: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [validated, setValidated] = useState<ValidationStatus>('default');
const mode = useCurrentRouteKey() === 'workspaceKindCreate' ? 'create' : 'edit';
const [specErrors, setSpecErrors] = useState<ValidationError[]>([]);
const [data, setData, resetData] = useGenericObjectState<WorkspaceKindFormData>({
properties: {
displayName: '',
@ -60,14 +67,24 @@ export const WorkspaceKindForm: React.FC = () => {
try {
if (mode === 'create') {
const newWorkspaceKind = await api.createWorkspaceKind({}, yamlValue);
// TODO: alert user about success
console.info('New workspace kind created:', JSON.stringify(newWorkspaceKind));
navigate('workspaceKinds');
}
} 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}`);
} finally {
setIsSubmitting(false);
}
navigate('workspaceKinds');
}, [navigate, mode, api, yamlValue]);
const canSubmit = useMemo(
@ -102,13 +119,25 @@ export const WorkspaceKindForm: React.FC = () => {
</PageGroup>
<PageSection isFilled>
{mode === 'create' && (
<WorkspaceKindFileUpload
resetData={resetData}
value={yamlValue}
setValue={setYamlValue}
validated={validated}
setValidated={setValidated}
/>
<Stack>
{specErrors.length > 0 && (
<StackItem style={{ padding: SmallPadding.value }}>
<ValidationErrorAlert title="Error creating workspace kind" errors={specErrors} />
</StackItem>
)}
<StackItem style={{ height: '100%' }}>
<WorkspaceKindFileUpload
resetData={resetData}
value={yamlValue}
setValue={setYamlValue}
validated={validated}
setValidated={setValidated}
onClear={() => {
setSpecErrors([]);
}}
/>
</StackItem>
</Stack>
)}
{mode === 'edit' && (
<>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useState } from 'react';
import yaml, { YAMLException } from 'js-yaml';
import {
FileUpload,
@ -7,6 +7,7 @@ import {
HelperText,
HelperTextItem,
Content,
DropzoneErrorCode,
} from '@patternfly/react-core';
import { isValidWorkspaceKindYaml } from '~/app/pages/WorkspaceKinds/Form/helpers';
import { ValidationStatus } from '~/app/pages/WorkspaceKinds/Form/WorkspaceKindForm';
@ -17,60 +18,52 @@ interface WorkspaceKindFileUploadProps {
resetData: () => void;
validated: ValidationStatus;
setValidated: (type: ValidationStatus) => void;
onClear: () => void;
}
const YAML_MIME_TYPE = 'application/x-yaml';
const YAML_EXTENSIONS = ['.yml', '.yaml'];
export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = ({
resetData,
value,
setValue,
validated,
setValidated,
onClear,
}) => {
const isYamlFileRef = useRef(false);
const [filename, setFilename] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [fileUploadHelperText, setFileUploadHelperText] = useState<string>('');
const [fileUploadHelperText, setFileUploadHelperText] = useState<string | undefined>();
const handleFileInputChange = useCallback(
(_: unknown, file: File) => {
const fileName = file.name;
onClear();
setFilename(file.name);
// if extension is not yaml or yml, raise a flag
const ext = fileName.split('.').pop();
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');
}
setFileUploadHelperText(undefined);
setValidated('success');
},
[resetData, setValidated],
[setValidated, onClear],
);
// TODO: Use zod or another TS type coercion/schema for file upload
const handleDataChange = useCallback(
(_: DropEvent, v: string) => {
setValue(v);
if (isYamlFileRef.current) {
try {
const parsed = yaml.load(v);
if (!isValidWorkspaceKindYaml(parsed)) {
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']}`);
try {
const parsed = yaml.load(v);
if (!isValidWorkspaceKindYaml(parsed)) {
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');
}
},
[setValue, setValidated, resetData],
@ -82,7 +75,8 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
setFileUploadHelperText('');
setValidated('default');
resetData();
}, [resetData, setValidated, setValue]);
onClear();
}, [resetData, setValidated, setValue, onClear]);
const handleFileReadStarted = useCallback(() => {
setIsLoading(true);
@ -110,14 +104,27 @@ export const WorkspaceKindFileUpload: React.FC<WorkspaceKindFileUploadProps> = (
validated={validated}
allowEditingUploadedText={false}
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>
<HelperText>
<HelperTextItem id="helper-text-example-helpText">
{fileUploadHelperText}
</HelperTextItem>
</HelperText>
</FileUploadHelperText>
{fileUploadHelperText && (
<FileUploadHelperText>
<HelperText>
<HelperTextItem id="helper-text-example-helpText" variant="error">
{fileUploadHelperText}
</HelperTextItem>
</HelperText>
</FileUploadHelperText>
)}
</FileUpload>
</Content>
);

View File

@ -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 { 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', () => {
it('should successfully return namespaces', async () => {
@ -11,14 +12,14 @@ describe('handleRestFailures', () => {
});
it('should handle and throw notebook errors', async () => {
const statusMock: APIError = {
const errorEnvelope: ErrorEnvelope = {
error: {
code: '',
message: 'error',
code: '<error_code>',
message: '<error_message>',
},
};
await expect(handleRestFailures(Promise.resolve(statusMock))).rejects.toThrow('error');
const expectedError = new ErrorEnvelopeException(errorEnvelope);
await expect(handleRestFailures(Promise.reject(errorEnvelope))).rejects.toThrow(expectedError);
});
it('should handle common state errors ', async () => {

View File

@ -1,10 +1,9 @@
import { BFF_API_VERSION } from '~/app/const';
import { restGET } from '~/shared/api/apiUtils';
import { handleRestFailures } from '~/shared/api/errorUtils';
import { restGET, wrapRequest } from '~/shared/api/apiUtils';
import { listNamespaces } from '~/shared/api/notebookService';
const mockRestPromise = Promise.resolve({ data: {} });
const mockRestResponse = {};
const mockRestResponse = { data: {} };
const mockRestPromise = Promise.resolve(mockRestResponse);
jest.mock('~/shared/api/apiUtils', () => ({
restCREATE: jest.fn(() => mockRestPromise),
@ -12,13 +11,10 @@ jest.mock('~/shared/api/apiUtils', () => ({
restPATCH: jest.fn(() => mockRestPromise),
isNotebookResponse: jest.fn(() => true),
extractNotebookResponse: jest.fn(() => mockRestResponse),
wrapRequest: jest.fn(() => mockRestPromise),
}));
jest.mock('~/shared/api/errorUtils', () => ({
handleRestFailures: jest.fn(() => mockRestPromise),
}));
const handleRestFailuresMock = jest.mocked(handleRestFailures);
const wrapRequestMock = jest.mocked(wrapRequest);
const restGETMock = jest.mocked(restGET);
const APIOptionsMock = {};
@ -33,7 +29,7 @@ describe('getNamespaces', () => {
{},
APIOptionsMock,
);
expect(handleRestFailuresMock).toHaveBeenCalledTimes(1);
expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise);
expect(wrapRequestMock).toHaveBeenCalledTimes(1);
expect(wrapRequestMock).toHaveBeenCalledWith(mockRestPromise);
});
});

View File

@ -1,3 +1,5 @@
import { ErrorEnvelope } from '~/shared/api/backendApiTypes';
import { handleRestFailures } from '~/shared/api/errorUtils';
import { APIOptions, ResponseBody } from '~/shared/api/types';
import { EitherOrNone } from '~/shared/typeHelpers';
import { AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const';
@ -222,9 +224,48 @@ export const isNotebookResponse = <T>(response: unknown): response is ResponseBo
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 {
if (isNotebookResponse<T>(response)) {
return response.data;
}
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');
}
}

View File

@ -282,3 +282,36 @@ export interface WorkspacePauseState {
workspaceName: string;
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;
};

View File

@ -1,25 +1,16 @@
import { APIError } from '~/shared/api/types';
import { ErrorEnvelopeException, isErrorEnvelope } from '~/shared/api//apiUtils';
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> =>
promise
.then((result) => {
if (isError(result)) {
throw result;
}
return result;
})
.catch((e) => {
if (isError(e)) {
throw new Error(e.error.message);
}
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');
});
promise.catch((e) => {
if (isErrorEnvelope(e)) {
throw new ErrorEnvelopeException(e);
}
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');
});

View File

@ -1,19 +1,12 @@
import {
extractNotebookResponse,
restCREATE,
restDELETE,
restGET,
restPATCH,
restUPDATE,
restYAML,
wrapRequest,
} from '~/shared/api/apiUtils';
import { handleRestFailures } from '~/shared/api/errorUtils';
import {
Namespace,
Workspace,
WorkspaceKind,
WorkspacePauseState,
} from '~/shared/api/backendApiTypes';
import {
CreateWorkspaceAPI,
CreateWorkspaceKindAPI,
@ -32,86 +25,60 @@ import {
StartWorkspaceAPI,
UpdateWorkspaceAPI,
UpdateWorkspaceKindAPI,
} from './callTypes';
} from '~/shared/api/callTypes';
export const getHealthCheck: GetHealthCheckAPI = (hostPath) => (opts) =>
handleRestFailures(restGET(hostPath, `/healthcheck`, {}, opts));
wrapRequest(restGET(hostPath, `/healthcheck`, {}, opts), false);
export const listNamespaces: ListNamespacesAPI = (hostPath) => (opts) =>
handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) =>
extractNotebookResponse<Namespace[]>(response),
);
wrapRequest(restGET(hostPath, `/namespaces`, {}, opts));
export const listAllWorkspaces: ListAllWorkspacesAPI = (hostPath) => (opts) =>
handleRestFailures(restGET(hostPath, `/workspaces`, {}, opts)).then((response) =>
extractNotebookResponse<Workspace[]>(response),
);
wrapRequest(restGET(hostPath, `/workspaces`, {}, opts));
export const listWorkspaces: ListWorkspacesAPI = (hostPath) => (opts, namespace) =>
handleRestFailures(restGET(hostPath, `/workspaces/${namespace}`, {}, opts)).then((response) =>
extractNotebookResponse<Workspace[]>(response),
);
wrapRequest(restGET(hostPath, `/workspaces/${namespace}`, {}, opts));
export const getWorkspace: GetWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
handleRestFailures(restGET(hostPath, `/workspaces/${namespace}/${workspace}`, {}, opts)).then(
(response) => extractNotebookResponse<Workspace>(response),
);
wrapRequest(restGET(hostPath, `/workspaces/${namespace}/${workspace}`, {}, opts));
export const createWorkspace: CreateWorkspaceAPI = (hostPath) => (opts, namespace, data) =>
handleRestFailures(restCREATE(hostPath, `/workspaces/${namespace}`, data, {}, opts)).then(
(response) => extractNotebookResponse<Workspace>(response),
);
wrapRequest(restCREATE(hostPath, `/workspaces/${namespace}`, data, {}, opts));
export const updateWorkspace: UpdateWorkspaceAPI =
(hostPath) => (opts, namespace, workspace, data) =>
handleRestFailures(
restUPDATE(hostPath, `/workspaces/${namespace}/${workspace}`, data, {}, opts),
).then((response) => extractNotebookResponse<Workspace>(response));
wrapRequest(restUPDATE(hostPath, `/workspaces/${namespace}/${workspace}`, data, {}, opts));
export const patchWorkspace: PatchWorkspaceAPI = (hostPath) => (opts, namespace, workspace, data) =>
handleRestFailures(restPATCH(hostPath, `/workspaces/${namespace}/${workspace}`, data, opts)).then(
(response) => extractNotebookResponse<Workspace>(response),
);
wrapRequest(restPATCH(hostPath, `/workspaces/${namespace}/${workspace}`, data, opts));
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) =>
handleRestFailures(
wrapRequest(
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/pause`, {}, opts),
).then((response) => extractNotebookResponse<WorkspacePauseState>(response));
);
export const startWorkspace: StartWorkspaceAPI = (hostPath) => (opts, namespace, workspace) =>
handleRestFailures(
wrapRequest(
restCREATE(hostPath, `/workspaces/${namespace}/${workspace}/actions/start`, {}, opts),
).then((response) => extractNotebookResponse<WorkspacePauseState>(response));
);
export const listWorkspaceKinds: ListWorkspaceKindsAPI = (hostPath) => (opts) =>
handleRestFailures(restGET(hostPath, `/workspacekinds`, {}, opts)).then((response) =>
extractNotebookResponse<WorkspaceKind[]>(response),
);
wrapRequest(restGET(hostPath, `/workspacekinds`, {}, opts));
export const getWorkspaceKind: GetWorkspaceKindAPI = (hostPath) => (opts, kind) =>
handleRestFailures(restGET(hostPath, `/workspacekinds/${kind}`, {}, opts)).then((response) =>
extractNotebookResponse<WorkspaceKind>(response),
);
wrapRequest(restGET(hostPath, `/workspacekinds/${kind}`, {}, opts));
export const createWorkspaceKind: CreateWorkspaceKindAPI = (hostPath) => (opts, data) =>
handleRestFailures(restYAML(hostPath, `/workspacekinds`, data, {}, opts)).then((response) =>
extractNotebookResponse<WorkspaceKind>(response),
);
wrapRequest(restYAML(hostPath, `/workspacekinds`, data, {}, opts));
export const updateWorkspaceKind: UpdateWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
handleRestFailures(restUPDATE(hostPath, `/workspacekinds/${kind}`, data, {}, opts)).then(
(response) => extractNotebookResponse<WorkspaceKind>(response),
);
wrapRequest(restUPDATE(hostPath, `/workspacekinds/${kind}`, data, {}, opts));
export const patchWorkspaceKind: PatchWorkspaceKindAPI = (hostPath) => (opts, kind, data) =>
handleRestFailures(restPATCH(hostPath, `/workspacekinds/${kind}`, data, opts)).then((response) =>
extractNotebookResponse<WorkspaceKind>(response),
);
wrapRequest(restPATCH(hostPath, `/workspacekinds/${kind}`, data, opts));
export const deleteWorkspaceKind: DeleteWorkspaceKindAPI = (hostPath) => (opts, kind) =>
handleRestFailures(restDELETE(hostPath, `/workspacekinds/${kind}`, {}, {}, opts)).then(
(response) => extractNotebookResponse<void>(response),
);
wrapRequest(restDELETE(hostPath, `/workspacekinds/${kind}`, {}, {}, opts), false);

View File

@ -5,13 +5,6 @@ export type APIOptions = {
headers?: Record<string, string>;
};
export type APIError = {
error: {
code: string;
message: string;
};
};
export type APIState<T> = {
/** If API will successfully call */
apiAvailable: boolean;

View File

@ -1,3 +1,5 @@
import { ErrorEnvelopeException } from '~/shared/api/apiUtils';
import { FieldErrorType } from '~/shared/api/backendApiTypes';
import {
CreateWorkspaceAPI,
CreateWorkspaceKindAPI,
@ -27,6 +29,7 @@ import {
mockWorkspaceKind1,
mockWorkspaceKinds,
} from '~/shared/mock/mockNotebookServiceData';
import { isInvalidYaml } from '~/shared/mock/mockUtils';
const delay = (ms: number) =>
new Promise((resolve) => {
@ -70,7 +73,37 @@ export const mockListWorkspaceKinds: ListWorkspaceKindsAPI = () => async () => m
export const mockGetWorkspaceKind: GetWorkspaceKindAPI = () => async (_opts, 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;

View File

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

View File

@ -124,7 +124,6 @@
.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__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--FontSize: var(--mui-alert__icon--FontSize);
--pf-v6-c-alert--BoxShadow: var(--mui-alert--BoxShadow);