From df02d3fc9ca90c14ebbdfa39567e7530da479ea2 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 12:11:35 +0800 Subject: [PATCH 1/2] fix(metadata-admin): validation messages name the offending widget + field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nested Zod issue (e.g. `widgets.2.layout`) was shown as just its head field label — "Widgets: Invalid input" — so an author couldn't tell WHICH widget or sub-field was at fault. labelForIssuePath dropped everything after the first path segment. Add describeIssuePath: for a nested path it appends a readable trail, resolving each array index to the item's stable identity (id/name/title, incl. I18nLabel objects) from the draft — "Widgets → priority_split → layout". Single-segment paths are unchanged. Pure + unit-tested (5 cases); labelForIssuePath now computes the head label then delegates. Found dogfooding the Studio dashboard designer. Co-Authored-By: Claude Opus 4.8 --- .../views/metadata-admin/ResourceEditPage.tsx | 32 ++++++---- .../views/metadata-admin/issuePath.test.ts | 41 ++++++++++++ .../src/views/metadata-admin/issuePath.ts | 64 +++++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/issuePath.test.ts create mode 100644 packages/app-shell/src/views/metadata-admin/issuePath.ts diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index ad9c2ac04..23c7954c6 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -105,6 +105,7 @@ import { getMetadataDefaultInspector } from './default-inspector-registry'; import { detectLocale, t, tFormat, translateValidationMessage } from './i18n'; import { JsonSourceEditor } from './JsonSourceEditor'; import { validateMetadataDraft, hasClientValidator } from './clientValidation'; +import { describeIssuePath } from './issuePath'; // react-resizable-panels' `direction` prop type does not always narrow // cleanly in our TS config; cast at the boundary (precedent: @@ -918,20 +919,27 @@ function MetadataResourceEditPageImpl({ function labelForIssuePath(path: string): string { const key = path.split('.')[0]; if (!key) return path; - const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any)); - const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : []; - for (const section of sections) { - const fields = Array.isArray(section?.fields) ? section.fields : []; - for (const field of fields) { - if (typeof field === 'string') { - if (field === key) return field; - } else if (field?.field === key) { - return String(field.label ?? key); + // Resolve the human label for the HEAD segment from the form/schema. + const headLabel = ((): string => { + const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any)); + const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : []; + for (const section of sections) { + const fields = Array.isArray(section?.fields) ? section.fields : []; + for (const field of fields) { + if (typeof field === 'string') { + if (field === key) return field; + } else if (field?.field === key) { + return String(field.label ?? key); + } } } - } - const props = (schema?.properties ?? {}) as Record; - return String(props[key]?.title ?? key); + const props = (schema?.properties ?? {}) as Record; + return String(props[key]?.title ?? key); + })(); + // For a NESTED path (e.g. `widgets.2.layout`) append a readable trail naming + // the offending element + sub-field, so a terse "Widgets: Invalid input" + // becomes "Widgets → priority_split → layout". + return describeIssuePath(headLabel, path, draft); } async function doSave(force: boolean) { diff --git a/packages/app-shell/src/views/metadata-admin/issuePath.test.ts b/packages/app-shell/src/views/metadata-admin/issuePath.test.ts new file mode 100644 index 000000000..50d73faef --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/issuePath.test.ts @@ -0,0 +1,41 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { describeIssuePath } from './issuePath'; + +describe('describeIssuePath', () => { + const dashboard = { widgets: [{ id: 'kpi_tasks' }, { id: 'priority_split' }] }; + + it('returns the head label unchanged for a single-segment path', () => { + expect(describeIssuePath('Widgets', 'widgets', dashboard)).toBe('Widgets'); + }); + + it('names the offending array item by id for a nested path', () => { + expect(describeIssuePath('Widgets', 'widgets.1.layout', dashboard)).toBe( + 'Widgets → priority_split → layout', + ); + }); + + it('falls back to a 1-based index when the item has no identity', () => { + expect(describeIssuePath('Widgets', 'widgets.0.values', { widgets: [{}] })).toBe( + 'Widgets → #1 → values', + ); + }); + + it('resolves an I18nLabel identity object to its string', () => { + const d = { sections: [{ title: { key: 's.overview', defaultValue: 'Overview' } }] }; + expect(describeIssuePath('Sections', 'sections.0.fields', d)).toBe( + 'Sections → Overview → fields', + ); + }); + + it('handles a missing array gracefully (index past the end)', () => { + expect(describeIssuePath('Widgets', 'widgets.3.layout', {})).toBe('Widgets → #4 → layout'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/issuePath.ts b/packages/app-shell/src/views/metadata-admin/issuePath.ts new file mode 100644 index 000000000..7737a1ede --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/issuePath.ts @@ -0,0 +1,64 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Render a (possibly nested) validation issue path into a human-readable trail + * that names the offending element. A Zod issue on a dashboard widget arrives as + * a dot-joined path like `widgets.2.layout`; shown as just its head field + * ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This + * turns it into "Widgets → priority_split → layout" by resolving each array + * index to the item's stable identity (id/name/title) from the draft value. + * + * @param headLabel resolved human label for the first segment (caller knows the + * form/schema labels). + * @param path dot-joined issue path (e.g. `widgets.2.layout`). + * @param rootValue the draft object the path indexes into (used to resolve an + * array index to the item's identity). + */ +export function describeIssuePath(headLabel: string, path: string, rootValue: unknown): string { + const segments = path.split('.'); + if (segments.length <= 1) return headLabel; + + const parts: string[] = [headLabel]; + let cursor: unknown = asRecord(rootValue)?.[segments[0]]; + for (let i = 1; i < segments.length; i++) { + const seg = segments[i]; + if (/^\d+$/.test(seg)) { + const idx = Number(seg); + const item = Array.isArray(cursor) ? cursor[idx] : undefined; + // 1-based index reads naturally for non-developers ("#1" not "#0"). + parts.push(itemIdentity(item) ?? `#${idx + 1}`); + cursor = item; + } else { + parts.push(seg); + cursor = asRecord(cursor)?.[seg]; + } + } + return parts.join(' → '); +} + +/** Best-effort stable identity of an array item, resolving an I18nLabel object + * ({ key, defaultValue }) to its string. Returns undefined when none usable. */ +function itemIdentity(item: unknown): string | undefined { + const o = asRecord(item); + if (!o) return undefined; + for (const k of ['id', 'name', 'key', 'title', 'label']) { + const v = o[k]; + if (typeof v === 'string' && v.trim()) return v; + const nested = asRecord(v); + if (nested) { + const s = nested.defaultValue ?? nested.key; + if (typeof s === 'string' && s.trim()) return s; + } + } + return undefined; +} + +function asRecord(v: unknown): Record | undefined { + return v && typeof v === 'object' ? (v as Record) : undefined; +} From fda4e9d8ecb2a045198cf0503f8f4932b46adc54 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 12:12:13 +0800 Subject: [PATCH 2/2] chore: changeset for validation issue-path labels Co-Authored-By: Claude Opus 4.8 --- .changeset/validation-issue-path-labels.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/validation-issue-path-labels.md diff --git a/.changeset/validation-issue-path-labels.md b/.changeset/validation-issue-path-labels.md new file mode 100644 index 000000000..22e21a5bd --- /dev/null +++ b/.changeset/validation-issue-path-labels.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': patch +--- + +Validation messages name the offending widget + field + +A nested Zod issue (e.g. `widgets.2.layout`) was shown as just its head field label — "Widgets: Invalid input" — so an author couldn't tell which widget or sub-field was at fault. `labelForIssuePath` now appends a readable trail, resolving each array index to the item's stable identity (id/name/title, incl. I18nLabel objects) from the draft: "Widgets → priority_split → layout". Single-segment paths are unchanged.