From 30600e048886684c62c863e2654ab7dd3abe371f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:07:47 +0000 Subject: [PATCH 1/4] fix(app-shell): Studio refusals read as an author sentence, with the raw text under Details (objectui#11785) Studio's save strips printed formatMetadataError(e) verbatim: the object write guard's developer prose, and raw server issue paths. Each strip now shows a StudioRefusal: a sentence naming the input (a field's label, a step and its inspector input, a navigation item), a "Show me" button that opens it, and the raw text inside a closed "Details" disclosure. The write guard's message names no class, package or tracker id, and is the same at every door, so Studio recognises its refusal by re-running the guard on the body it sent instead of parsing prose. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/views/metadata-admin/i18n.ts | 31 ++ ...mationsPillar.authorRefusal-11785.test.tsx | 182 +++++++++++ .../DataPillar.authorRefusal-11785.test.tsx | 156 +++++++++ .../studio-design/StudioDesignSurface.tsx | 221 +++++++++---- .../metadataError.authorRefusal-11785.test.ts | 229 ++++++++++++++ .../src/views/studio-design/metadataError.ts | 299 +++++++++++++++++- .../metadata-client.objectWriteGuard.test.ts | 21 +- .../src/object-metadata-write-guard.test.ts | 31 +- .../src/object-metadata-write-guard.ts | 33 +- 9 files changed, 1126 insertions(+), 77 deletions(-) create mode 100644 packages/app-shell/src/views/studio-design/AutomationsPillar.authorRefusal-11785.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/metadataError.authorRefusal-11785.test.ts diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 2f453c3e97..15a964a698 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2730,6 +2730,23 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.publish': 'Publish', 'engine.studio.loading': 'Loading…', 'engine.studio.loadFailed': 'Failed to load', + // objectui#11785 — a refused save, in the author's words. The strip shows one + // of these sentences; the raw refusal (field paths, codes) stays under + // `engine.studio.refusal.details`, and `show` opens the input it names. + 'engine.studio.refusal.choiceWithoutOptions': + 'Changes not saved: the field “{field}” needs at least one option. Add an option, or change the field to a type that is not a choice.', + 'engine.studio.refusal.relationshipWithoutTarget': + 'Changes not saved: the field “{field}” does not say which object it links to. Pick the object, or change the field to a type that is not a relationship.', + 'engine.studio.refusal.issue': 'Changes not saved. Check {where} — {problem}', + 'engine.studio.refusal.more': '({count} more under Details)', + 'engine.studio.refusal.unlocated': 'Changes not saved: the server refused this draft. Details say what to change.', + 'engine.studio.refusal.field': 'the field “{field}”', + 'engine.studio.refusal.step': 'the step “{step}”', + 'engine.studio.refusal.stepInput': '{input} on the step “{step}”', + 'engine.studio.refusal.navItem': 'the navigation item “{item}”', + 'engine.studio.refusal.navItemInput': '{input} on the navigation item “{item}”', + 'engine.studio.refusal.details': 'Details', + 'engine.studio.refusal.show': 'Show me', 'engine.studio.unpublishedDraft': 'Unpublished draft', 'engine.studio.unpublished': 'Unpublished', 'engine.studio.new': 'New', @@ -5662,6 +5679,20 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.publish': '发布', 'engine.studio.loading': '加载中…', 'engine.studio.loadFailed': '加载失败', + 'engine.studio.refusal.choiceWithoutOptions': + '更改未保存:字段「{field}」至少需要一个选项。请添加选项,或将该字段改为非选项类型。', + 'engine.studio.refusal.relationshipWithoutTarget': + '更改未保存:字段「{field}」未指定要关联的对象。请选择对象,或将该字段改为非关联类型。', + 'engine.studio.refusal.issue': '更改未保存。请检查{where} —— {problem}', + 'engine.studio.refusal.more': '(另有 {count} 处,见详情)', + 'engine.studio.refusal.unlocated': '更改未保存:服务端拒绝了此草稿。详情中说明了需要修改的内容。', + 'engine.studio.refusal.field': '字段「{field}」', + 'engine.studio.refusal.step': '步骤「{step}」', + 'engine.studio.refusal.stepInput': '步骤「{step}」的「{input}」', + 'engine.studio.refusal.navItem': '导航项「{item}」', + 'engine.studio.refusal.navItemInput': '导航项「{item}」的「{input}」', + 'engine.studio.refusal.details': '详情', + 'engine.studio.refusal.show': '定位', 'engine.studio.unpublishedDraft': '未发布草稿', 'engine.studio.unpublished': '未发布', 'engine.studio.new': '新建', diff --git a/packages/app-shell/src/views/studio-design/AutomationsPillar.authorRefusal-11785.test.tsx b/packages/app-shell/src/views/studio-design/AutomationsPillar.authorRefusal-11785.test.tsx new file mode 100644 index 0000000000..e72feaa490 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/AutomationsPillar.authorRefusal-11785.test.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11785 — on the Studio Automations page, a server refusal names the + * step and the input it is about, instead of printing a raw issue path. + * + * The card's measured case: a 422 on `nodes.2.config.title` printed + * "nodes.2.config.title — …" as the banner. The strip now says which step + * (node 2, by its label) and which input (its Title, by the inspector's own + * label), offers "Show me" to open that step, and keeps the raw line under a + * closed "Details" disclosure. A path it cannot place stays under Details. + * + * The canvas and inspector are the real registered `FlowPreview` and + * `FlowInspector`; the client is a server double whose `save` refuses, driven + * through the header switch, which saves at once (as in + * `StudioDesignSurface.automationsReadOnly-11124.test.tsx`). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const FLOW = { + name: 'approval', + label: 'Approval', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'route', type: 'decision', label: 'Route' }, + { id: 'tell', type: 'notify', label: 'Notify approver', config: { recipients: ['owner'] } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'route' }, + { id: 'e2', source: 'route', target: 'tell' }, + { id: 'e3', source: 'tell', target: 'end' }, + ], +}; + +const MISSING = 'Invalid input: expected string, received undefined'; + +const server = vi.hoisted(() => ({ + /** The issues the next draft save is refused with. */ + issues: [] as Array<{ path: string; message: string }>, +})); + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async () => [{ name: 'approval', label: 'Approval' }]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ + code: null, + overlay: null, + overlayScope: null, + effective: JSON.parse(JSON.stringify(FLOW)), + editable: true, + deletable: true, + resettable: false, + lock: 'none', + })), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async () => { + // The client's parsed `MetadataError`: a headline, a status, a code and + // the structured issues the server returned. + throw Object.assign(new Error(`flow/approval failed spec validation (${server.issues.length})`), { + status: 422, + code: 'INVALID_METADATA', + issues: server.issues, + }); + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t, tFormat } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); + +// The pillar's `/automation/_status` probe and the inspector's catalog reads go +// through the global `fetch`; "absent" keeps each on its documented fallback. +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('flow', FlowInspector); + +beforeEach(() => { + server.issues = []; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); +}); + +afterEach(cleanup); + +/** Open the flow, then flip the header switch: a save that the server refuses. */ +async function refusedSave(issues: Array<{ path: string; message: string }>): Promise { + server.issues = issues; + render( + + + , + ); + const toggle = await screen.findByRole('switch', undefined, { timeout: 8000 }); + await waitFor(() => expect(toggle).not.toBeDisabled()); + fireEvent.click(toggle); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1)); + return screen.findByTestId('studio-refusal'); +} + +describe('Automations page — a server refusal names the step and its input (objectui#11785)', () => { + it('`nodes.2.config.title` names node 2\'s Title, with the raw path under Details', async () => { + const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]); + + const message = within(strip).getByTestId('studio-refusal-message'); + expect(message).toHaveTextContent( + tFormat('engine.studio.refusal.issue', 'en', { + where: tFormat('engine.studio.refusal.stepInput', 'en', { input: 'Title', step: 'Notify approver' }), + problem: t('engine.validation.expectedStringUndefined', 'en'), + }), + ); + expect(message.textContent).not.toMatch(/nodes\.\d|config\.title/); + + const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement; + expect(details.open).toBe(false); + expect(details.querySelector('pre')).toHaveTextContent(`• nodes.2.config.title — ${MISSING}`); + }); + + it('"Show me" opens that step in the inspector', async () => { + const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]); + fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') })); + + const rail = screen.getByRole('complementary'); + const label = (await within(rail).findByLabelText('Label', undefined, { timeout: 8000 })) as HTMLInputElement; + expect(label.value).toBe('Notify approver'); + expect(within(rail).getByLabelText('ID')).toHaveValue('tell'); + }); + + it('a path it cannot place stays reachable under Details', async () => { + const strip = await refusedSave([{ path: 'variables.0.name', message: 'Invalid input' }]); + + expect(within(strip).getByTestId('studio-refusal-message')).toHaveTextContent( + t('engine.studio.refusal.unlocated', 'en'), + ); + expect(within(strip).queryByRole('button', { name: t('engine.studio.refusal.show', 'en') })).toBeNull(); + expect(within(strip).getByTestId('studio-refusal-detail').querySelector('pre')).toHaveTextContent( + '• variables.0.name — Invalid input', + ); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx new file mode 100644 index 0000000000..651fc7b1d7 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11785 — on the Studio data page, a save the write guard holds shows + * the author a sentence, not the guard's developer text. + * + * The card's measured case: add a field, switch its type to Picklist, and the + * draft autosave is held because a choice field has no options. The strip used + * to print the guard's message verbatim (a class name and tracker ids). It now + * shows a sentence naming the field by its label, a "Show me" button that + * opens that field, and the raw text inside a closed "Details" disclosure. + * + * The client is a real `MetadataClient`, so the door that runs the guard is the + * production one; only the transport under it is a double, as in + * `DataPillar.choiceWithoutOptions-11253.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, act, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const objectDef = { + name: 'showcase_task', + label: 'Task', + fields: [{ name: 'title', label: 'Title', type: 'text' }], +}; + +const puts: unknown[] = []; + +const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + puts.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }); +}); + +const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch }); +Object.assign(client, { + list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })), + getDraft: vi.fn(async () => null), +}); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +import { DataPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; +import { t, tFormat } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); +registerBuiltinInspectors(); + +afterEach(() => { + cleanup(); + puts.length = 0; + fetchImpl.mockClear(); +}); + +/** The inspector control under a visible label. */ +function controlUnder(label: string): HTMLElement { + const lab = screen.getByText(label, { selector: 'label' }); + return lab.parentElement!.querySelector('input, [role="combobox"]') as HTMLElement; +} + +describe('Studio data page — a held Picklist reads as a sentence (objectui#11785)', () => { + it('names the field by its label, shows no developer text, and keeps the raw text under Details', async () => { + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Form' })); + fireEvent.click(await screen.findByTitle(/^Add a field/)); + await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); + const apiName = (controlUnder('API name') as HTMLInputElement).value; + const label = (controlUnder('Label') as HTMLInputElement).value; + expect(apiName, 'the new field has no API name: the harness is dead').not.toBe(''); + expect(label, 'the new field has no label: the harness is dead').not.toBe(''); + await userEvent.click(controlUnder('Type')); + await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ })); + + // Past the autosave's 1.5 s debounce: the guard holds the save. + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); + expect(puts, 'the guard must hold this save').toEqual([]); + + const strip = await screen.findByTestId('studio-refusal'); + const message = within(strip).getByTestId('studio-refusal-message'); + expect(message).toHaveTextContent( + tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: label }), + ); + expect(message.textContent).not.toMatch(/MetadataClient|objectstack#|objectui#|`/); + // The API name is the guard's word for the field; the author reads its label. + expect(label).not.toBe(apiName); + expect(message.textContent).not.toContain(apiName); + + // The raw text is still there, closed until the author asks for it. + const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement; + expect(details.open).toBe(false); + expect(within(details).getByText(t('engine.studio.refusal.details', 'en'))).toBeVisible(); + const raw = details.querySelector('pre')!; + expect(raw).not.toBeVisible(); + expect(raw).toHaveTextContent(new RegExp(`\`${apiName}\` is a \`select\` with no options`)); + fireEvent.click(within(details).getByText(t('engine.studio.refusal.details', 'en'))); + expect(details.open).toBe(true); + }); + + it('"Show me" opens the named field\'s inspector after it was closed', async () => { + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Form' })); + fireEvent.click(await screen.findByTitle(/^Add a field/)); + await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); + const apiName = (controlUnder('API name') as HTMLInputElement).value; + await userEvent.click(controlUnder('Type')); + await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ })); + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); + const strip = await screen.findByTestId('studio-refusal'); + + fireEvent.click(screen.getByRole('button', { name: t('engine.studio.close', 'en') })); + expect(screen.queryByText('API name', { selector: 'label' })).toBeNull(); + + fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') })); + expect(await screen.findByText('API name', { selector: 'label' })).toBeInTheDocument(); + expect((controlUnder('API name') as HTMLInputElement).value).toBe(apiName); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index a795370b7c..0c8c6c6f8f 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -109,7 +109,16 @@ import { useNavSelDeepLink } from '../metadata-admin/useNavSelDeepLink.js'; import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js'; import { fetchPendingDrafts, usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; -import { formatPublishFailures, type PublishFailure } from './metadataError.js'; +import { + flowSaveRefusal, + formatPublishFailures, + issueRefusal, + navEntryLocator, + objectSaveRefusal, + plainRefusal, + type PublishFailure, + type StudioRefusal, +} from './metadataError.js'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; import { loadPackageLessSurfaces, loadPackageSurfaces } from './packageSurfaces.js'; import { @@ -405,6 +414,58 @@ const KIND_ICON: Record = { const navIcon = (type?: string): LucideIcon => KIND_ICON[type ?? ''] ?? Compass; +/** + * objectui#11785 — the strip a pillar shows for a failed save or load. It + * shows the refusal's sentence, a "Show me" button that opens the input the + * sentence names, and the raw text (field paths, codes) inside a closed + * "Details" disclosure. A failure with nothing rewritten (`plainRefusal`) has + * no detail and renders as the single line it always was. + * + * Exported for its pins; `index.ts` does not re-export it. + */ +export function StudioRefusalStrip({ + refusal, + locale, + onShow, + className, +}: { + refusal: StudioRefusal; + locale: string; + /** Opens the target's input in the pillar that raised the refusal. */ + onShow?: (target: MetadataSelection) => void; + /** Spacing and type size, which differ by pillar. */ + className?: string; +}): React.ReactElement { + const { message, target, detail } = refusal; + return ( +
+
+

+ {message} +

+ {target && onShow && ( + + )} +
+ {detail && ( +
+ {t('engine.studio.refusal.details', locale)} +
{detail}
+
+ )} +
+ ); +} + /** Top-bar package switcher: list app packages (writable base vs read-only * code), switch by navigation, create a new writable base via the standard * CreatePackageDialog, and open the standard PackageDetailSheet (info + @@ -2018,8 +2079,9 @@ export function InterfacesPillar({ // in the canvas banner beside the pillar's `error` until a nav save lands, // or the buffer it was about is put back. Held apart from `error` so a nav // save that lands clears its own failure and never one a leaf load or save - // is still showing. - const [navError, setNavError] = React.useState(null); + // is still showing. objectui#11785 — held as a refusal: the author sentence, + // the nav entry it names, and the raw text behind Details. + const [navError, setNavError] = React.useState(null); // App resolution status — tells "still loading" apart from "this package has // no app", so the canvas shows a real empty state instead of an endless @@ -2261,7 +2323,9 @@ export function InterfacesPillar({ const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [hasDraft, setHasDraft] = React.useState(false); - const [error, setError] = React.useState(null); + // objectui#11785 — a load failure as it was (`plainRefusal`), a refused leaf + // save as an author sentence with the raw text behind Details. + const [error, setError] = React.useState(null); // Objects in THIS package (published ∪ draft) — the nav item inspector's // object picker, so nav can be wired to sibling objects before publishing. const [pkgObjects, setPkgObjects] = React.useState>([]); @@ -2397,7 +2461,7 @@ export function InterfacesPillar({ setCurrent((cur) => cur ?? deepLinked ?? firstLeaf); } catch (e) { if (!cancelled) { - setError(formatMetadataError(e)); + setError(plainRefusal(e)); setAppStatus('missing'); } } @@ -2532,7 +2596,7 @@ export function InterfacesPillar({ setHasDraft(!!body); setIfDirty(false); } catch (e) { - if (!cancelled) setError(formatMetadataError(e)); + if (!cancelled) setError(plainRefusal(e)); } finally { settled = true; if (!cancelled) setLoading(false); @@ -2574,11 +2638,14 @@ export function InterfacesPillar({ if (sent.unmoved()) setIfDirty(false); onDraftSaved?.(); } catch (e) { - setError(formatMetadataError(e)); + // objectui#11785 — no locator: a leaf's issue paths (page blocks, + // dashboard widgets) name no input this pillar can open, so the sentence + // says the draft was refused and Details keep every path. + setError(issueRefusal(e, locale)); } finally { setSaving(false); } - }, [saveLeafDraft, current, draft, onDraftSaved, packageId]); + }, [saveLeafDraft, current, draft, onDraftSaved, packageId, locale]); const { loaded: draftLoaded } = useDraftAutoSave({ // objectui#11232 — the leaf `doSave` addresses, `type:name`. target: leafKey, @@ -2597,22 +2664,24 @@ export function InterfacesPillar({ const doNavSave = React.useCallback(async (sent: DraftSend) => { if (!appName) return; setNavSaving('draft'); + // objectui#11776 — "Add nav item" births `{ id, type: 'object' }`, which + // the spec refuses until a target is picked in the inspector, and an + // unbind leaves an entry with no `type`. The save sends the editor's + // navigation less every entry that names no target for its `type`, at + // every depth (`navPayloadOf`); the editor keeps showing it, in its + // place. A root entry with no `id` is given one by its place in the + // EDITOR, before anything is left out, so leaving an entry out never + // moves another entry's id. objectui#11785 — computed before the save so + // a refusal is placed on the entry it names: its path indexes `sentNav`, + // and `editorNav` keeps the editor's indexes. + const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : []; + const editorNav = rawNav.map((n, i) => { + const item = n as Record; + if (!item || typeof item !== 'object' || (typeof item.id === 'string' && item.id)) return n; + return { ...item, id: `nav_item_${i + 1}` }; + }); + const sentNav = navPayloadOf(editorNav); try { - // objectui#11776 — "Add nav item" births `{ id, type: 'object' }`, which - // the spec refuses until a target is picked in the inspector, and an - // unbind leaves an entry with no `type`. The save sends the editor's - // navigation less every entry that names no target for its `type`, at - // every depth (`navPayloadOf`); the editor keeps showing it, in its - // place. A root entry with no `id` is given one by its place in the - // EDITOR, before anything is left out, so leaving an entry out never - // moves another entry's id. - const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : []; - const editorNav = rawNav.map((n, i) => { - const item = n as Record; - if (!item || typeof item !== 'object' || (typeof item.id === 'string' && item.id)) return n; - return { ...item, id: `nav_item_${i + 1}` }; - }); - const sentNav = navPayloadOf(editorNav); const leftOut = sentNav.length !== editorNav.length || sentNav.some((n, i) => n !== editorNav[i]); const saved = { ...appDraft, navigation: sentNav }; const outcome = await saveNavDraft('app', appName, saved, { mode: 'draft', packageId }); @@ -2637,11 +2706,17 @@ export function InterfacesPillar({ if (sent.unmoved() && !leftOut) setNavDirty(false); onDraftSaved?.(); } catch (e) { - setNavError(formatMetadataError(e)); + setNavError( + issueRefusal( + e, + locale, + navEntryLocator({ sent: sentNav, editor: editorNav, locale, targetLabel: targetLabelRef.current }), + ), + ); } finally { setNavSaving(false); } - }, [saveNavDraft, appName, appDraft, onDraftSaved, packageId, publishNonce]); + }, [saveNavDraft, appName, appDraft, onDraftSaved, packageId, publishNonce, locale]); // objectui#5813 — nav edits auto-save while edit mode is open. const { flush: flushNavSave } = useDraftAutoSave({ // objectui#11232 — the app `doNavSave` addresses. The package is this @@ -2749,10 +2824,23 @@ export function InterfacesPillar({ )} {(error || navError) && ( -
+
{/* objectui#11776 — the pillar's failure and the nav editor's own, - each cleared by what settles it. */} - {error && navError && error !== navError ? `${error}\n${navError}` : (error ?? navError)} + each cleared by what settles it, the same failure shown once. + objectui#11785 — each as a refusal strip; the nav editor's "Show + me" opens nav editing on the entry it names. */} + {error && } + {navError && !(error && error.message === navError.message && error.detail === navError.detail) && ( + { + setEditNav(true); + setNavSel({ kind: target.kind, id: target.id }); + }} + className="px-3 py-2 text-xs" + /> + )}
)}
(null); + // objectui#11785 — a load failure as it was (`plainRefusal`), a refused save + // as an author sentence naming the field, with the raw text behind Details. + const [error, setError] = React.useState(null); // field management — a selected field opens ObjectFieldInspector (full type + config) const [fieldSel, setFieldSel] = React.useState(null); // Blocking author-time issues the field inspector is showing — a CEL formula @@ -3628,7 +3718,7 @@ export function DataPillar({ // empty package (dogfood #2555). The empty-state panel carries the // create CTA instead. } catch (e) { - if (!cancelled) setError(formatMetadataError(e)); + if (!cancelled) setError(plainRefusal(e)); } finally { if (!cancelled) setObjectsLoaded(true); } @@ -3682,7 +3772,7 @@ export function DataPillar({ // column the data API can answer yet (see `gridColumns`). setPublishedFieldNames(new Set(readFields(baseline.fields).entries.map((e) => e.name))); } catch (e) { - if (!cancelled) setError(formatMetadataError(e)); + if (!cancelled) setError(plainRefusal(e)); } finally { settled = true; if (!cancelled) setLoading(false); @@ -3816,7 +3906,7 @@ export function DataPillar({ // object can't be authored; the rule lives in packages-io/spec. const name = prefixObjectName(rawName, namespace); if (objects.some((o) => o.name === name)) { - setError(tFormat('engine.studio.data.idExists', locale, { name })); + setError({ message: tFormat('engine.studio.data.idExists', locale, { name }) }); return; } setCreateBusy(true); @@ -3832,7 +3922,8 @@ export function DataPillar({ setCreating(false); onDraftSaved?.(); } catch (e) { - setError(formatMetadataError(e)); + // Shown in the create dialog, which prints the message only: kept whole. + setError(plainRefusal(e)); } finally { setCreateBusy(false); } @@ -3844,11 +3935,13 @@ export function DataPillar({ if (!current) return; setSaving('draft'); setError(null); + // objectui#10202 — the buffer was seeded from the served object, whose + // picklist-bound fields carry the list's resolved `options`; the door + // refuses them beside `picklist`, so they stay out of the body. + // objectui#11785 — kept, so a refusal is read against what was sent. + const body = dropServedPicklistOptions(objDraft); try { - // objectui#10202 — the buffer was seeded from the served object, whose - // picklist-bound fields carry the list's resolved `options`; the door - // refuses them beside `picklist`, so they stay out of the body. - const outcome = await saveObjDraft('object', current.name, dropServedPicklistOptions(objDraft), { mode: 'draft', packageId }); + const outcome = await saveObjDraft('object', current.name, body, { mode: 'draft', packageId }); // objectui#11773 — the author chose the saved version; the load replaces the buffer. if (outcome === 'reloaded') return; setHasDraft(true); @@ -3859,7 +3952,7 @@ export function DataPillar({ // every editing pause — the quiet last-saved hint is the affordance. onDraftSaved?.(); } catch (e) { - setError(formatMetadataError(e)); + setError(objectSaveRefusal(e, body, locale)); } finally { setSaving(false); } @@ -3916,21 +4009,22 @@ export function DataPillar({ setObjDraft(body); setSaving('draft'); setError(null); + // objectui#10202 — same served `options` as `doSave` above. + const wire = dropServedPicklistOptions(body); try { - // objectui#10202 — same served `options` as `doSave` above. - const outcome = await saveObjDraft('object', current.name, dropServedPicklistOptions(body), { mode: 'draft', packageId }); + const outcome = await saveObjDraft('object', current.name, wire, { mode: 'draft', packageId }); if (outcome === 'reloaded') return; setHasDraft(true); if (sent.unmoved()) setDirty(false); onDraftSaved?.(); setGridVer((v) => v + 1); // remount so the grid reflects the new (draft) order } catch (e) { - setError(formatMetadataError(e)); + setError(objectSaveRefusal(e, wire, locale)); } finally { setSaving(false); } }, - [saveObjDraft, current, objDraft, onDraftSaved, sendingObjDraft, packageId], + [saveObjDraft, current, objDraft, onDraftSaved, sendingObjDraft, packageId, locale], ); const inspector = getMetadataInspector('object'); @@ -4185,9 +4279,13 @@ export function DataPillar({ )}
{error && ( -
- {error} -
+ setFieldSel({ kind: target.kind, id: target.id })} + className="mb-2 px-3 py-1.5 text-[11px]" + /> )} {!objLoaded ? ( // objectui#11272 — no view of another object's buffer under @@ -4536,7 +4634,7 @@ export function DataPillar({ submitLabel={t('engine.studio.createDraft', locale)} submittingLabel={t('engine.studio.creating', locale)} busy={createBusy} - error={error} + error={error?.message ?? null} locale={locale} extra={ /* Record sharing (OWD) — the third thing `New object` must ask for @@ -4732,7 +4830,9 @@ export function AutomationsPillar({ const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [hasDraft, setHasDraft] = React.useState(false); - const [error, setError] = React.useState(null); + // objectui#11785 — a load failure as it was (`plainRefusal`), a refused save + // as an author sentence naming the step and its input, raw text behind Details. + const [error, setError] = React.useState(null); // Tells "still fetching the list" apart from "fetched, package has no flows" // — without it the empty rail showed an endless "Loading…" for a fresh package. const [listed, setListed] = React.useState(false); @@ -4830,7 +4930,7 @@ export function AutomationsPillar({ } setCurrent((c) => c ?? deepLinked ?? (named ? null : items[0]) ?? null); } catch (e) { - if (!cancelled) setError(formatMetadataError(e)); + if (!cancelled) setError(plainRefusal(e)); } finally { if (!cancelled) setListed(true); } @@ -4873,7 +4973,8 @@ export function AutomationsPillar({ onDraftSaved?.(); toast.success(tFormat('engine.studio.auto.savedDraft', locale, { label })); } catch (e) { - setError(formatMetadataError(e)); + // Shown in the create dialog, which prints the message only: kept whole. + setError(plainRefusal(e)); } finally { setCreateBusy(false); } @@ -4906,7 +5007,7 @@ export function AutomationsPillar({ forgetFlowVersion(); setHasDraft(!!draftBody); } catch (e) { - if (!cancelled) setError(formatMetadataError(e)); + if (!cancelled) setError(plainRefusal(e)); } finally { settled = true; if (!cancelled) { @@ -4945,11 +5046,12 @@ export function AutomationsPillar({ if (sent.unmoved()) setAutoDirty(false); onDraftSaved?.(); } catch (e) { - setError(formatMetadataError(e)); + // objectui#11785 — read against `draft`, the body this save sent. + setError(flowSaveRefusal(e, draft, locale)); } finally { setSaving(false); } - }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved]); + }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale]); const { sending: sendingFlowDraft, loaded: flowLoaded } = useDraftAutoSave({ // objectui#11232 — the flow `doSave` addresses. target: `flow:${current?.name ?? ''}`, @@ -5004,7 +5106,7 @@ export function AutomationsPillar({ const { status: _refused, ...rest } = d; return 'status' in prevDraft ? { ...rest, status: prevDraft.status } : rest; }); - setError(formatMetadataError(e)); + setError(flowSaveRefusal(e, nextDraft, locale)); } finally { setSaving(false); } @@ -5139,9 +5241,14 @@ export function AutomationsPillar({ {current && flow · {current.name}}
{error && ( -
- {error} -
+ setSelection({ kind: target.kind, id: target.id })} + className="mb-3 shrink-0 px-3 py-2 text-xs" + /> )} {/* `flex-1 min-h-0` so the canvas fills the pillar's full remaining * height instead of shrinking to FlowCanvas's intrinsic content @@ -5250,7 +5357,7 @@ export function AutomationsPillar({ submitLabel={t('engine.studio.createDraft', locale)} submittingLabel={t('engine.studio.creating', locale)} busy={createBusy} - error={error} + error={error?.message ?? null} locale={locale} onSubmit={({ label, name }) => void doCreateFlow(label, name)} /> diff --git a/packages/app-shell/src/views/studio-design/metadataError.authorRefusal-11785.test.ts b/packages/app-shell/src/views/studio-design/metadataError.authorRefusal-11785.test.ts new file mode 100644 index 0000000000..1ec9b0e61c --- /dev/null +++ b/packages/app-shell/src/views/studio-design/metadataError.authorRefusal-11785.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11785 — a Studio refusal reads as a sentence for the author, names + * the input it is about, and keeps the raw text behind Details. + * + * Measured on the card before the fix: Studio's strips printed + * `formatMetadataError(e)` verbatim, so a Picklist with no options showed the + * write guard's developer prose (a class name and tracker ids), and a server + * refusal showed its raw issue paths (`nodes.2.config.title — …`). + * + * These pins read the view model the strips render. The strip itself, wired + * into the pillars, is pinned in `DataPillar.authorRefusal-11785.test.tsx` and + * `AutomationsPillar.authorRefusal-11785.test.tsx`. + */ + +import { describe, expect, it } from 'vitest'; +import { MetadataClient, formatMetadataError } from '@object-ui/data-objectstack'; +import { + flowSaveRefusal, + issueRefusal, + navEntryLocator, + objectSaveRefusal, + plainRefusal, +} from './metadataError'; +import { t, tFormat } from '../metadata-admin/i18n'; + +/** Text an author cannot act on: a class or package name, a tracker id, a raw path, code quotes. */ +const DEVELOPER_TEXT = /MetadataClient|@objectstack|objectstack#|objectui#|`|fields\.|nodes\.\d|navigation\.\d/; + +/** The write guard's refusal of `body`, through the real door, with no request issued. */ +async function guardRefusal(body: Record): Promise { + const client = new MetadataClient({ + baseUrl: 'http://test.local', + fetch: (async () => { + throw new Error('the guard must refuse before any request'); + }) as unknown as typeof fetch, + }); + return client.save('object', 'showcase_task', body).then( + () => { + throw new Error('expected the guard to refuse this body'); + }, + (e: unknown) => e, + ); +} + +/** A server refusal as the client parses it: status, code and the structured issues. */ +function serverRefusal(issues: Array<{ path: string; message: string }>): Error { + return Object.assign(new Error(`${issues.length} issue(s): ${issues.map((i) => i.path).join(', ')}`), { + status: 422, + code: 'INVALID_METADATA', + issues, + }); +} + +const TASK = { + name: 'showcase_task', + label: 'Task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'select', label: 'Status' }, + }, +}; + +describe('the write guard, in the author\'s words (objectui#11785)', () => { + it('a choice with no options: a sentence naming the field by its label, the guard\'s text under Details', async () => { + const e = await guardRefusal(TASK); + const refusal = objectSaveRefusal(e, TASK, 'en'); + + expect(refusal.message).toBe(tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: 'Status' })); + expect(refusal.message).not.toMatch(DEVELOPER_TEXT); + expect(refusal.target).toEqual({ kind: 'field', id: 'status' }); + // Nothing is lost: the raw refusal is exactly what the strip printed before. + expect(refusal.detail).toBe(formatMetadataError(e)); + expect(refusal.detail).toMatch(/`status` is a `select` with no options/); + }); + + it('a relationship with no target, in the array `fields` shape the Studio data page PUTs', async () => { + const body = { + name: 'showcase_task', + fields: [ + { name: 'title', type: 'text', label: 'Title' }, + { name: 'technician', type: 'lookup', label: 'Technician' }, + ], + }; + const refusal = objectSaveRefusal(await guardRefusal(body), body, 'en'); + + expect(refusal.message).toBe( + tFormat('engine.studio.refusal.relationshipWithoutTarget', 'en', { field: 'Technician' }), + ); + expect(refusal.target).toEqual({ kind: 'field', id: 'technician' }); + }); + + it('names the field the guard refused, not merely the first choice field', async () => { + const body = { + fields: { + stage: { type: 'select', label: 'Stage', options: [{ value: 'a', label: 'A' }] }, + owner: { type: 'lookup', label: 'Owner' }, + }, + }; + const refusal = objectSaveRefusal(await guardRefusal(body), body, 'en'); + expect(refusal.target).toEqual({ kind: 'field', id: 'owner' }); + }); + + it('a field with no label is named by its API name', async () => { + const body = { fields: { status: { type: 'radio' } } }; + const refusal = objectSaveRefusal(await guardRefusal(body), body, 'en'); + expect(refusal.message).toBe(tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: 'status' })); + }); + + it('reads in the designer locale', async () => { + const refusal = objectSaveRefusal(await guardRefusal(TASK), TASK, 'zh-CN'); + expect(refusal.message).toBe(tFormat('engine.studio.refusal.choiceWithoutOptions', 'zh-CN', { field: 'Status' })); + expect(refusal.message).not.toBe(tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: 'Status' })); + }); + + it('CONTROL: the same words with a server status are not the guard, and stay as they were', async () => { + const guard = (await guardRefusal(TASK)) as Error; + const fromServer = Object.assign(new Error(guard.message), { status: 400 }); + expect(objectSaveRefusal(fromServer, TASK, 'en')).toEqual({ message: guard.message }); + }); + + it('CONTROL: the guard\'s words about a body that was NOT sent are not taken for its refusal', async () => { + const guard = (await guardRefusal(TASK)) as Error; + const fixed = { ...TASK, fields: { ...TASK.fields, status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] } } }; + expect(objectSaveRefusal(guard, fixed, 'en')).toEqual({ message: guard.message }); + }); + + it('CONTROL: a transport failure is shown as it was, with nothing behind Details', () => { + const e = new TypeError('Failed to fetch'); + expect(objectSaveRefusal(e, TASK, 'en')).toEqual({ message: 'Failed to fetch' }); + expect(plainRefusal(e)).toEqual({ message: 'Failed to fetch' }); + }); +}); + +describe('a server refusal, placed on the input it names (objectui#11785)', () => { + const FLOW = { + name: 'approval', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'route', type: 'decision', label: 'Route' }, + { id: 'tell', type: 'notify', label: 'Notify approver' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'route' }], + }; + const MISSING = 'Invalid input: expected string, received undefined'; + + it('`nodes.2.config.title` names node 2 and its Title, and opens that node', () => { + const e = serverRefusal([{ path: 'nodes.2.config.title', message: MISSING }]); + const refusal = flowSaveRefusal(e, FLOW, 'en'); + + expect(refusal.message).toBe( + tFormat('engine.studio.refusal.issue', 'en', { + where: tFormat('engine.studio.refusal.stepInput', 'en', { input: 'Title', step: 'Notify approver' }), + problem: t('engine.validation.expectedStringUndefined', 'en'), + }), + ); + expect(refusal.message).not.toMatch(DEVELOPER_TEXT); + expect(refusal.target).toEqual({ kind: 'node', id: 'tell' }); + expect(refusal.detail).toBe(formatMetadataError(e)); + expect(refusal.detail).toContain('nodes.2.config.title'); + }); + + it('a node key with no inspector input names the step alone', () => { + const e = serverRefusal([{ path: 'nodes.1.config.nowhere', message: 'Unrecognized key' }]); + const refusal = flowSaveRefusal(e, FLOW, 'en'); + expect(refusal.message).toContain(tFormat('engine.studio.refusal.step', 'en', { step: 'Route' })); + expect(refusal.target).toEqual({ kind: 'node', id: 'route' }); + }); + + it('an unmapped path stays reachable under Details, and the sentence says the draft was refused', () => { + const e = serverRefusal([{ path: 'edges.0.source', message: 'Unknown node' }]); + const refusal = flowSaveRefusal(e, FLOW, 'en'); + expect(refusal.message).toBe(t('engine.studio.refusal.unlocated', 'en')); + expect(refusal.target).toBeUndefined(); + expect(refusal.detail).toBe('• edges.0.source — Unknown node'); + }); + + it('several issues: the first it can place leads, the rest are counted, all stay under Details', () => { + const e = serverRefusal([ + { path: 'edges.0.source', message: 'Unknown node' }, + { path: 'nodes.2.config.title', message: MISSING }, + { path: 'nodes.0.label', message: 'Too long' }, + ]); + const refusal = flowSaveRefusal(e, FLOW, 'en'); + expect(refusal.target).toEqual({ kind: 'node', id: 'tell' }); + expect(refusal.message).toMatch(new RegExp(`${tFormat('engine.studio.refusal.more', 'en', { count: 2 }).replace(/[()]/g, '\\$&')}$`)); + expect(refusal.detail?.split('\n')).toHaveLength(3); + }); + + it('`fields.NAME…` on an object names the field by its label', () => { + const e = serverRefusal([{ path: 'fields.status.options', message: 'Invalid input' }]); + const refusal = objectSaveRefusal(e, TASK, 'en'); + expect(refusal.message).toContain(tFormat('engine.studio.refusal.field', 'en', { field: 'Status' })); + expect(refusal.target).toEqual({ kind: 'field', id: 'status' }); + }); + + it('a nav path indexes the navigation that was SENT, and opens the editor\'s own entry', () => { + // The editor holds an unbound entry the save leaves out (objectui#11776), + // so the server's `navigation.1` is the editor's third entry. + const editor = [ + { id: 'home', type: 'page', pageName: 'home', label: 'Home' }, + { id: 'nav_item_2', type: 'object' }, + { id: 'accounts', type: 'object', objectName: 'BAD NAME', label: 'Accounts' }, + ]; + const sent = [editor[0], editor[2]]; + const e = serverRefusal([{ path: 'navigation.1.objectName', message: 'Invalid input' }]); + const refusal = issueRefusal(e, 'en', navEntryLocator({ sent, editor, locale: 'en' })); + + expect(refusal.target).toEqual({ kind: 'nav', id: 'navigation[2]' }); + expect(refusal.message).toContain( + tFormat('engine.studio.refusal.navItemInput', 'en', { input: t('engine.studio.nav.linkObject', 'en'), item: 'Accounts' }), + ); + expect(refusal.message).not.toMatch(DEVELOPER_TEXT); + }); + + it('a nested nav entry is not one the Studio nav inspector edits: it stays under Details', () => { + const editor = [{ id: 'grp', type: 'group', label: 'Group', children: [{ id: 'c', type: 'object', objectName: 'x' }] }]; + const e = serverRefusal([{ path: 'navigation.0.children.0.objectName', message: 'Invalid input' }]); + const refusal = issueRefusal(e, 'en', navEntryLocator({ sent: editor, editor, locale: 'en' })); + expect(refusal.message).toBe(t('engine.studio.refusal.unlocated', 'en')); + expect(refusal.detail).toContain('navigation.0.children.0.objectName'); + }); + + it('CONTROL: `formatMetadataError` — what every other caller prints — is unchanged', () => { + const e = serverRefusal([{ path: 'nodes.2.config.title', message: MISSING }]); + expect(formatMetadataError(e)).toBe(`• nodes.2.config.title — ${MISSING}`); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/metadataError.ts b/packages/app-shell/src/views/studio-design/metadataError.ts index 75e351143e..de79417a8d 100644 --- a/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/packages/app-shell/src/views/studio-design/metadataError.ts @@ -10,8 +10,26 @@ // `MetadataError.issues` they read (objectui#11302), so `plugin-designer`'s // `MetadataFieldsPage` — which cannot import from app-shell — renders a refused // save through the same reader. Callers import them from there directly. +// +// Below the publish formatter sits the strips' view of a refused save +// (`StudioRefusal`, objectui#11785): an author sentence, the input it is about, +// and that same reader's text behind a disclosure. -import { formatMetadataIssue, type MetadataValidationIssue } from '@object-ui/data-objectstack'; +import { + assertObjectMetadataWritable, + CHOICE_TYPES_REQUIRING_OPTIONS, + formatMetadataError, + formatMetadataIssue, + OBJECT_METADATA_TYPE, + RELATIONSHIP_TYPES_REQUIRING_REFERENCE, + type MetadataError, + type MetadataValidationIssue, +} from '@object-ui/data-objectstack'; +import type { NavTargetLabelResolver } from '@object-ui/layout'; +import type { MetadataSelection } from '../metadata-admin/preview-registry.js'; +import { t, tFormat, translateValidationMessage } from '../metadata-admin/i18n.js'; +import { fieldsForNodeType, localizeFlowFields } from '../metadata-admin/inspectors/flow-node-config.js'; +import { navEntryLabelText, type NavEntryLike } from '../metadata-admin/previews/navItemLabel.js'; /** A single failed draft from a publish response's `data.failed[]`. */ export interface PublishFailure { @@ -59,3 +77,282 @@ export function formatPublishFailures(failed: PublishFailure[]): string { } return failed.map(line).join('\n'); } + +// --------------------------------------------------------------------------- +// objectui#11785 — a refusal the author can act on. +// +// Studio's strips used to print `formatMetadataError(e)` verbatim: the object +// write guard's prose, and the server's issue lines with raw paths such as +// `nodes.2.config.title`. Each strip now shows a `StudioRefusal`: one sentence +// in the designer's own words, naming the input it is about in the terms the +// editor shows (a field's label, a step's label and its inspector input, a +// navigation item), the selection that opens that input, and the raw text +// behind a "Details" disclosure. +// +// ⛔ `formatMetadataError` is not changed. It is the one reader of a failed save +// for every other surface, and its text is exactly what Details holds here, so +// nothing the strip printed before is lost: a path this module cannot place on +// the open editor keeps its line under Details, and the sentence says so. +// +// Everything here reads the body the save SENT, never the editor's buffer: an +// issue path indexes the document the server received, and the Interfaces nav +// save sends fewer entries than the editor holds (objectui#11776), so the same +// index can name a different entry in each. +// --------------------------------------------------------------------------- + +/** One save failure as a Studio strip shows it (objectui#11785). */ +export interface StudioRefusal { + /** The sentence the strip shows. */ + message: string; + /** The selection that opens the input the refusal is about, when the open editor has it. */ + target?: MetadataSelection; + /** + * The raw refusal, behind the disclosure: `formatMetadataError`'s text, what + * the strip printed before. Absent when `message` already is that text. + */ + detail?: string; +} + +/** Where an issue's input sits on the open editor. */ +export interface LocatedInput { + /** The author's words for the input, e.g. `the field “Status”`. */ + where: string; + selection: MetadataSelection; +} + +/** Places one issue path, split on `.`, on the body that was sent; `null` when it cannot. */ +export type IssueLocator = (path: readonly string[]) => LocatedInput | null; + +type Doc = Record; + +function isDoc(value: unknown): value is Doc { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** The declared `MetadataError.issues`, or none. */ +function issuesOf(e: unknown): MetadataValidationIssue[] { + const issues = (e as Partial | null | undefined)?.issues; + return Array.isArray(issues) ? issues : []; +} + +/** A failure with nothing to rewrite (a read, a transport failure): shown as it was. */ +export function plainRefusal(e: unknown): StudioRefusal { + return { message: formatMetadataError(e) }; +} + +/** + * A server refusal that carries issues: the first issue a locator can place + * becomes the sentence and the target; every issue stays in Details. With no + * issues the failure is shown as it was ({@link plainRefusal}). + */ +export function issueRefusal(e: unknown, locale: string, locate?: IssueLocator): StudioRefusal { + const detail = formatMetadataError(e); + const issues = issuesOf(e); + if (issues.length === 0) return { message: detail }; + for (const issue of issues) { + const path = String(issue.path ?? '').split('.').filter((seg) => seg !== ''); + const at = locate?.(path); + if (!at) continue; + const sentence = tFormat('engine.studio.refusal.issue', locale, { + where: at.where, + problem: translateValidationMessage(issue.message, locale), + }); + const more = issues.length - 1; + return { + message: more > 0 ? `${sentence} ${tFormat('engine.studio.refusal.more', locale, { count: more })}` : sentence, + target: at.selection, + detail, + }; + } + return { message: t('engine.studio.refusal.unlocated', locale), detail }; +} + +/** A field's label as the editor shows it, its API name when it has none. */ +function fieldLabelOf(name: string, def: Doc): string { + return typeof def.label === 'string' && def.label.trim() !== '' ? def.label.trim() : name; +} + +/** + * The fields of an object body in the order and under the names the write + * guard reads them (`fieldEntries` in `object-metadata-write-guard.ts`): a + * record's keys, or an array entry's `name` (`[index]` without one). `named` + * says whether the editor can select it. + */ +function guardFieldEntries(fields: unknown): Array<{ name: string; def: Doc; named: boolean }> { + if (Array.isArray(fields)) { + return fields.flatMap((raw, index) => { + if (!isDoc(raw)) return []; + const named = typeof raw.name === 'string' && raw.name !== ''; + return [{ name: named ? (raw.name as string) : `[${index}]`, def: raw, named }]; + }); + } + if (isDoc(fields)) { + return Object.entries(fields).flatMap(([name, def]) => (isDoc(def) ? [{ name, def, named: true }] : [])); + } + return []; +} + +const GUARD_PROBE = 'studio-refusal-probe'; + +/** The message the write guard throws for `body`, or `null` when it lets it through. */ +function guardMessageFor(body: Doc): string | null { + try { + assertObjectMetadataWritable(OBJECT_METADATA_TYPE, body, GUARD_PROBE); + return null; + } catch (probe) { + return probe instanceof Error ? probe.message : String(probe); + } +} + +/** + * The object write guard's refusal of `sent`, in the author's words, or `null` + * when `e` is not that refusal. + * + * Told apart without reading the prose: the guard throws before any request, so + * its error carries no status and no issues, and its message is a function of + * the body alone (it names no door). So the guard is run again on the body that + * was sent; `e` is its refusal only if the two messages are identical, and the + * field is the first one the guard refuses on its own, by the same equality. + * Anything else falls through, and the strip shows the failure as it was. + */ +function guardRefusalOf(e: unknown, sent: Doc, locale: string): StudioRefusal | null { + if (!(e instanceof Error)) return null; + if ((e as Partial).status !== undefined || issuesOf(e).length > 0) return null; + if (guardMessageFor(sent) !== e.message) return null; + for (const { name, def, named } of guardFieldEntries(sent.fields)) { + const own = guardMessageFor({ fields: { [name]: def } }); + if (own === null) continue; + if (own !== e.message || !named) return null; + const type = String(def.type); + const key = CHOICE_TYPES_REQUIRING_OPTIONS.includes(type) + ? 'engine.studio.refusal.choiceWithoutOptions' + : RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(type) + ? 'engine.studio.refusal.relationshipWithoutTarget' + : null; + if (!key) return null; + return { + message: tFormat(key, locale, { field: fieldLabelOf(name, def) }), + target: { kind: 'field', id: name }, + detail: e.message, + }; + } + return null; +} + +/** + * Data pillar: `fields.NAME…` on the object body that was sent (a record keyed + * by name, or an array addressed by index or by `name`), placed on that field. + */ +export function objectFieldLocator(sent: Doc, locale: string): IssueLocator { + return (path) => { + if (path[0] !== 'fields' || path.length < 2) return null; + const key = path[1]; + const fields = sent.fields; + let name: string | null = null; + let def: Doc | null = null; + if (Array.isArray(fields)) { + const raw = /^\d+$/.test(key) ? fields[Number(key)] : fields.find((f) => isDoc(f) && f.name === key); + if (isDoc(raw) && typeof raw.name === 'string' && raw.name !== '') { + name = raw.name; + def = raw; + } + } else if (isDoc(fields) && isDoc(fields[key])) { + name = key; + def = fields[key] as Doc; + } + if (name === null || def === null) return null; + return { + where: tFormat('engine.studio.refusal.field', locale, { field: fieldLabelOf(name, def) }), + selection: { kind: 'field', id: name }, + }; + }; +} + +/** A refused save of an object body, as the Data pillar's strip shows it. */ +export function objectSaveRefusal(e: unknown, sent: Doc, locale: string): StudioRefusal { + return guardRefusalOf(e, sent, locale) ?? issueRefusal(e, locale, objectFieldLocator(sent, locale)); +} + +/** + * The inspector label of the input a node path below the node names, e.g. + * `['config', 'title']` on a `notify` node → its Title. The node's field table + * is the one the inspector renders offline; the longest declared path that the + * issue path starts with wins. + */ +function flowInputLabel(type: unknown, rest: readonly string[], locale: string): string | null { + if (typeof type !== 'string' || rest.length === 0) return null; + let best: { label: string; depth: number } | null = null; + for (const field of localizeFlowFields(type, fieldsForNodeType(type), locale)) { + const depth = field.path.length; + if (depth > rest.length || !field.path.every((seg, i) => seg === rest[i])) continue; + if (!best || depth > best.depth) best = { label: field.label, depth }; + } + return best ? best.label : null; +} + +/** + * Automations pillar: `nodes.INDEX…` on the flow body that was sent, placed on + * that node (the step), and on its inspector input when the node's table + * declares one at the rest of the path. + */ +export function flowNodeLocator(sent: Doc, locale: string): IssueLocator { + return (path) => { + if (path[0] !== 'nodes' || !/^\d+$/.test(path[1] ?? '')) return null; + const node = Array.isArray(sent.nodes) ? sent.nodes[Number(path[1])] : undefined; + if (!isDoc(node) || typeof node.id !== 'string' || node.id === '') return null; + const step = typeof node.label === 'string' && node.label.trim() !== '' ? node.label.trim() : node.id; + const input = flowInputLabel(node.type, path.slice(2), locale); + return { + where: input + ? tFormat('engine.studio.refusal.stepInput', locale, { input, step }) + : tFormat('engine.studio.refusal.step', locale, { step }), + selection: { kind: 'node', id: node.id }, + }; + }; +} + +/** A refused save of a flow body, as the Automations pillar's strip shows it. */ +export function flowSaveRefusal(e: unknown, sent: Doc, locale: string): StudioRefusal { + return issueRefusal(e, locale, flowNodeLocator(sent, locale)); +} + +/** The nav inspector's own label for a top-level entry key it edits. */ +const NAV_INPUT_KEYS: Readonly> = { + label: 'engine.studio.nav.label', + objectName: 'engine.studio.nav.linkObject', +}; + +/** + * Interfaces pillar nav: `navigation.INDEX…` on the navigation that was SENT, + * mapped back to the editor's entry of the same object or `id` (the save sends + * the editor's entries less the unbound ones, objectui#11776), and placed on it + * with the nav selection the editor uses (`navigation[i]`). Only top-level + * entries: the Studio nav inspector edits no nested entry, so a `children` + * path stays under Details. + */ +export function navEntryLocator(opts: { + sent: readonly unknown[]; + editor: readonly unknown[]; + locale: string; + targetLabel?: NavTargetLabelResolver; +}): IssueLocator { + const { sent, editor, locale, targetLabel } = opts; + return (path) => { + if (path[0] !== 'navigation' || !/^\d+$/.test(path[1] ?? '') || path[2] === 'children') return null; + const entry = sent[Number(path[1])]; + if (!isDoc(entry)) return null; + const index = editor.findIndex( + (e) => e === entry || (isDoc(e) && typeof e.id === 'string' && e.id !== '' && e.id === entry.id), + ); + if (index < 0) return null; + const own = editor[index] as NavEntryLike; + const item = navEntryLabelText(own, locale, targetLabel) || String(own.id ?? ''); + const inputKey = path[2] !== undefined ? NAV_INPUT_KEYS[path[2]] : undefined; + return { + where: inputKey + ? tFormat('engine.studio.refusal.navItemInput', locale, { input: t(inputKey, locale), item }) + : tFormat('engine.studio.refusal.navItem', locale, { item }), + selection: { kind: 'nav', id: `navigation[${index}]` }, + }; + }; +} diff --git a/packages/data-objectstack/src/metadata-client.objectWriteGuard.test.ts b/packages/data-objectstack/src/metadata-client.objectWriteGuard.test.ts index 95f8987713..9f942b9864 100644 --- a/packages/data-objectstack/src/metadata-client.objectWriteGuard.test.ts +++ b/packages/data-objectstack/src/metadata-client.objectWriteGuard.test.ts @@ -22,6 +22,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MetadataClient } from './metadata-client'; +import { assertObjectMetadataWritable } from './object-metadata-write-guard'; function okResponse(): Response { return new Response(JSON.stringify({ success: true, version: 'v1' }), { @@ -90,10 +91,24 @@ describe('MetadataClient.save — the door applies the object-metadata write gua expect(fetchImpl).not.toHaveBeenCalled(); }); - it('the message names the door, so an author sees where the write stopped', async () => { + it('the door refuses in the guard\'s own words, naming no code (objectui#11785)', async () => { + // Studio tells this refusal from any other failure by re-running the guard + // on the body it sent and comparing the two messages, so the door must add + // nothing of its own: no class name, and no label that differs by door. const { client } = harness(); - await expect(client.save('object', 'account', HALF_FILLED)) - .rejects.toThrow(/^MetadataClient\.save refused/); + let alone = ''; + try { + assertObjectMetadataWritable('object', HALF_FILLED, 'probe'); + } catch (e) { + alone = (e as Error).message; + } + expect(alone, 'the guard alone must refuse this body').not.toBe(''); + const atDoor = await client.save('object', 'account', HALF_FILLED).then( + () => '', + (e: unknown) => (e as Error).message, + ); + expect(atDoor).toBe(alone); + expect(atDoor).not.toMatch(/MetadataClient|@objectstack|objectstack#|objectui#/); }); }); diff --git a/packages/data-objectstack/src/object-metadata-write-guard.test.ts b/packages/data-objectstack/src/object-metadata-write-guard.test.ts index dda72663a3..603c4923cc 100644 --- a/packages/data-objectstack/src/object-metadata-write-guard.test.ts +++ b/packages/data-objectstack/src/object-metadata-write-guard.test.ts @@ -32,6 +32,23 @@ import { const objectWith = (fields: unknown) => ({ name: 'account', label: 'Account', fields }); +/** The message a refused call throws; a call that does not throw fails the test. */ +function messageOf(call: () => void): string { + try { + call(); + } catch (e) { + return (e as Error).message; + } + throw new Error('expected the guard to refuse this body'); +} + +/** + * Code an author cannot act on (objectui#11785): a door's class or function + * name, a package name, or a tracker id. The rationale for each rule stays in + * the module's docblock, out of the runtime string. + */ +const NAMES_CODE = /MetadataClient|MetadataService|importObjectDraft|@objectstack|objectstack#|objectui#|#\d+/; + describe('assertObjectMetadataWritable — the four states of an unusable target', () => { // The four states the contract distinguishes, each measured against // `ObjectSchema` by the derivation pin. The guard must refuse all four, @@ -72,10 +89,11 @@ describe('assertObjectMetadataWritable — the four states of an unusable target .toThrow(/whitespace-only `reference`/); }); - it('names the door that refused, so a thrown message says where the write stopped', () => { + it('reads the same at every door and names no code (objectui#11785)', () => { const body = objectWith({ owner: { type: 'lookup', label: 'Owner' } }); - expect(() => assertObjectMetadataWritable('object', body, 'MetadataClient.save')) - .toThrow(/^MetadataClient\.save refused/); + const at = (door: string) => messageOf(() => assertObjectMetadataWritable('object', body, door)); + expect(at('MetadataClient.save')).toBe(at('importObjectDraft')); + expect(at('MetadataClient.save')).not.toMatch(NAMES_CODE); }); }); @@ -206,10 +224,11 @@ describe('assertObjectMetadataWritable — a choice field needs an option source expect(() => assertObjectMetadataWritable('object', body, 'TEST')).toThrow(/`field_2`/); }); - it('names the door that refused', () => { + it('reads the same at every door and names no code (objectui#11785)', () => { const body = objectWith({ stage: { type: 'radio', label: 'Stage' } }); - expect(() => assertObjectMetadataWritable('object', body, 'MetadataClient.save')) - .toThrow(/^MetadataClient\.save refused/); + const at = (door: string) => messageOf(() => assertObjectMetadataWritable('object', body, door)); + expect(at('MetadataClient.save')).toBe(at('MetadataService')); + expect(at('MetadataClient.save')).not.toMatch(NAMES_CODE); }); it('says nothing about the multi-choice types the ruling does not name', () => { diff --git a/packages/data-objectstack/src/object-metadata-write-guard.ts b/packages/data-objectstack/src/object-metadata-write-guard.ts index 264d8ab6fb..2f326642d3 100644 --- a/packages/data-objectstack/src/object-metadata-write-guard.ts +++ b/packages/data-objectstack/src/object-metadata-write-guard.ts @@ -215,33 +215,46 @@ function fieldEntries(fields: unknown): Array<{ name: string; def: Record).fields; for (const { name, def } of fieldEntries(fields)) { if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) { throw new Error( - `${writer} refused this object metadata write: the field \`${name}\` is a ` + + `The object was not saved: the field \`${name}\` is a ` + `\`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it ` + 'offers nothing to choose and nothing to check a stored value against. A choice field ' + - 'needs at least one option or a shared `picklist` (objectstack#20827), so this draft ' + - 'stays here until it has one. Add an option, or change the field to a non-choice type.', + 'needs at least one option or a shared `picklist`, so this draft stays here until it ' + + 'has one. Add an option, or change the field to a non-choice type.', ); } if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue; if (isUsableTarget(def.reference)) continue; throw new Error( - `${writer} refused this object metadata write: the field \`${name}\` is a ` + + `The object was not saved: the field \`${name}\` is a ` + `\`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object ` + - 'to link to. `@objectstack/spec` refuses the same document at the server with a 422 on ' + + 'to link to. The server refuses the same document with a 422 on ' + `\`fields.${name}.reference\`, and that refusal blocks every later save of this object for ` + - 'as long as the half-filled field rides along in the draft (objectui#7714). ' + + 'as long as the half-filled field rides along in the draft. ' + 'Pick the target object, or change the field to a non-relationship type.', ); } From 3c1a2beb0b90e8c3409ffcbdcdc9e4e101d79e98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:10:58 +0000 Subject: [PATCH 2/4] test(app-shell): close the field rail by its own header in the 11785 pin (objectui#11785) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../studio-design/DataPillar.authorRefusal-11785.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx index 651fc7b1d7..21ee20f602 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx @@ -146,7 +146,9 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117 }); const strip = await screen.findByTestId('studio-refusal'); - fireEvent.click(screen.getByRole('button', { name: t('engine.studio.close', 'en') })); + // The rail's own header close (the field inspector inside carries another). + const railHeader = screen.getByText(t('engine.studio.data.fieldProps', 'en')).closest('header') as HTMLElement; + fireEvent.click(within(railHeader).getByRole('button', { name: t('engine.studio.close', 'en') })); expect(screen.queryByText('API name', { selector: 'label' })).toBeNull(); fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') })); From ddd2316ef1ec8eb9a652b7cb9839ea805db8e571 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:27:47 +0000 Subject: [PATCH 3/4] fix(app-shell): name the refusal body type apart from the spec's own symbol, and declare the release (objectui#11785) `check:spec-symbols` read the module-local `Doc` alias as a hand-written copy of a spec export; it is renamed `SentBody`. Adds the patch changeset for @object-ui/app-shell and @object-ui/data-objectstack. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11785-author-refusal-text.md | 12 ++++++ .../src/views/studio-design/metadataError.ts | 42 +++++++++---------- 2 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 .changeset/11785-author-refusal-text.md diff --git a/.changeset/11785-author-refusal-text.md b/.changeset/11785-author-refusal-text.md new file mode 100644 index 0000000000..0bb74ea3b9 --- /dev/null +++ b/.changeset/11785-author-refusal-text.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/data-objectstack': patch +--- + +Studio shows a refused save as a sentence the author can act on, with the technical detail behind a "Details" disclosure (objectui#11785). + +- **Studio's save strips.** The Data, Automations and Interfaces pages used to print the refusal verbatim: the write guard's developer prose, or the server's raw issue paths such as `nodes.2.config.title — …`. Each strip now shows one sentence that names the input the refusal is about, in the terms the editor shows. On the Data page that is the field's label. On the Automations page it is the step's label and the inspector input, so a refusal at `nodes.2.config.title` reads "Check Title on the step “Notify approver” — …". On the Interfaces page it is the navigation item and its input. A "Show me" button opens that field, step or navigation item. The raw text stays under a closed "Details" disclosure, unchanged. A path Studio cannot place on the open page keeps its line there, and the sentence says the draft was refused. +- **A Picklist with no options.** Switching a field to Picklist before it has an option now says "Changes not saved: the field “Status” needs at least one option…", instead of the guard's message. +- **The object write guard's message names no code.** `assertObjectMetadataWritable` no longer prints the door's class name, a package name or tracker ids. Its message is the same at every door, so the other surfaces that show it as their banner read the field, its type and the remedy. Its signature, error type and exports are unchanged. + +`formatMetadataError` is unchanged, so every other surface that calls it prints what it printed before. Nothing is added to either package entry: no export, prop, type member or language-pack key. The new sentences are rows in the metadata-admin designer's own string tables (en and zh). diff --git a/packages/app-shell/src/views/studio-design/metadataError.ts b/packages/app-shell/src/views/studio-design/metadataError.ts index de79417a8d..bc1155b31d 100644 --- a/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/packages/app-shell/src/views/studio-design/metadataError.ts @@ -123,9 +123,9 @@ export interface LocatedInput { /** Places one issue path, split on `.`, on the body that was sent; `null` when it cannot. */ export type IssueLocator = (path: readonly string[]) => LocatedInput | null; -type Doc = Record; +type SentBody = Record; -function isDoc(value: unknown): value is Doc { +function isSentBody(value: unknown): value is SentBody { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -168,7 +168,7 @@ export function issueRefusal(e: unknown, locale: string, locate?: IssueLocator): } /** A field's label as the editor shows it, its API name when it has none. */ -function fieldLabelOf(name: string, def: Doc): string { +function fieldLabelOf(name: string, def: SentBody): string { return typeof def.label === 'string' && def.label.trim() !== '' ? def.label.trim() : name; } @@ -178,16 +178,16 @@ function fieldLabelOf(name: string, def: Doc): string { * record's keys, or an array entry's `name` (`[index]` without one). `named` * says whether the editor can select it. */ -function guardFieldEntries(fields: unknown): Array<{ name: string; def: Doc; named: boolean }> { +function guardFieldEntries(fields: unknown): Array<{ name: string; def: SentBody; named: boolean }> { if (Array.isArray(fields)) { return fields.flatMap((raw, index) => { - if (!isDoc(raw)) return []; + if (!isSentBody(raw)) return []; const named = typeof raw.name === 'string' && raw.name !== ''; return [{ name: named ? (raw.name as string) : `[${index}]`, def: raw, named }]; }); } - if (isDoc(fields)) { - return Object.entries(fields).flatMap(([name, def]) => (isDoc(def) ? [{ name, def, named: true }] : [])); + if (isSentBody(fields)) { + return Object.entries(fields).flatMap(([name, def]) => (isSentBody(def) ? [{ name, def, named: true }] : [])); } return []; } @@ -195,7 +195,7 @@ function guardFieldEntries(fields: unknown): Array<{ name: string; def: Doc; nam const GUARD_PROBE = 'studio-refusal-probe'; /** The message the write guard throws for `body`, or `null` when it lets it through. */ -function guardMessageFor(body: Doc): string | null { +function guardMessageFor(body: SentBody): string | null { try { assertObjectMetadataWritable(OBJECT_METADATA_TYPE, body, GUARD_PROBE); return null; @@ -215,7 +215,7 @@ function guardMessageFor(body: Doc): string | null { * field is the first one the guard refuses on its own, by the same equality. * Anything else falls through, and the strip shows the failure as it was. */ -function guardRefusalOf(e: unknown, sent: Doc, locale: string): StudioRefusal | null { +function guardRefusalOf(e: unknown, sent: SentBody, locale: string): StudioRefusal | null { if (!(e instanceof Error)) return null; if ((e as Partial).status !== undefined || issuesOf(e).length > 0) return null; if (guardMessageFor(sent) !== e.message) return null; @@ -243,22 +243,22 @@ function guardRefusalOf(e: unknown, sent: Doc, locale: string): StudioRefusal | * Data pillar: `fields.NAME…` on the object body that was sent (a record keyed * by name, or an array addressed by index or by `name`), placed on that field. */ -export function objectFieldLocator(sent: Doc, locale: string): IssueLocator { +export function objectFieldLocator(sent: SentBody, locale: string): IssueLocator { return (path) => { if (path[0] !== 'fields' || path.length < 2) return null; const key = path[1]; const fields = sent.fields; let name: string | null = null; - let def: Doc | null = null; + let def: SentBody | null = null; if (Array.isArray(fields)) { - const raw = /^\d+$/.test(key) ? fields[Number(key)] : fields.find((f) => isDoc(f) && f.name === key); - if (isDoc(raw) && typeof raw.name === 'string' && raw.name !== '') { + const raw = /^\d+$/.test(key) ? fields[Number(key)] : fields.find((f) => isSentBody(f) && f.name === key); + if (isSentBody(raw) && typeof raw.name === 'string' && raw.name !== '') { name = raw.name; def = raw; } - } else if (isDoc(fields) && isDoc(fields[key])) { + } else if (isSentBody(fields) && isSentBody(fields[key])) { name = key; - def = fields[key] as Doc; + def = fields[key] as SentBody; } if (name === null || def === null) return null; return { @@ -269,7 +269,7 @@ export function objectFieldLocator(sent: Doc, locale: string): IssueLocator { } /** A refused save of an object body, as the Data pillar's strip shows it. */ -export function objectSaveRefusal(e: unknown, sent: Doc, locale: string): StudioRefusal { +export function objectSaveRefusal(e: unknown, sent: SentBody, locale: string): StudioRefusal { return guardRefusalOf(e, sent, locale) ?? issueRefusal(e, locale, objectFieldLocator(sent, locale)); } @@ -295,11 +295,11 @@ function flowInputLabel(type: unknown, rest: readonly string[], locale: string): * that node (the step), and on its inspector input when the node's table * declares one at the rest of the path. */ -export function flowNodeLocator(sent: Doc, locale: string): IssueLocator { +export function flowNodeLocator(sent: SentBody, locale: string): IssueLocator { return (path) => { if (path[0] !== 'nodes' || !/^\d+$/.test(path[1] ?? '')) return null; const node = Array.isArray(sent.nodes) ? sent.nodes[Number(path[1])] : undefined; - if (!isDoc(node) || typeof node.id !== 'string' || node.id === '') return null; + if (!isSentBody(node) || typeof node.id !== 'string' || node.id === '') return null; const step = typeof node.label === 'string' && node.label.trim() !== '' ? node.label.trim() : node.id; const input = flowInputLabel(node.type, path.slice(2), locale); return { @@ -312,7 +312,7 @@ export function flowNodeLocator(sent: Doc, locale: string): IssueLocator { } /** A refused save of a flow body, as the Automations pillar's strip shows it. */ -export function flowSaveRefusal(e: unknown, sent: Doc, locale: string): StudioRefusal { +export function flowSaveRefusal(e: unknown, sent: SentBody, locale: string): StudioRefusal { return issueRefusal(e, locale, flowNodeLocator(sent, locale)); } @@ -340,9 +340,9 @@ export function navEntryLocator(opts: { return (path) => { if (path[0] !== 'navigation' || !/^\d+$/.test(path[1] ?? '') || path[2] === 'children') return null; const entry = sent[Number(path[1])]; - if (!isDoc(entry)) return null; + if (!isSentBody(entry)) return null; const index = editor.findIndex( - (e) => e === entry || (isDoc(e) && typeof e.id === 'string' && e.id !== '' && e.id === entry.id), + (e) => e === entry || (isSentBody(e) && typeof e.id === 'string' && e.id !== '' && e.id === entry.id), ); if (index < 0) return null; const own = editor[index] as NavEntryLike; From 00304bc5541c318095f4957c307d2b59430d01b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:16:27 +0000 Subject: [PATCH 4/4] docs(data-objectstack): the README's two thrown-message quotes follow the guard's new first line (objectui#11785) The write guard no longer names its door. Both `// throws:` quotes in the README began "MetadataClient.save refused this object metadata write:"; they now begin "The object was not saved:", as the guard throws for those two example bodies. No other README text changes. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/data-objectstack/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 77db264972..ff986e6ab9 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -781,7 +781,7 @@ await client.save('object', 'account', { name: 'account', fields: { owner: { type: 'lookup', label: 'Owner' } }, }); -// throws: MetadataClient.save refused this object metadata write: the field +// throws: The object was not saved: the field // `owner` is a `lookup` and carries no `reference` key at all ... ``` @@ -808,7 +808,7 @@ await client.save('object', 'deal', { name: 'deal', fields: { stage: { type: 'select', label: 'Stage', options: [] } }, }); -// throws: MetadataClient.save refused this object metadata write: the field +// throws: The object was not saved: the field // `stage` is a `select` with no options ... ```