From 95e3449773fbc8e018c1f3c469d91460518cede1 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Fri, 22 May 2026 12:15:02 +0100 Subject: [PATCH 1/4] fix(metadata): surface real error when saving custom field Replace the generic "Failed to save custom field" toast with a phase-aware message that distinguishes a failure to create the MetadataField from a failure to bind it to a content type. When the field was created but the binding call failed (e.g. 403 for non-admin project administrators, see #7424), tell the user the field is orphaned and needs an organisation administrator to finish or remove it. Other errors now bubble up the backend detail string instead of being swallowed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/modals/CreateMetadataField.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/modals/CreateMetadataField.tsx b/frontend/web/components/modals/CreateMetadataField.tsx index 8c8dd076a8a6..065cfa68542b 100644 --- a/frontend/web/components/modals/CreateMetadataField.tsx +++ b/frontend/web/components/modals/CreateMetadataField.tsx @@ -161,7 +161,42 @@ const CreateMetadataField: FC = ({ return query } + const getErrorDetail = (e: any): string | null => { + const data = e?.data + if (!data) return null + if (typeof data === 'string') return data + if (typeof data?.detail === 'string') return data.detail + if (Array.isArray(data) && typeof data[0] === 'string') return data[0] + return null + } + + const buildErrorMessage = ( + e: any, + phase: 'field' | 'binding', + orphaned: boolean, + ): string => { + const status = e?.status + const detail = getErrorDetail(e) + + if (status === 403) { + if (phase === 'binding' && orphaned) { + return "You don't have permission to assign this custom field to entities. The field was created but is not bound to anything — ask an organisation administrator to finish the setup or remove it." + } + return detail || "You don't have permission to perform this action." + } + + if (phase === 'binding' && orphaned) { + return `Custom field was created but failed to bind to the selected entities${ + detail ? `: ${detail}` : '.' + } Ask an organisation administrator to finish the setup or remove the orphaned field.` + } + + return detail || 'Failed to save custom field' + } + const save = async () => { + let orphanedField = false + let phase: 'field' | 'binding' = 'field' try { if (isEdit) { await updateMetadataField({ @@ -174,6 +209,7 @@ const CreateMetadataField: FC = ({ }, id: id!, }).unwrap() + phase = 'binding' if (metadataFieldSelectList.length) { await Promise.all( metadataFieldSelectList.map(async (m) => { @@ -226,6 +262,8 @@ const CreateMetadataField: FC = ({ }, }).unwrap() if (res?.id) { + phase = 'binding' + orphanedField = true await Promise.all( metadataFieldSelectList.map(async (m) => { const query = generateDataQuery( @@ -238,6 +276,7 @@ const CreateMetadataField: FC = ({ await createMetadataModelField(query).unwrap() }), ) + orphanedField = false } } getStore().dispatch( @@ -246,7 +285,7 @@ const CreateMetadataField: FC = ({ onComplete?.() closeModal() } catch (e) { - toast('Failed to save custom field', 'danger') + toast(buildErrorMessage(e, phase, orphanedField), 'danger') } } From e1683733e987ab4be508174bce1641c07e069109 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Fri, 22 May 2026 13:02:55 +0100 Subject: [PATCH 2/4] fix(metadata): handle more backend error shapes in custom field save Address review feedback on #7578: getErrorDetail now extracts messages from DRF field-error objects, non_field_errors arrays, raw string errors, and network failures with message/error properties. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/components/modals/CreateMetadataField.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/modals/CreateMetadataField.tsx b/frontend/web/components/modals/CreateMetadataField.tsx index 065cfa68542b..1412a684e7d3 100644 --- a/frontend/web/components/modals/CreateMetadataField.tsx +++ b/frontend/web/components/modals/CreateMetadataField.tsx @@ -162,11 +162,24 @@ const CreateMetadataField: FC = ({ } const getErrorDetail = (e: any): string | null => { + if (typeof e === 'string') return e const data = e?.data - if (!data) return null + if (!data) return e?.message || e?.error || null if (typeof data === 'string') return data if (typeof data?.detail === 'string') return data.detail + if ( + Array.isArray(data?.non_field_errors) && + typeof data.non_field_errors[0] === 'string' + ) { + return data.non_field_errors[0] + } if (Array.isArray(data) && typeof data[0] === 'string') return data[0] + if (data && typeof data === 'object') { + const firstKey = Object.keys(data)[0] + const firstError = firstKey ? data[firstKey] : undefined + const errorMsg = Array.isArray(firstError) ? firstError[0] : firstError + if (typeof errorMsg === 'string') return errorMsg + } return null } From 9f39c48ed1f08bb64f0288a5e6c5fe6ac6aebe9a Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Fri, 22 May 2026 13:05:12 +0100 Subject: [PATCH 3/4] refactor(metadata): use existing ErrorMessage component for save errors Drop the custom getErrorDetail/buildErrorMessage parsers in favour of the existing component, which already handles DRF detail, non_field_errors, field-error objects and Error instances. Store the caught error in state and render it inline, with a separate warning banner when a successful create was followed by a binding failure (the orphaned-field case from #7424). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/modals/CreateMetadataField.tsx | 67 +++++-------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/frontend/web/components/modals/CreateMetadataField.tsx b/frontend/web/components/modals/CreateMetadataField.tsx index 1412a684e7d3..5057f1474649 100644 --- a/frontend/web/components/modals/CreateMetadataField.tsx +++ b/frontend/web/components/modals/CreateMetadataField.tsx @@ -129,6 +129,8 @@ const CreateMetadataField: FC = ({ >([]) const [metadataUpdatedSelectList, setMetadataFieldUpdatedSelectList] = useState([]) + const [saveError, setSaveError] = useState(null) + const [orphanedAfterCreate, setOrphanedAfterCreate] = useState(false) const generateDataQuery = ( contentType: number, @@ -161,55 +163,10 @@ const CreateMetadataField: FC = ({ return query } - const getErrorDetail = (e: any): string | null => { - if (typeof e === 'string') return e - const data = e?.data - if (!data) return e?.message || e?.error || null - if (typeof data === 'string') return data - if (typeof data?.detail === 'string') return data.detail - if ( - Array.isArray(data?.non_field_errors) && - typeof data.non_field_errors[0] === 'string' - ) { - return data.non_field_errors[0] - } - if (Array.isArray(data) && typeof data[0] === 'string') return data[0] - if (data && typeof data === 'object') { - const firstKey = Object.keys(data)[0] - const firstError = firstKey ? data[firstKey] : undefined - const errorMsg = Array.isArray(firstError) ? firstError[0] : firstError - if (typeof errorMsg === 'string') return errorMsg - } - return null - } - - const buildErrorMessage = ( - e: any, - phase: 'field' | 'binding', - orphaned: boolean, - ): string => { - const status = e?.status - const detail = getErrorDetail(e) - - if (status === 403) { - if (phase === 'binding' && orphaned) { - return "You don't have permission to assign this custom field to entities. The field was created but is not bound to anything — ask an organisation administrator to finish the setup or remove it." - } - return detail || "You don't have permission to perform this action." - } - - if (phase === 'binding' && orphaned) { - return `Custom field was created but failed to bind to the selected entities${ - detail ? `: ${detail}` : '.' - } Ask an organisation administrator to finish the setup or remove the orphaned field.` - } - - return detail || 'Failed to save custom field' - } - const save = async () => { let orphanedField = false - let phase: 'field' | 'binding' = 'field' + setSaveError(null) + setOrphanedAfterCreate(false) try { if (isEdit) { await updateMetadataField({ @@ -222,7 +179,6 @@ const CreateMetadataField: FC = ({ }, id: id!, }).unwrap() - phase = 'binding' if (metadataFieldSelectList.length) { await Promise.all( metadataFieldSelectList.map(async (m) => { @@ -275,7 +231,6 @@ const CreateMetadataField: FC = ({ }, }).unwrap() if (res?.id) { - phase = 'binding' orphanedField = true await Promise.all( metadataFieldSelectList.map(async (m) => { @@ -298,7 +253,8 @@ const CreateMetadataField: FC = ({ onComplete?.() closeModal() } catch (e) { - toast(buildErrorMessage(e, phase, orphanedField), 'danger') + setSaveError(e) + setOrphanedAfterCreate(orphanedField) } } @@ -405,7 +361,16 @@ const CreateMetadataField: FC = ({ }} metadataModelFieldList={metadataModelFieldList!} /> - {errorCreating && } + {orphanedAfterCreate && ( +
+ The custom field was created but could not be bound to the selected + entities. Ask an organisation administrator to finish the setup or + remove the orphaned field. +
+ )} + {(saveError || errorCreating) && ( + + )}