diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index de38ebf5b0..a33710ee6c 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -40,6 +40,7 @@ import type { ApiFormFieldType, ApiFormProps } from '@lib/types/Forms'; +import { IconExclamationCircle } from '@tabler/icons-react'; import { useApi } from '../../contexts/ApiContext'; import { isEquivalent } from '../../functions/comparison'; import { constructField, extractAvailableFields } from '../../functions/forms'; @@ -569,12 +570,35 @@ export function ApiForm({ case 400: // Data validation errors const _nonFieldErrors: string[] = []; + let _fieldErrorCount = 0; + + const setFieldError = (path: string, message: string) => { + _fieldErrorCount += 1; + form.setError(path, { message: message }); + }; + + // Response keys which carry a general (non-field) error message + const NON_FIELD_ERROR_KEYS: string[] = [ + 'non_field_errors', + '__all__', + 'detail', + 'error', + 'message', + 'reason' + ]; const processErrors = (errors: any, _path?: string) => { + // Ignore empty values + if (errors === null || errors === undefined) { + return; + } + // Handle an array of errors if (Array.isArray(errors)) { errors.forEach((error: any) => { - _nonFieldErrors.push(error.toString()); + if (typeof error === 'string') { + _nonFieldErrors.push(error); + } }); return; } @@ -585,42 +609,100 @@ export function ApiForm({ return; } + // Ignore any other non-object values (numbers, booleans) + if (typeof errors !== 'object') { + return; + } + for (const [k, v] of Object.entries(errors)) { const path = _path ? `${_path}.${k}` : k; - // Determine if field "k" is valid (exists and is visible) - const field = fields[k]; - const valid = field && !field.hidden; - - if (!valid || k == 'non_field_errors' || k == '__all__') { + // Known error keys are displayed as non-field errors + if (NON_FIELD_ERROR_KEYS.includes(k)) { processErrors(v); continue; } - if (typeof v === 'object' && Array.isArray(v)) { - if (field?.field_type == 'table') { - // Special handling for "table" fields - they have nested errors - v.forEach((item: any, idx: number) => { - for (const [key, value] of Object.entries(item)) { - const path: string = `${k}.${idx}.${key}`; - if (Array.isArray(value)) { - form.setError(path, { message: value.join(', ') }); - } + // Nested errors (below the top level) cannot be mapped to a form field, + // so they are displayed as non-field errors rather than being dropped + if (_path) { + processErrors(v); + continue; + } + + const field = fields[k]; + + // Ignore top-level keys which do not correspond to a form field + // (e.g. response metadata such as "transition" or "state") + if (!field) { + continue; + } + + // Errors against hidden fields are displayed as non-field errors + if (field.hidden) { + processErrors(v); + continue; + } + + if ( + field.field_type == 'table' && + typeof v === 'object' && + v !== null + ) { + // Special handling for "table" fields - they have nested (per-row) errors. + // DRF may return these as an array (one entry per row), + // or as an object keyed by row index (only rows with errors) + const rows: [string, any][] = Array.isArray(v) + ? v.map((item: any, idx: number) => [`${idx}`, item]) + : Object.entries(v); + + rows.forEach(([idx, item]) => { + // Row-level error (not associated with a particular column) + if ( + typeof item !== 'object' || + item === null || + Array.isArray(item) + ) { + processErrors(item); + return; + } + + for (const [key, value] of Object.entries(item)) { + const rowPath: string = `${k}.${idx}.${key}`; + if (Array.isArray(value)) { + setFieldError(rowPath, value.join(', ')); + } else if (typeof value === 'string') { + setFieldError(rowPath, value); + } else { + processErrors(value); } - }); - } else { - // Standard error handling for other fields - form.setError(path, { message: v.join(', ') }); - } + } + }); + } else if (Array.isArray(v)) { + // Standard error handling for other fields + setFieldError(path, v.join(', ')); } else if (typeof v === 'string') { - form.setError(path, { message: v }); + setFieldError(path, v); } else { processErrors(v, path); } } }; - processErrors(error.response.data); + try { + processErrors(error.response.data); + } catch (err) { + // Malformed error response - fall through to the generic message below + console.error('Failed to process form errors:', err); + } + + // No errors could be extracted from the response + if (_nonFieldErrors.length == 0 && _fieldErrorCount == 0) { + _nonFieldErrors.push( + t`An unknown error occurred. Refer to the browser console for details.` + ); + } + setNonFieldErrors(_nonFieldErrors); props.onFormError?.(error, form); @@ -710,7 +792,12 @@ export function ApiForm({ {/* Form Fields */} {(!isValid || nonFieldErrors.length > 0) && ( - + } + radius='sm' + color='red' + title={t`Form Error`} + > {nonFieldErrors.length > 0 ? ( {nonFieldErrors