Skip to content

Commit 15f140d

Browse files
os-zhuangclaude
andauthored
fix(metadata-admin): validation messages name the offending widget + field (#1940)
* fix(metadata-admin): 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 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 <noreply@anthropic.com> * chore: changeset for validation issue-path labels Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6a0f1ef commit 15f140d

4 files changed

Lines changed: 132 additions & 12 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Validation messages name the offending widget + field
6+
7+
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.

packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { getMetadataDefaultInspector } from './default-inspector-registry';
105105
import { detectLocale, t, tFormat, translateValidationMessage } from './i18n';
106106
import { JsonSourceEditor } from './JsonSourceEditor';
107107
import { validateMetadataDraft, hasClientValidator } from './clientValidation';
108+
import { describeIssuePath } from './issuePath';
108109

109110
// react-resizable-panels' `direction` prop type does not always narrow
110111
// cleanly in our TS config; cast at the boundary (precedent:
@@ -918,20 +919,27 @@ function MetadataResourceEditPageImpl({
918919
function labelForIssuePath(path: string): string {
919920
const key = path.split('.')[0];
920921
if (!key) return path;
921-
const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any));
922-
const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : [];
923-
for (const section of sections) {
924-
const fields = Array.isArray(section?.fields) ? section.fields : [];
925-
for (const field of fields) {
926-
if (typeof field === 'string') {
927-
if (field === key) return field;
928-
} else if (field?.field === key) {
929-
return String(field.label ?? key);
922+
// Resolve the human label for the HEAD segment from the form/schema.
923+
const headLabel = ((): string => {
924+
const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any));
925+
const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : [];
926+
for (const section of sections) {
927+
const fields = Array.isArray(section?.fields) ? section.fields : [];
928+
for (const field of fields) {
929+
if (typeof field === 'string') {
930+
if (field === key) return field;
931+
} else if (field?.field === key) {
932+
return String(field.label ?? key);
933+
}
930934
}
931935
}
932-
}
933-
const props = (schema?.properties ?? {}) as Record<string, any>;
934-
return String(props[key]?.title ?? key);
936+
const props = (schema?.properties ?? {}) as Record<string, any>;
937+
return String(props[key]?.title ?? key);
938+
})();
939+
// For a NESTED path (e.g. `widgets.2.layout`) append a readable trail naming
940+
// the offending element + sub-field, so a terse "Widgets: Invalid input"
941+
// becomes "Widgets → priority_split → layout".
942+
return describeIssuePath(headLabel, path, draft);
935943
}
936944

937945
async function doSave(force: boolean) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { describeIssuePath } from './issuePath';
11+
12+
describe('describeIssuePath', () => {
13+
const dashboard = { widgets: [{ id: 'kpi_tasks' }, { id: 'priority_split' }] };
14+
15+
it('returns the head label unchanged for a single-segment path', () => {
16+
expect(describeIssuePath('Widgets', 'widgets', dashboard)).toBe('Widgets');
17+
});
18+
19+
it('names the offending array item by id for a nested path', () => {
20+
expect(describeIssuePath('Widgets', 'widgets.1.layout', dashboard)).toBe(
21+
'Widgets → priority_split → layout',
22+
);
23+
});
24+
25+
it('falls back to a 1-based index when the item has no identity', () => {
26+
expect(describeIssuePath('Widgets', 'widgets.0.values', { widgets: [{}] })).toBe(
27+
'Widgets → #1 → values',
28+
);
29+
});
30+
31+
it('resolves an I18nLabel identity object to its string', () => {
32+
const d = { sections: [{ title: { key: 's.overview', defaultValue: 'Overview' } }] };
33+
expect(describeIssuePath('Sections', 'sections.0.fields', d)).toBe(
34+
'Sections → Overview → fields',
35+
);
36+
});
37+
38+
it('handles a missing array gracefully (index past the end)', () => {
39+
expect(describeIssuePath('Widgets', 'widgets.3.layout', {})).toBe('Widgets → #4 → layout');
40+
});
41+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Render a (possibly nested) validation issue path into a human-readable trail
11+
* that names the offending element. A Zod issue on a dashboard widget arrives as
12+
* a dot-joined path like `widgets.2.layout`; shown as just its head field
13+
* ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This
14+
* turns it into "Widgets → priority_split → layout" by resolving each array
15+
* index to the item's stable identity (id/name/title) from the draft value.
16+
*
17+
* @param headLabel resolved human label for the first segment (caller knows the
18+
* form/schema labels).
19+
* @param path dot-joined issue path (e.g. `widgets.2.layout`).
20+
* @param rootValue the draft object the path indexes into (used to resolve an
21+
* array index to the item's identity).
22+
*/
23+
export function describeIssuePath(headLabel: string, path: string, rootValue: unknown): string {
24+
const segments = path.split('.');
25+
if (segments.length <= 1) return headLabel;
26+
27+
const parts: string[] = [headLabel];
28+
let cursor: unknown = asRecord(rootValue)?.[segments[0]];
29+
for (let i = 1; i < segments.length; i++) {
30+
const seg = segments[i];
31+
if (/^\d+$/.test(seg)) {
32+
const idx = Number(seg);
33+
const item = Array.isArray(cursor) ? cursor[idx] : undefined;
34+
// 1-based index reads naturally for non-developers ("#1" not "#0").
35+
parts.push(itemIdentity(item) ?? `#${idx + 1}`);
36+
cursor = item;
37+
} else {
38+
parts.push(seg);
39+
cursor = asRecord(cursor)?.[seg];
40+
}
41+
}
42+
return parts.join(' → ');
43+
}
44+
45+
/** Best-effort stable identity of an array item, resolving an I18nLabel object
46+
* ({ key, defaultValue }) to its string. Returns undefined when none usable. */
47+
function itemIdentity(item: unknown): string | undefined {
48+
const o = asRecord(item);
49+
if (!o) return undefined;
50+
for (const k of ['id', 'name', 'key', 'title', 'label']) {
51+
const v = o[k];
52+
if (typeof v === 'string' && v.trim()) return v;
53+
const nested = asRecord(v);
54+
if (nested) {
55+
const s = nested.defaultValue ?? nested.key;
56+
if (typeof s === 'string' && s.trim()) return s;
57+
}
58+
}
59+
return undefined;
60+
}
61+
62+
function asRecord(v: unknown): Record<string, unknown> | undefined {
63+
return v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined;
64+
}

0 commit comments

Comments
 (0)