From ab84deff46f37a87cad42fb4dfac2f7c31acb154 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:56:24 +0000 Subject: [PATCH 1/5] fix(studio): hold a dashboard list property's unfinished item instead of autosaving it "Add item" under a dashboard's Global Filters or header Actions appends an item with every input unset; the draft door refused it with a 422 and every later save of the same body was refused for it too. The Interfaces pillar now holds such an item the way it holds a widget waiting for its binding: judged by the spec's own DashboardSchema, unsent while every issue on the item is an input it leaves unset, with a held line naming it and "Show me" opening the dashboard's properties. Claude-Session: https://claude.ai/code/session_01Utp6CZQGM7KwDbfPanMns3 Co-authored-by: Claude --- .../src/views/metadata-admin/SchemaForm.tsx | 9 +- .../metadata-admin/field-source-label.ts | 13 ++ .../studio-design/StudioDesignSurface.tsx | 20 ++- .../src/views/studio-design/metadataError.ts | 162 ++++++++++++++++-- ui/packages/i18n/src/namespaces/studio/en.ts | 3 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 6 files changed, 185 insertions(+), 24 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 892c144b4c1..c6f9eb717d0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -69,7 +69,7 @@ import { type WidgetRenderer, } from './widgets.js'; import { useMetadataLocale, t, tFormat, translateValidationMessage, translateEnumOption, translateSchemaFieldLabel, translateSchemaFieldHelp } from './i18n.js'; -import { untranslatedFieldLabel } from './field-source-label.js'; +import { prettifyFieldName as prettify, untranslatedFieldLabel } from './field-source-label.js'; /** * The form authoring surface — `FormFieldSpec` and the `VisibilityPredicate` @@ -3437,13 +3437,6 @@ function safeStringify(v: unknown): string { } } -function prettify(key: string): string { - return key - .replace(/[_-]+/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - function orderKeys(keys: string[], preferred: string[]): string[] { if (!preferred.length) return keys; const set = new Set(keys); diff --git a/ui/packages/app-shell/src/views/metadata-admin/field-source-label.ts b/ui/packages/app-shell/src/views/metadata-admin/field-source-label.ts index 36c989a995c..76c5a0d077f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/field-source-label.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/field-source-label.ts @@ -61,3 +61,16 @@ export function untranslatedFieldLabel( } return typeof fieldSpec.label === 'string' ? fieldSpec.label : undefined; } + +/** + * The label a form row shows for `key` when neither the form nor the schema + * names it: `globalFilters` reads "Global Filters". Kept here, beside the + * source label, so a reader that names a row as `SchemaForm` does (the Studio's + * held line) needs no import of the form itself. + */ +export function prettifyFieldName(key: string): string { + return key + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index a72dc91de47..9befa881905 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -556,16 +556,16 @@ function CreateTakenNotice({ * * Exported for its pins; `index.ts` does not re-export it. */ -export function StudioHeldNotice({ +export function StudioHeldNotice({ held, locale, onShow, className, }: { - held: StudioHeld; + held: StudioHeld; locale: string; /** Opens the held input; omitted while that input is the one open. */ - onShow?: (target: MetadataSelection) => void; + onShow?: (target: Target) => void; /** Spacing and type size, which differ by pillar. */ className?: string; }): React.ReactElement { @@ -4832,6 +4832,9 @@ export function InterfacesPillar({ // widget's inspector hints the input, and the line on the canvas names it. // Asked of the spec's own widget schema (`dashboardHeldEdit`); a widget that // is finished but wrong is sent, and its refusal shows as before. + // objectstack#27459 — so is an item just added to one of the dashboard's + // own list properties (Global Filters, the header's Actions), asked of the + // spec's dashboard schema, until its required inputs are filled in. const leafType = current?.type; const leafIncomplete = React.useMemo( () => (ifDirty && leafType === 'dashboard' ? dashboardHeldEdit(draft, locale) : null), @@ -5572,10 +5575,15 @@ export function InterfacesPillar({ held={leafHeld} locale={locale} // objectui#11910 — no "Show me" while that widget is the one open. + // objectstack#27459 — an item of the leaf's own list property + // (`target: null`) always offers it: the leaf's properties may + // be collapsed, or scrolled past the list it opens and focuses. onShow={ - selection?.kind === leafHeld.target.kind && selection.id === leafHeld.target.id - ? undefined - : (target) => setSelection(target) + leafHeld.target === null + ? () => showLeafRefusal(null, leafHeld.input) + : selection?.kind === leafHeld.target.kind && selection.id === leafHeld.target.id + ? undefined + : (target) => setSelection(target) } className="px-3 py-2 text-xs" /> diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.ts index b62367a323c..e52f543c205 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.ts @@ -26,11 +26,14 @@ import { type MetadataValidationIssue, } from '@object-ui/data-objectstack'; import { FlowSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; -import { ActionSchema, DashboardWidgetSchema, resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; +import { ActionSchema, DashboardSchema, DashboardWidgetSchema, resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; 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 { t, tFormat, translateSchemaFieldLabel, translateValidationMessage } from '../metadata-admin/i18n.js'; +import { getDashboardForm } from '../metadata-admin/dashboard-schema.js'; +import { prettifyFieldName } from '../metadata-admin/field-source-label.js'; +import type { FormFieldSpec, FormViewSpec } from '../metadata-admin/form-spec.js'; import { fieldsForNodeType, getFieldValue, @@ -672,15 +675,21 @@ export function publishRefusal( // what the author finished. // --------------------------------------------------------------------------- -/** An edit a pillar holds unsent because its body is incomplete (objectui#11786). */ -export interface StudioHeld { +/** + * An edit a pillar holds unsent because its body is incomplete (objectui#11786). + * `Target` widens to `MetadataSelection | null` for a held edit that lives in + * the leaf's own properties, which no selection opens (objectstack#27459). + */ +export interface StudioHeld { /** * What the edit still needs, as a clause the held line and the Publish * refusal both build on, e.g. `the field “Status” needs at least one option`. */ clause: string; - /** The selection that opens the input the edit needs. */ - target: MetadataSelection; + /** The selection that opens the input the edit needs; `null` = the leaf's own properties. */ + target: Target; + /** The label of the input "Show me" focuses once the target is open, when it names one. */ + input?: string; } /** What the write guard asks of a field it refuses: its options, its target, or nothing it names. */ @@ -968,11 +977,14 @@ export function widgetMissingInputs(widget: unknown): string[] { * The edit the Interfaces pillar holds for `draft`, the dashboard its leaf * autosave would send, or `null`: the first widget the spec refuses only for a * binding input it leaves out ({@link widgetMissingInputs}), named by its title - * in the designer locale and selected as the canvas selects it. + * in the designer locale and selected as the canvas selects it; else the first + * item of one of its own list properties the spec refuses only for inputs it + * leaves unset ({@link listItemHeldInput}), placed on the dashboard's + * properties (objectstack#27459). */ -export function dashboardHeldEdit(draft: SentBody, locale: string): StudioHeld | null { - if (!Array.isArray(draft.widgets)) return null; - for (const widget of draft.widgets) { +export function dashboardHeldEdit(draft: SentBody, locale: string): StudioHeld | null { + const widgets = Array.isArray(draft.widgets) ? draft.widgets : []; + for (const widget of widgets) { if (!isSentBody(widget) || typeof widget.id !== 'string' || widget.id === '') continue; const [key] = widgetMissingInputs(widget); const input = WIDGET_BINDING_INPUTS.find((entry) => entry.key === key); @@ -987,5 +999,135 @@ export function dashboardHeldEdit(draft: SentBody, locale: string): StudioHeld | target: { kind: 'widget', id: widget.id }, }; } + const item = listItemHeldInput(draft, DashboardSchema, DASHBOARD_WIDGET_LIST); + return item ? heldListItem(item, getDashboardForm(locale), locale) : null; +} + +// --------------------------------------------------------------------------- +// objectstack#27459 — an item of a leaf's list property held until it is +// filled in. +// +// A properties-panel list ("Add item" under Global Filters, or the header's +// Actions) appends an item with every input unset, and the installed spec +// refuses it until its required inputs are given. Sent, the draft door answers +// 422 for that item, and every later save of the same body is refused for it +// too, so unrelated edits are lost. Such an item is held as a widget waiting +// for its binding is ({@link dashboardHeldEdit}): unsent, with a neutral line +// naming the input, and Publish refusing while it is held. +// +// ⛔ No list of required inputs lives here. The judge is the leaf's own spec +// schema, asked of the body as it stands. An item is merely unfinished when +// every issue the spec names inside it is on an input the item leaves unset +// (absent, or a text cleared to `''`); an item refused for a value it holds is +// finished but wrong, so it is sent, and the refusal it draws shows as before. +// --------------------------------------------------------------------------- + +/** The dashboard list whose items are widgets: judged by {@link widgetMissingInputs}, never as a list item. */ +const DASHBOARD_WIDGET_LIST: ReadonlySet = new Set(['widgets']); + +/** An item of a leaf's list property held for an unset input. */ +export interface HeldListItem { + /** The path of the list property in the leaf body, e.g. `['header', 'actions']`. */ + list: string[]; + /** The item's index in that list. */ + index: number; + /** The item's input the spec refuses it without. */ + input: string; +} + +/** + * The first item of a list property in `body` that `schema` refuses only for + * inputs the item leaves unset, with the first such input, or `null`. + * + * Each issue is placed on the item of the outermost list its path enters; an + * issue on no list item, or on a list `skip` names by its first key, is not + * this rule's. An item is held only when every issue placed on it names an + * unset input directly below it; one issue on the item itself, or on an input + * it gives a value, makes it finished but wrong. + */ +export function listItemHeldInput( + body: SentBody, + schema: { safeParse: (value: unknown) => { success: boolean; error?: { issues: ReadonlyArray<{ path: PropertyKey[] }> } } }, + skip: ReadonlySet = new Set(), +): HeldListItem | null { + const parsed = schema.safeParse(body); + if (parsed.success || !parsed.error) return null; + // Insertion order is the spec's issue order, so the first held item is the first one it names. + const items = new Map(); + for (const issue of parsed.error.issues) { + const path = issue.path.map(String); + let value: unknown = body; + let at = -1; + for (let i = 0; i < path.length; i++) { + if (Array.isArray(value) && /^\d+$/.test(path[i])) { + at = i; + break; + } + value = isSentBody(value) ? value[path[i]] : undefined; + } + if (at < 1 || skip.has(path[0])) continue; + const list = path.slice(0, at); + const index = Number(path[at]); + const key = `${list.join('.')}#${index}`; + if (items.get(key) === null) continue; + const item = (value as unknown[])[index]; + const input = path[at + 1]; + if (input === undefined || !isSentBody(item) || !itemInputUnset(item[input])) { + items.set(key, null); + continue; + } + if (!items.has(key)) items.set(key, { list, index, input }); + } + for (const item of items.values()) if (item) return item; return null; } + +/** Whether a list item's input holds no value yet: absent, or a text cleared to `''`. */ +function itemInputUnset(value: unknown): boolean { + return value === undefined || value === ''; +} + +/** The form row of `key` among `fields`, as the form declares it. */ +function formRowOf(fields: ReadonlyArray | undefined, key: string): FormFieldSpec | undefined { + for (const row of fields ?? []) { + const spec = typeof row === 'string' ? { field: row } : row; + if (spec.field === key) return spec; + } + return undefined; +} + +/** A form row's label as `SchemaForm` shows it when the schema gives it no title: its own, translated, or prettified. */ +function formRowLabel(spec: FormFieldSpec | undefined, key: string, locale: string): string { + return spec?.label || translateSchemaFieldLabel(key, undefined, locale) || prettifyFieldName(key); +} + +/** + * {@link HeldListItem} as the leaf's held edit: a clause naming the input, the + * item by its place in the list (the `#n` its row shows) and the list by the + * label `form` gives it; placed on the leaf's own properties, with the list's + * label for "Show me" to focus. + */ +export function heldListItem( + item: HeldListItem, + form: FormViewSpec | undefined, + locale: string, +): StudioHeld { + let rows: ReadonlyArray | undefined = (form?.sections ?? []).flatMap((section) => + 'fields' in section && Array.isArray(section.fields) ? section.fields : [], + ); + let listRow: FormFieldSpec | undefined; + for (const key of item.list) { + listRow = formRowOf(rows, key); + rows = listRow?.fields; + } + const list = formRowLabel(listRow, item.list[item.list.length - 1], locale); + return { + clause: tFormat('engine.studio.held.listItemNeedsInput', locale, { + input: formRowLabel(formRowOf(rows, item.input), item.input, locale), + n: item.index + 1, + list, + }), + target: null, + input: list, + }; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 62de87573c4..bc602311f1a 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3012,6 +3012,9 @@ const STUDIO_EN: Record = { // objectui#11910 — a dashboard widget held until it is bound; `input` is the // widget inspector's own label for it (Dataset, Values (measures)). 'engine.studio.held.widgetNeedsInput': '{input} on the widget “{widget}” needs a value', + // objectstack#27459 — an item of a dashboard list property (Global Filters, + // the header's Actions) held until it is filled in; `n` is the `#n` its row shows. + 'engine.studio.held.listItemNeedsInput': '{input} on item #{n} of “{list}” needs a value', // objectstack#27464 — a navigation rename typed but not committed yet. 'engine.studio.held.navRename': 'the rename of the navigation item “{item}” is not finished', // objectstack#27485 — a field CEL expression the inspector judged broken, held after it closes. diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 2114a39dc68..2b78fbacac9 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2681,6 +2681,8 @@ const STUDIO_ZH: Record = { 'engine.studio.held.needsRuleField': '规则「{rule}」需要选择一个字段', 'engine.studio.held.needsRuleMessage': '规则「{rule}」需要一条错误消息', 'engine.studio.held.widgetNeedsInput': '组件「{widget}」的「{input}」需要填写', + // objectstack#27459 — a dashboard list property's item held until it is filled in. + 'engine.studio.held.listItemNeedsInput': '「{list}」第 {n} 项的「{input}」需要填写', // objectstack#27464 — a navigation rename typed but not committed yet. 'engine.studio.held.navRename': '导航项「{item}」的重命名尚未完成', 'engine.studio.held.celInvalid': '字段「{field}」的表达式有错误需要修正', From 62dac23e119b5e4534b264259470e79300d420a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:04:52 +0000 Subject: [PATCH 2/5] test(studio): pin the held dashboard list item, and add its changeset Claude-Session: https://claude.ai/code/session_01Utp6CZQGM7KwDbfPanMns3 Co-authored-by: Claude --- .changeset/studio-dashboard-list-item-held.md | 13 ++ ...terfacesPillar.heldListItem-27459.test.tsx | 207 ++++++++++++++++++ .../metadataError.heldListItem-27459.test.ts | 160 ++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 .changeset/studio-dashboard-list-item-held.md create mode 100644 ui/packages/app-shell/src/views/studio-design/InterfacesPillar.heldListItem-27459.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/metadataError.heldListItem-27459.test.ts diff --git a/.changeset/studio-dashboard-list-item-held.md b/.changeset/studio-dashboard-list-item-held.md new file mode 100644 index 00000000000..5ee32d47463 --- /dev/null +++ b/.changeset/studio-dashboard-list-item-held.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): an item just added to a dashboard's list property is held until it is filled in, instead of being autosaved and refused + +Clause-②: no +Metadata-protocol: none + +In the Studio's Interfaces pillar, "Add item" under a dashboard's **Global Filters** or its header **Actions** used to autosave the new, empty item. The draft door refused it (422), and every later save of the dashboard carried the same item and was refused too, so an edit made afterwards, such as a new Label, was lost on reload. + +Such an item is now held the way a widget waiting for its dataset is: nothing is sent while the dashboard's own schema refuses the item only for inputs it leaves unset, a neutral line names what it needs (for example "Field on item #1 of “Global Filters” needs a value") with a **Show me** that opens the dashboard's properties at that list, and Publish refuses while it is held. Once the item is filled in, the next autosave sends every edit kept meanwhile. An item refused for a value it holds is not held: it is sent, and the refusal it draws shows as before. diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.heldListItem-27459.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.heldListItem-27459.test.tsx new file mode 100644 index 00000000000..c194537d902 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.heldListItem-27459.test.tsx @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27459 — the Interfaces pillar holds an item just added to a + * dashboard's own list property unsent until it is filled in, instead of + * autosaving it for the draft door to refuse. + * + * The card: Properties → Global Filters → "Add item", then a Label edit. The + * autosave PUT `globalFilters[N] = {}`, the door answered 422 on + * `globalFilters.N.field`, the Label save carried the same item and was refused + * too, and the Label edit was gone after a reload. The pillar now holds the + * dashboard as it holds a widget waiting for its binding (objectui#11910): + * nothing is sent, a neutral line names the input with a "Show me", and + * filling the item in lets the next autosave send every edit kept meanwhile. + * + * Mounted with a metadata client double that records every save, a stub + * canvas and the real `DashboardDefaultInspector` (its spec-driven form). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { DashboardSchema } from '@objectstack/spec/ui'; + +const NAV = [{ id: 'nav_sales', type: 'dashboard', label: 'Sales', dashboardName: 'sales_dash' }]; + +/** One bound widget and no list property yet: the dashboard parses with the spec as served. */ +const SALES_DASH = { + name: 'sales_dash', + label: 'Sales', + widgets: [ + { + id: 'pipeline', + type: 'metric', + title: 'Pipeline', + dataset: 'sales_ds', + values: ['revenue'], + layout: { x: 0, y: 0, w: 3, h: 2 }, + }, + ], +}; + +const mockClient = { + withPreviewDrafts: vi.fn((): unknown => mockClient), + save: vi.fn(async (_type: string, _name: string, _body: Record) => ({ + success: true, + version: 'v1', + state: 'draft', + })), + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }]; + if (type === 'dashboard') return [SALES_DASH]; + return []; + }), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (type: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', active: true, navigation: NAV } }; + if (type === 'dashboard') return { effective: JSON.parse(JSON.stringify(SALES_DASH)) }; + return { code: null, overlay: null, overlayScope: null, effective: null }; + }), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), +}; + +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: () => ({}) }; +}); + +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; +import { DashboardDefaultInspector } from '../metadata-admin/inspectors/DashboardDefaultInspector'; +import { t, tFormat } from '../metadata-admin/i18n'; + +// A stub canvas: the leaf needs a registered preview to be draft-editable, +// and the canvas's own drawing is not what this file pins. +registerMetadataPreview('dashboard', () => null); +registerMetadataDefaultInspector('dashboard', DashboardDefaultInspector); + +beforeEach(() => { + for (const fn of Object.values(mockClient)) fn.mockClear(); +}); +afterEach(cleanup); + +function mountPillar(onHeldEditChange?: (clause: string | null) => void) { + return render( + + + , + ); +} + +function dashboardSaves(): Array> { + return mockClient.save.mock.calls.filter((c) => c[0] === 'dashboard').map((c) => c[2]); +} + +/** Past the autosave's 1.5 s debounce. */ +async function outlastDebounce(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); +} + +/** The dashboard's properties are open, with its Filters section expanded: its Global Filters row is drawn. */ +async function ready(): Promise { + const inspector = await screen.findByTestId('if-inspector', undefined, { timeout: 8000 }); + fireEvent.click(await within(inspector).findByRole('button', { name: /^Filters/ }, { timeout: 8000 })); + await within(inspector).findByText('Global Filters', { selector: 'label' }, { timeout: 8000 }); + return inspector; +} + +/** The form row of the list property labelled `label`: its label and its repeater. */ +function listRow(inspector: HTMLElement, label: string): HTMLElement { + const row = within(inspector).getByText(label, { selector: 'label' }).parentElement?.parentElement; + if (!row) throw new Error(`no form row for ${label}`); + return row; +} + +/** The dashboard's own Label input commits on blur. */ +function editLabel(inspector: HTMLElement, value: string): void { + const input = within(inspector).getByDisplayValue('Sales'); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); +} + +const clause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Field', n: 1, list: 'Global Filters' }); + +describe('an item just added to a dashboard list property is held until it is filled in (objectstack#27459)', () => { + it('Global Filters → Add item, then a Label edit: nothing is sent, no red strip, the line names the Field', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready(); + fireEvent.click(within(listRow(inspector, 'Global Filters')).getByRole('button', { name: t('engine.form.addItem', 'en') })); + editLabel(inspector, 'Sales v2'); + await outlastDebounce(); + + expect(dashboardSaves(), 'the unfinished item is not sent, nor the Label edit with it').toEqual([]); + expect(screen.queryByTestId('studio-refusal'), 'no red strip on the normal path').toBeNull(); + expect(screen.getByTestId('studio-held-message')).toHaveTextContent( + tFormat('engine.studio.held.line', 'en', { clause }), + ); + // Publish's half: the surface is told what is held. + expect(report).toHaveBeenLastCalledWith(clause); + }); + + it('"Show me" focuses the list in the dashboard properties', async () => { + mountPillar(); + const inspector = await ready(); + const row = listRow(inspector, 'Global Filters'); + fireEvent.click(within(row).getByRole('button', { name: t('engine.form.addItem', 'en') })); + const show = await within(await screen.findByTestId('studio-held')).findByRole('button', { + name: t('engine.studio.refusal.show', 'en'), + }); + (document.activeElement as HTMLElement | null)?.blur(); + fireEvent.click(show); + await waitFor(() => expect(row.contains(document.activeElement)).toBe(true)); + }); + + it('filling the Field in sends one save carrying the item and the Label edit kept meanwhile, and clears the line', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready(); + const row = listRow(inspector, 'Global Filters'); + fireEvent.click(within(row).getByRole('button', { name: t('engine.form.addItem', 'en') })); + editLabel(inspector, 'Sales v2'); + await outlastDebounce(); + expect(dashboardSaves()).toEqual([]); + + const field = within(row).getByLabelText('Field'); + fireEvent.change(field, { target: { value: 'stage' } }); + fireEvent.blur(field); + await waitFor(() => expect(dashboardSaves()).toHaveLength(1), { timeout: 4000 }); + const [sent] = dashboardSaves(); + expect(sent.label).toBe('Sales v2'); + expect(sent.globalFilters).toEqual([expect.objectContaining({ field: 'stage' })]); + expect( + DashboardSchema.safeParse(JSON.parse(JSON.stringify(sent))).success, + 'the body sent is one the door accepts', + ).toBe(true); + await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull()); + expect(screen.queryByTestId('studio-refusal')).toBeNull(); + expect(report).toHaveBeenLastCalledWith(null); + }); + + // CONTROL: an edit to a dashboard with no unfinished item saves at once. + it('a Label edit alone saves at once, with no line', async () => { + mountPillar(); + const inspector = await ready(); + editLabel(inspector, 'Sales v2'); + await waitFor(() => expect(dashboardSaves()).toHaveLength(1), { timeout: 4000 }); + expect(dashboardSaves()[0].label).toBe('Sales v2'); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.heldListItem-27459.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.heldListItem-27459.test.ts new file mode 100644 index 00000000000..9051ec9a6e6 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.heldListItem-27459.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27459 — which dashboards the Interfaces pillar's leaf autosave + * HOLDS for an unfinished item of one of the dashboard's own list properties. + * + * The card: Properties → Global Filters → "Add item" autosaved + * `globalFilters[N] = {}`, the draft door answered 422 on + * `globalFilters.N.field`, and every later save (a Label edit) carried the same + * item and was refused too, so the edit was lost. The header's Actions → "Add + * item" did the same on `header.actions.0.label` / `actionUrl`. + * + * Every "held" case is paired with the spec's own `DashboardSchema` verdict on + * the same dashboard (it refuses it, at exactly the unset inputs), and every + * "not held" control with a dashboard that judge accepts, or refuses for a + * value the author wrote: the hold is never stricter than the contract, and + * never hides a finished but wrong item. The pillar that acts on it is pinned + * in `InterfacesPillar.heldListItem-27459.test.tsx`. + */ + +import { describe, expect, it } from 'vitest'; +import { DashboardSchema } from '@objectstack/spec/ui'; +import { dashboardHeldEdit, listItemHeldInput } from './metadataError'; +import { t, tFormat } from '../metadata-admin/i18n'; + +/** The issue paths the spec's dashboard parse refuses `dashboard` at, as the door reports them. */ +function dashboardRefusals(dashboard: Record): string[] { + const result = DashboardSchema.safeParse(dashboard); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +} + +const BOUND = { id: 'pipeline', type: 'metric', title: 'Pipeline', dataset: 'sales_ds', values: ['revenue'] }; +const STAGE_FILTER = { field: 'stage', label: 'Stage' }; + +const dash = (extra: Record) => ({ name: 'sales_dash', label: 'Sales', widgets: [BOUND], ...extra }); + +/** An item as the properties panel's repeater "Add item" builds it: every row input present and unset. */ +function blank(...inputs: string[]): Record { + return Object.fromEntries(inputs.map((input) => [input, undefined])); +} +const FILTER_INPUTS = ['name', 'field', 'object', 'label', 'type', 'options', 'optionsFrom', 'defaultValue', 'scope', 'targetWidgets']; +const ACTION_INPUTS = ['label', 'actionUrl', 'actionType', 'icon']; + +const clause = (input: string, n: number, list: string, locale = 'en') => + tFormat('engine.studio.held.listItemNeedsInput', locale, { input, n, list }); + +describe('the Interfaces pillar holds a dashboard list item the spec refuses only for unset inputs (objectstack#27459)', () => { + it('Global Filters → Add item: held for its Field, as the door refuses it, placed on the dashboard properties', () => { + const body = dash({ globalFilters: [STAGE_FILTER, blank(...FILTER_INPUTS)] }); + expect(dashboardRefusals(body)).toEqual(['globalFilters.1.field']); + expect(dashboardHeldEdit(body, 'en')).toEqual({ + clause: clause('Field', 2, 'Global Filters'), + target: null, + input: 'Global Filters', + }); + }); + + it('the stored `{}` the card saw is held the same way', () => { + const body = dash({ globalFilters: [{}] }); + expect(dashboardRefusals(body)).toEqual(['globalFilters.0.field']); + expect(dashboardHeldEdit(body, 'en')?.clause).toBe(clause('Field', 1, 'Global Filters')); + }); + + // CONTROL: the hold is never stricter than the contract. + it('a Field cleared to blank is one the spec takes: not held', () => { + const body = dash({ globalFilters: [{ ...blank(...FILTER_INPUTS), field: '' }] }); + expect(dashboardRefusals(body)).toEqual([]); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + }); + + it('an Action URL cleared to blank is unset: held like an absent one', () => { + const body = dash({ header: { actions: [{ ...blank(...ACTION_INPUTS), label: 'Open', actionUrl: '' }] } }); + expect(dashboardRefusals(body)).toEqual(['header.actions.0.actionUrl']); + expect(dashboardHeldEdit(body, 'en')?.clause).toBe(clause('Action URL', 1, 'Actions')); + }); + + it('a Field filled: not held, and the spec accepts it', () => { + const body = dash({ globalFilters: [{ ...blank(...FILTER_INPUTS), field: 'stage' }] }); + expect(dashboardRefusals(body)).toEqual([]); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + }); + + it('header Actions → Add item: held for the first input it needs, nested list named by its own label', () => { + const body = dash({ header: { actions: [blank(...ACTION_INPUTS)] } }); + expect(dashboardRefusals(body)).toEqual(['header.actions.0.label', 'header.actions.0.actionUrl']); + expect(dashboardHeldEdit(body, 'en')).toEqual({ + clause: clause('Label', 1, 'Actions'), + target: null, + input: 'Actions', + }); + const labelled = dash({ header: { actions: [{ ...blank(...ACTION_INPUTS), label: 'Open' }] } }); + expect(dashboardRefusals(labelled)).toEqual(['header.actions.0.actionUrl']); + expect(dashboardHeldEdit(labelled, 'en')?.clause).toBe(clause('Action URL', 1, 'Actions')); + }); + + it('names the list and the item in the designer locale the panel shows them in', () => { + const body = dash({ globalFilters: [{}] }); + expect(dashboardHeldEdit(body, 'zh-CN')).toEqual({ + clause: clause('Field', 1, '全局筛选', 'zh-CN'), + target: null, + input: '全局筛选', + }); + }); + + it('a widget waiting for its binding is named first: the widget rule keeps its own hold', () => { + const body = dash({ widgets: [BOUND, { id: 'widget_1', type: 'metric', title: 'New metric' }], globalFilters: [{}] }); + expect(dashboardHeldEdit(body, 'en')?.target).toEqual({ kind: 'widget', id: 'widget_1' }); + }); + + // CONTROL: errors stay for finished work. + it('an item refused for a value it holds is finished but wrong: not held, so its refusal shows', () => { + const body = dash({ header: { actions: [{ label: 'Open', actionUrl: 'javascript:alert(1)' }] } }); + expect(dashboardRefusals(body)).toEqual(['header.actions.0.actionUrl']); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + }); + + it('a wrong value the spec does not judge yet is judged once the item is filled in', () => { + // The spec judges the URL only once the item has its label, so it names the label alone: held for it. + const unlabelled = dash({ header: { actions: [{ actionUrl: 'javascript:alert(1)' }] } }); + expect(dashboardRefusals(unlabelled)).toEqual(['header.actions.0.label']); + expect(dashboardHeldEdit(unlabelled, 'en')?.clause).toBe(clause('Label', 1, 'Actions')); + // Labelled, the URL is refused for the value it holds: sent, and its refusal shows. + const labelled = dash({ header: { actions: [{ label: 'Open', actionUrl: 'javascript:alert(1)' }] } }); + expect(dashboardRefusals(labelled)).toEqual(['header.actions.0.actionUrl']); + expect(dashboardHeldEdit(labelled, 'en')).toBeNull(); + }); + + it('an item with an unset input and a wrong value the spec names both of is not held', () => { + const body = dash({ globalFilters: [{ type: 'radio' }] }); + expect(dashboardRefusals(body)).toEqual(['globalFilters.0.field', 'globalFilters.0.type']); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + }); + + it('a list holding something other than an item (a refused value on the list itself) is not held', () => { + const body = dash({ globalFilters: ['stage'] }); + expect(dashboardRefusals(body)).toEqual(['globalFilters.0']); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + }); + + it('a dashboard the spec accepts holds nothing', () => { + const body = dash({ globalFilters: [STAGE_FILTER], header: { actions: [{ label: 'Open', actionUrl: '/x' }] } }); + expect(dashboardRefusals(body)).toEqual([]); + expect(dashboardHeldEdit(body, 'en')).toBeNull(); + expect(listItemHeldInput(body, DashboardSchema)).toBeNull(); + }); + + it('`listItemHeldInput` places the item: the list path, the index and the input', () => { + expect(listItemHeldInput(dash({ header: { actions: [{ label: 'A', actionUrl: '/a' }, {}] } }), DashboardSchema)).toEqual({ + list: ['header', 'actions'], + index: 1, + input: 'label', + }); + }); +}); + +// Keeps `t` honest: the clause key exists in both catalogs. +it('the held clause key is catalogued in en and zh', () => { + expect(t('engine.studio.held.listItemNeedsInput', 'en')).not.toBe('engine.studio.held.listItemNeedsInput'); + expect(t('engine.studio.held.listItemNeedsInput', 'zh-CN')).not.toBe('engine.studio.held.listItemNeedsInput'); +}); From 47639fc4393f3898895da8d4e41ffc8019a4fd86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:32:26 +0000 Subject: [PATCH 3/5] fix(studio): hold a report whose new Order or Blocks item, or chart picked before its axes, is unfinished Claude-Session: https://claude.ai/code/session_01Utp6CaQABe2o9G1TyQ3LTx Co-authored-by: Claude --- .../studio-design/StudioDesignSurface.tsx | 14 +++- .../src/views/studio-design/metadataError.ts | 84 ++++++++++++++++++- ui/packages/i18n/src/namespaces/studio/en.ts | 3 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 9befa881905..064578278d4 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -261,6 +261,7 @@ import { objectSaveRefusal, plainRefusal, publishRefusal, + reportHeldEdit, type PublishFailure, type StudioHeld, type StudioRefusal, @@ -4835,11 +4836,16 @@ export function InterfacesPillar({ // objectstack#27459 — so is an item just added to one of the dashboard's // own list properties (Global Filters, the header's Actions), asked of the // spec's dashboard schema, until its required inputs are filled in. + // objectstack#27785 — a report likewise: an item just added to Order or a + // joined report's Blocks, or a chart whose type is picked before its axes, + // asked of the spec's report schema (`reportHeldEdit`). const leafType = current?.type; - const leafIncomplete = React.useMemo( - () => (ifDirty && leafType === 'dashboard' ? dashboardHeldEdit(draft, locale) : null), - [ifDirty, leafType, draft, locale], - ); + const leafIncomplete = React.useMemo(() => { + if (!ifDirty) return null; + if (leafType === 'dashboard') return dashboardHeldEdit(draft, locale); + if (leafType === 'report') return reportHeldEdit(draft, locale); + return null; + }, [ifDirty, leafType, draft, locale]); const { loaded: draftLoaded } = useDraftAutoSave({ // objectui#11232 — the leaf `doSave` addresses, `type:name`. target: leafKey, diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.ts index 9dba4746fc7..90e17158edd 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.ts @@ -28,12 +28,20 @@ import { import { FlowSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; import { FieldSchema } from '@objectstack/spec/data'; import { checkFieldCompleteness, FIELD_SUMMARY_WITHOUT_OPERATIONS } from '@objectstack/spec/kernel'; -import { ActionSchema, DashboardSchema, DashboardWidgetSchema, resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; +import { + ActionSchema, + DashboardSchema, + DashboardWidgetSchema, + ReportSchema, + resolveI18nLabel, + type I18nLabel, +} from '@objectstack/spec/ui'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; import type { NavTargetLabelResolver } from '@object-ui/layout'; import type { MetadataSelection } from '../metadata-admin/preview-registry.js'; import { t, tFormat, translateSchemaFieldLabel, translateValidationMessage } from '../metadata-admin/i18n.js'; import { getDashboardForm } from '../metadata-admin/dashboard-schema.js'; +import { getReportForm } from '../metadata-admin/report-schema.js'; import { prettifyFieldName } from '../metadata-admin/field-source-label.js'; import type { FormFieldSpec, FormViewSpec } from '../metadata-admin/form-spec.js'; import { @@ -1094,6 +1102,11 @@ export function dashboardHeldEdit(draft: SentBody, locale: string): StudioHeld = new Set(['widgets']); +/** A leaf's own spec schema, as the held rules ask it of a body. */ +type LeafSpecSchema = { + safeParse: (value: unknown) => { success: boolean; error?: { issues: ReadonlyArray<{ path: PropertyKey[] }> } }; +}; + /** An item of a leaf's list property held for an unset input. */ export interface HeldListItem { /** The path of the list property in the leaf body, e.g. `['header', 'actions']`. */ @@ -1116,7 +1129,7 @@ export interface HeldListItem { */ export function listItemHeldInput( body: SentBody, - schema: { safeParse: (value: unknown) => { success: boolean; error?: { issues: ReadonlyArray<{ path: PropertyKey[] }> } } }, + schema: LeafSpecSchema, skip: ReadonlySet = new Set(), ): HeldListItem | null { const parsed = schema.safeParse(body); @@ -1200,3 +1213,70 @@ export function heldListItem( input: list, }; } + +// --------------------------------------------------------------------------- +// objectstack#27785 — a report held until its new list item, or its chart, is +// filled in. +// +// The report's properties append the same unfinished items the dashboard's do +// (Order → "Add item", a joined report's Blocks → "Add item"), and the chart +// panel writes `chart: { type }` as soon as a chart type is picked, before its +// axes. The draft door refused each with a 422, and every later save with it. +// The same rule holds them: the judge is `ReportSchema`, asked of the body. +// +// The chart is an object property, not a list item, so it is asked on its own +// ({@link objectHeldInput}) rather than by widening {@link listItemHeldInput} +// to every nested object: that would change what the dashboard holds too, and +// the chart's inputs live in the inspector's own chart panel, not in the +// spec form whose rows name the list items. +// --------------------------------------------------------------------------- + +/** + * The first input of the object property `key` of `body` that `schema` + * refuses `body` for only because the object leaves it unset, or `null`. + * + * Only issues under `key` are this rule's. The object is held only when every + * one of them names an unset input directly below it; one issue on the object + * itself, or on an input it gives a value, makes it finished but wrong. + */ +export function objectHeldInput(body: SentBody, schema: LeafSpecSchema, key: string): string | null { + const object = body[key]; + if (!isSentBody(object)) return null; + const parsed = schema.safeParse(body); + if (parsed.success || !parsed.error) return null; + let held: string | null = null; + for (const issue of parsed.error.issues) { + if (String(issue.path[0]) !== key) continue; + const input = issue.path.length === 2 ? String(issue.path[1]) : undefined; + if (input === undefined || !itemInputUnset(object[input])) return null; + held ??= input; + } + return held; +} + +/** The report inspector's chart panel label for a chart input it edits. */ +const REPORT_CHART_INPUT_KEYS: Readonly> = { + xAxis: 'engine.inspector.report.chartX', + yAxis: 'engine.inspector.report.chartY', +}; + +/** + * The edit the Interfaces pillar holds for `draft`, the report its leaf + * autosave would send, or `null`: the first item of one of its list properties + * `ReportSchema` refuses only for inputs it leaves unset, named by the report + * form's labels; else its chart, refused only for unset inputs, named by the + * chart panel's own label. Both are placed on the report's properties. + */ +export function reportHeldEdit(draft: SentBody, locale: string): StudioHeld | null { + const item = listItemHeldInput(draft, ReportSchema); + if (item) return heldListItem(item, getReportForm(), locale); + const key = objectHeldInput(draft, ReportSchema, 'chart'); + if (key === null) return null; + const labelKey = REPORT_CHART_INPUT_KEYS[key]; + const input = labelKey ? t(labelKey, locale) : prettifyFieldName(key); + return { + clause: tFormat('engine.studio.held.reportChartNeedsInput', locale, { input }), + target: null, + input, + }; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index a5a65380516..f635fda13e0 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3015,6 +3015,9 @@ const STUDIO_EN: Record = { // objectstack#27459 — an item of a dashboard list property (Global Filters, // the header's Actions) held until it is filled in; `n` is the `#n` its row shows. 'engine.studio.held.listItemNeedsInput': '{input} on item #{n} of “{list}” needs a value', + // objectstack#27785 — a report's chart held until its axes are picked; `input` + // is the chart panel's own label for the axis it still needs. + 'engine.studio.held.reportChartNeedsInput': '{input} on the report’s chart needs a value', // objectstack#27461 — a roll-up held until it is set up; `input` is the field // inspector's own label for the roll-up input it still needs. 'engine.studio.held.fieldNeedsInput': '{input} on the field “{field}” needs a value', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index adf8be2a13a..5ab84404ca0 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2683,6 +2683,8 @@ const STUDIO_ZH: Record = { 'engine.studio.held.widgetNeedsInput': '组件「{widget}」的「{input}」需要填写', // objectstack#27459 — a dashboard list property's item held until it is filled in. 'engine.studio.held.listItemNeedsInput': '「{list}」第 {n} 项的「{input}」需要填写', + // objectstack#27785 — a report's chart held until its axes are picked. + 'engine.studio.held.reportChartNeedsInput': '报表图表的「{input}」需要填写', 'engine.studio.held.fieldNeedsInput': '字段「{field}」的「{input}」需要填写', // objectstack#27464 — a navigation rename typed but not committed yet. 'engine.studio.held.navRename': '导航项「{item}」的重命名尚未完成', From 4dfb2013bd26f35a9ef9148edaed850960055d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:35:12 +0000 Subject: [PATCH 4/5] test(studio): pin the held report list item and chart, and add the changeset Claude-Session: https://claude.ai/code/session_01Utp6CaQABe2o9G1TyQ3LTx Co-authored-by: Claude --- .changeset/studio-report-held-edit.md | 13 + ...InterfacesPillar.reportHeld-27785.test.tsx | 292 ++++++++++++++++++ .../metadataError.reportHeld-27785.test.ts | 174 +++++++++++ 3 files changed, 479 insertions(+) create mode 100644 .changeset/studio-report-held-edit.md create mode 100644 ui/packages/app-shell/src/views/studio-design/InterfacesPillar.reportHeld-27785.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts diff --git a/.changeset/studio-report-held-edit.md b/.changeset/studio-report-held-edit.md new file mode 100644 index 00000000000..ac48ea2658d --- /dev/null +++ b/.changeset/studio-report-held-edit.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): a report's new Order or Blocks item, or a chart type picked before its axes, is held until it is filled in, instead of being autosaved and refused + +Clause-②: no +Metadata-protocol: none + +In the Studio's Interfaces pillar, a report's properties used to autosave an unfinished edit: "Add item" under **Order** sent an empty sort key, "Add item" under a joined report's **Blocks** sent an empty block, and picking a **Chart type** before its X and Y axes sent a chart without them. The draft door refused each (422), and every later save of the report carried the same body and was refused too, so an edit made afterwards was lost on reload. + +Such a report is now held the way a dashboard's unfinished list item is: nothing is sent while the report's own schema refuses the item, or the chart, only for inputs it leaves unset. A neutral line names what it needs (for example "Order By on item #1 of “Order” needs a value", or "X-Axis (dimension) on the report’s chart needs a value") with a **Show me** that opens the report's properties at that input, and Publish refuses while it is held. Once the input is filled in, the next autosave sends every edit kept meanwhile. An item or a chart refused for a value it holds is not held: it is sent, and the refusal it draws shows as before. diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.reportHeld-27785.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.reportHeld-27785.test.tsx new file mode 100644 index 00000000000..e082d8a6bf4 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.reportHeld-27785.test.tsx @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27785 — the Interfaces pillar holds a report unsent while an item + * just added to one of its list properties, or a chart whose type was picked + * before its axes, is unfinished, instead of autosaving it for the draft door + * to refuse. + * + * The card: Order → "Add item" sent `order: [{}]` (422 `order.0.by`), a joined + * report's Blocks → "Add item" sent `blocks: [{}]` (422), and a chart type + * picked first sent `chart: { type }` (422 `chart.xAxis` / `chart.yAxis`). + * Every later save carried the same body and was refused too. The pillar now + * holds the report as it holds the dashboard's list items (objectstack#27459): + * nothing is sent, a neutral line names the input with a "Show me", and + * filling it in lets the next autosave send every edit kept meanwhile. + * + * Mounted with a metadata client double that records every save, a stub + * canvas and the real `ReportDefaultInspector` (its spec-driven form and its + * own chart panel). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ReportSchema } from '@objectstack/spec/ui'; + +const NAV = [{ id: 'nav_rep', type: 'report', label: 'Sales report', reportName: 'sales_report' }]; + +const SUMMARY_REPORT = { + name: 'sales_report', + label: 'Sales report', + type: 'summary', + dataset: 'sales_ds', + rows: ['stage'], + values: ['revenue'], +}; + +const JOINED_REPORT = { + name: 'sales_report', + label: 'Sales report', + type: 'joined', + blocks: [{ name: 'won', dataset: 'sales_ds' }], +}; + +const DATASETS = [ + { + name: 'sales_ds', + label: 'Sales', + object: 'opportunity', + dimensions: [{ name: 'stage', label: 'Stage' }], + measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum' }], + }, +]; + +/** The report the pillar opens, as served: it parses with the spec. */ +let stored: Record = SUMMARY_REPORT; + +const mockClient = { + withPreviewDrafts: vi.fn((): unknown => mockClient), + save: vi.fn(async (_type: string, _name: string, _body: Record) => ({ + success: true, + version: 'v1', + state: 'draft', + })), + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }]; + if (type === 'dataset') return DATASETS; + if (type === 'report') return [stored]; + return []; + }), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (type: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', active: true, navigation: NAV } }; + if (type === 'report') return { effective: JSON.parse(JSON.stringify(stored)) }; + return { code: null, overlay: null, overlayScope: null, effective: null }; + }), + getDraft: vi.fn(async () => null), + get: vi.fn(async (type: string, name: string) => + type === 'dataset' ? DATASETS.find((d) => d.name === name) : undefined, + ), +}; + +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: () => ({}) }; +}); + +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; +import { ReportDefaultInspector } from '../metadata-admin/inspectors/ReportDefaultInspector'; +import { t, tFormat } from '../metadata-admin/i18n'; + +// A stub canvas: the leaf needs a registered preview to be draft-editable, +// and the canvas's own drawing is not what this file pins. +registerMetadataPreview('report', () => null); +registerMetadataDefaultInspector('report', ReportDefaultInspector); + +beforeEach(() => { + stored = SUMMARY_REPORT; + for (const fn of Object.values(mockClient)) fn.mockClear(); +}); +afterEach(cleanup); + +const L = (key: string) => t(key, 'en'); + +function mountPillar(onHeldEditChange?: (clause: string | null) => void) { + return render( + + + , + ); +} + +function reportSaves(): Array> { + return mockClient.save.mock.calls.filter((c) => c[0] === 'report').map((c) => c[2]); +} + +/** Past the autosave's 1.5 s debounce. */ +async function outlastDebounce(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); +} + +/** The report's properties are open, with the list property labelled `list` drawn. */ +async function ready(list: string): Promise { + const inspector = await screen.findByTestId('if-inspector', undefined, { timeout: 8000 }); + await within(inspector).findByText(list, { selector: 'label' }, { timeout: 8000 }); + return inspector; +} + +/** The form row of the list property labelled `label`: its label and its repeater. */ +function listRow(inspector: HTMLElement, label: string): HTMLElement { + const row = within(inspector).getByText(label, { selector: 'label' }).parentElement?.parentElement; + if (!row) throw new Error(`no form row for ${label}`); + return row; +} + +/** The report's own Label input. */ +function editLabel(inspector: HTMLElement, value: string): void { + const input = within(inspector).getByLabelText(L('engine.inspector.report.label')); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); +} + +/** Picks `option` in the inspector select labelled `label`. */ +async function pick(inspector: HTMLElement, label: string, option: string | RegExp): Promise { + fireEvent.keyDown(within(inspector).getByRole('combobox', { name: label }), { key: 'ArrowDown' }); + await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0)); + fireEvent.click(screen.getByRole('option', { name: option })); +} + +const orderClause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Order By', n: 1, list: 'Order' }); +const heldLine = (clause: string) => tFormat('engine.studio.held.line', 'en', { clause }); + +describe('an item just added to a report list property is held until it is filled in (objectstack#27785)', () => { + it('Order → Add item, then a Label edit: nothing is sent, no red strip, the line names the Order By', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready('Order'); + fireEvent.click(within(listRow(inspector, 'Order')).getByRole('button', { name: L('engine.form.addItem') })); + editLabel(inspector, 'Sales v2'); + await outlastDebounce(); + + expect(reportSaves(), 'the unfinished item is not sent, nor the Label edit with it').toEqual([]); + expect(screen.queryByTestId('studio-refusal'), 'no red strip on the normal path').toBeNull(); + expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(orderClause)); + // Publish's half: the surface is told what is held. + expect(report).toHaveBeenLastCalledWith(orderClause); + }); + + it('"Show me" focuses the list in the report properties', async () => { + mountPillar(); + const inspector = await ready('Order'); + const row = listRow(inspector, 'Order'); + fireEvent.click(within(row).getByRole('button', { name: L('engine.form.addItem') })); + const show = await within(await screen.findByTestId('studio-held')).findByRole('button', { + name: L('engine.studio.refusal.show'), + }); + (document.activeElement as HTMLElement | null)?.blur(); + fireEvent.click(show); + await waitFor(() => expect(row.contains(document.activeElement)).toBe(true)); + }); + + it('filling the Order By in sends one save carrying the item and the Label edit kept meanwhile', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready('Order'); + const row = listRow(inspector, 'Order'); + fireEvent.click(within(row).getByRole('button', { name: L('engine.form.addItem') })); + editLabel(inspector, 'Sales v2'); + await outlastDebounce(); + expect(reportSaves()).toEqual([]); + + const by = within(row).getByLabelText('Order By'); + fireEvent.change(by, { target: { value: 'stage' } }); + fireEvent.blur(by); + await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 }); + const [sent] = reportSaves(); + expect(sent.label).toBe('Sales v2'); + expect(sent.order).toEqual([expect.objectContaining({ by: 'stage' })]); + expect( + ReportSchema.safeParse(JSON.parse(JSON.stringify(sent))).success, + 'the body sent is one the door accepts', + ).toBe(true); + await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull()); + expect(report).toHaveBeenLastCalledWith(null); + }); + + it('a joined report’s Blocks → Add item: nothing is sent, the line names the block’s Name', async () => { + stored = JOINED_REPORT; + const report = vi.fn(); + mountPillar(report); + const inspector = await ready('Blocks'); + fireEvent.click(within(listRow(inspector, 'Blocks')).getByRole('button', { name: L('engine.form.addItem') })); + await outlastDebounce(); + + const clause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Name', n: 2, list: 'Blocks' }); + expect(reportSaves()).toEqual([]); + expect(screen.queryByTestId('studio-refusal')).toBeNull(); + expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(clause)); + expect(report).toHaveBeenLastCalledWith(clause); + }); + + // CONTROL: an edit to a report with nothing unfinished saves at once. + it('a Label edit alone saves at once, with no line', async () => { + mountPillar(); + const inspector = await ready('Order'); + editLabel(inspector, 'Sales v2'); + await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 }); + expect(reportSaves()[0].label).toBe('Sales v2'); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); +}); + +describe('a chart type picked before its axes is held until both axes are picked (objectstack#27785)', () => { + const x = L('engine.inspector.report.chartX'); + const y = L('engine.inspector.report.chartY'); + + it('nothing is sent while an axis is unset; the line names it, and "Show me" focuses its picker', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready('Order'); + await pick(inspector, L('engine.inspector.report.chartType'), 'bar'); + await outlastDebounce(); + + const clauseX = tFormat('engine.studio.held.reportChartNeedsInput', 'en', { input: x }); + expect(reportSaves(), 'the chart without axes is not sent').toEqual([]); + expect(screen.queryByTestId('studio-refusal')).toBeNull(); + expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(clauseX)); + expect(report).toHaveBeenLastCalledWith(clauseX); + + (document.activeElement as HTMLElement | null)?.blur(); + fireEvent.click(within(screen.getByTestId('studio-held')).getByRole('button', { name: L('engine.studio.refusal.show') })); + await waitFor(() => expect(document.activeElement).toBe(within(inspector).getByRole('combobox', { name: x }))); + + await pick(inspector, x, 'Stage (stage)'); + await outlastDebounce(); + expect(reportSaves(), 'one axis is not enough').toEqual([]); + expect(screen.getByTestId('studio-held-message')).toHaveTextContent( + heldLine(tFormat('engine.studio.held.reportChartNeedsInput', 'en', { input: y })), + ); + }); + + it('both axes picked: one save carrying the whole chart, and the line clears', async () => { + const report = vi.fn(); + mountPillar(report); + const inspector = await ready('Order'); + await pick(inspector, L('engine.inspector.report.chartType'), 'bar'); + await pick(inspector, x, 'Stage (stage)'); + await pick(inspector, y, /^Revenue/); + await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 }); + const [sent] = reportSaves(); + expect(sent.chart).toEqual(expect.objectContaining({ type: 'bar', xAxis: 'stage', yAxis: 'revenue' })); + expect(ReportSchema.safeParse(JSON.parse(JSON.stringify(sent))).success).toBe(true); + await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull()); + expect(report).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts new file mode 100644 index 00000000000..849680acec5 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27785 — which reports the Interfaces pillar's leaf autosave HOLDS + * for an unfinished item of one of its list properties, or an unfinished chart. + * + * The card: on a report's properties, Order → "Add item" autosaved + * `order: [{}]` and the draft door answered 422 on `order.0.by`; a joined + * report's Blocks → "Add item" did the same on `blocks: [{}]`; and picking a + * chart type before its axes sent `chart: { type }`, refused on `chart.xAxis` + * and `chart.yAxis`. Every later save carried the same body and was refused too. + * + * Every "held" case is paired with the spec's own `ReportSchema` verdict on the + * same report (it refuses it, at exactly the unset inputs), and every "not + * held" control with a report that judge accepts, or refuses for a value the + * author wrote: the hold is never stricter than the contract, and never hides + * finished but wrong work. The pillar that acts on it is pinned in + * `InterfacesPillar.reportHeld-27785.test.tsx`. + */ + +import { describe, expect, it } from 'vitest'; +import { ReportSchema } from '@objectstack/spec/ui'; +import { objectHeldInput, reportHeldEdit } from './metadataError'; +import { t, tFormat } from '../metadata-admin/i18n'; + +/** The issue paths the spec's report parse refuses `report` at, as the door reports them. */ +function reportRefusals(report: Record): string[] { + const result = ReportSchema.safeParse(report); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +} + +const summary = (extra: Record) => ({ + name: 'pipeline_report', + label: 'Pipeline', + type: 'summary', + dataset: 'sales_ds', + rows: ['stage'], + values: ['revenue'], + ...extra, +}); +const joined = (extra: Record) => ({ name: 'pipeline_joined', label: 'Pipeline', type: 'joined', ...extra }); + +/** An item as the properties panel's repeater "Add item" builds it: every row input present and unset. */ +function blank(...inputs: string[]): Record { + return Object.fromEntries(inputs.map((input) => [input, undefined])); +} +const ORDER_INPUTS = ['by', 'direction']; +const BLOCK_INPUTS = ['name', 'label', 'description', 'type', 'dataset', 'rows', 'columns', 'values', 'runtimeFilter', 'order']; + +const listClause = (input: string, n: number, list: string, locale = 'en') => + tFormat('engine.studio.held.listItemNeedsInput', locale, { input, n, list }); +const chartClause = (input: string, locale = 'en') => + tFormat('engine.studio.held.reportChartNeedsInput', locale, { input }); + +describe('the Interfaces pillar holds a report list item the spec refuses only for unset inputs (objectstack#27785)', () => { + it('Order → Add item: held for its Order By, as the door refuses it, placed on the report properties', () => { + const body = summary({ order: [blank(...ORDER_INPUTS)] }); + expect(reportRefusals(body)).toEqual(['order.0.by']); + expect(reportHeldEdit(body, 'en')).toEqual({ + clause: listClause('Order By', 1, 'Order'), + target: null, + input: 'Order', + }); + }); + + it('the stored `{}` the card saw is held the same way', () => { + const body = summary({ order: [{ by: 'stage' }, {}] }); + expect(reportRefusals(body)).toEqual(['order.1.by']); + expect(reportHeldEdit(body, 'en')?.clause).toBe(listClause('Order By', 2, 'Order')); + }); + + it('joined Blocks → Add item: held for the first input it needs, then for its Dataset once named', () => { + const body = joined({ blocks: [blank(...BLOCK_INPUTS)] }); + expect(reportRefusals(body)).toEqual(['blocks.0.name']); + expect(reportHeldEdit(body, 'en')).toEqual({ + clause: listClause('Name', 1, 'Blocks'), + target: null, + input: 'Blocks', + }); + const named = joined({ blocks: [{ ...blank(...BLOCK_INPUTS), name: 'won' }] }); + expect(reportRefusals(named)).toEqual(['blocks.0.dataset']); + expect(reportHeldEdit(named, 'en')?.clause).toBe(listClause('Dataset', 1, 'Blocks')); + }); + + // CONTROL: the hold is never stricter than the contract. + it('an Order By filled, or a block named and bound: not held, and the spec accepts it', () => { + const ordered = summary({ order: [{ ...blank(...ORDER_INPUTS), by: 'stage' }] }); + expect(reportRefusals(ordered)).toEqual([]); + expect(reportHeldEdit(ordered, 'en')).toBeNull(); + const bound = joined({ blocks: [{ ...blank(...BLOCK_INPUTS), name: 'won', dataset: 'sales_ds' }] }); + expect(reportRefusals(bound)).toEqual([]); + expect(reportHeldEdit(bound, 'en')).toBeNull(); + }); + + // CONTROL: errors stay for finished work. + it('an Order By naming something the report does not select is finished but wrong: not held', () => { + const body = summary({ order: [{ by: 'region' }] }); + expect(reportRefusals(body)).toEqual(['order']); + expect(reportHeldEdit(body, 'en')).toBeNull(); + }); + + it('a block with an unset input and a wrong value the spec names both of is not held', () => { + const body = joined({ blocks: [{ name: 'Won Deals' }] }); + expect(reportRefusals(body)).toEqual(['blocks.0.name', 'blocks.0.dataset']); + expect(reportHeldEdit(body, 'en')).toBeNull(); + }); + + it('names the list and the item in the designer locale', () => { + const body = summary({ order: [{}] }); + const held = reportHeldEdit(body, 'zh-CN'); + expect(held?.clause).toBe(listClause('Order By', 1, held?.input ?? '', 'zh-CN')); + expect(held?.target).toBeNull(); + }); +}); + +describe('the Interfaces pillar holds a report chart the spec refuses only for unset axes (objectstack#27785)', () => { + const x = t('engine.inspector.report.chartX', 'en'); + const y = t('engine.inspector.report.chartY', 'en'); + + it('a chart type picked before its axes: held for the X axis, named by the chart panel’s own label', () => { + const body = summary({ chart: { type: 'bar' } }); + expect(reportRefusals(body)).toEqual(['chart.xAxis', 'chart.yAxis']); + expect(reportHeldEdit(body, 'en')).toEqual({ clause: chartClause(x), target: null, input: x }); + }); + + it('the X axis picked: held for the Y axis', () => { + const body = summary({ chart: { type: 'bar', xAxis: 'stage' } }); + expect(reportRefusals(body)).toEqual(['chart.yAxis']); + expect(reportHeldEdit(body, 'en')).toEqual({ clause: chartClause(y), target: null, input: y }); + }); + + it('names the axis in the designer locale', () => { + const body = summary({ chart: { type: 'bar' } }); + const zx = t('engine.inspector.report.chartX', 'zh-CN'); + expect(reportHeldEdit(body, 'zh-CN')).toEqual({ clause: chartClause(zx, 'zh-CN'), target: null, input: zx }); + }); + + // CONTROL: the hold is never stricter than the contract. + it('both axes picked: not held, and the spec accepts it', () => { + const body = summary({ chart: { type: 'bar', xAxis: 'stage', yAxis: 'revenue' } }); + expect(reportRefusals(body)).toEqual([]); + expect(reportHeldEdit(body, 'en')).toBeNull(); + }); + + // CONTROL: errors stay for finished work. + it('a chart refused for a value it holds is finished but wrong: not held', () => { + const body = summary({ chart: { type: 'nope' } }); + expect(reportRefusals(body)).toEqual(['chart.type', 'chart.xAxis', 'chart.yAxis']); + expect(reportHeldEdit(body, 'en')).toBeNull(); + }); + + it('an unfinished list item is named before the chart', () => { + const body = summary({ order: [{}], chart: { type: 'bar' } }); + expect(reportHeldEdit(body, 'en')?.clause).toBe(listClause('Order By', 1, 'Order')); + }); + + it('a report with no chart, or one the spec accepts, holds nothing', () => { + expect(objectHeldInput(summary({}), ReportSchema, 'chart')).toBeNull(); + expect(reportRefusals(summary({}))).toEqual([]); + expect(reportHeldEdit(summary({}), 'en')).toBeNull(); + }); + + it('`objectHeldInput` asks only the issues under its key: a refusal elsewhere is not the chart’s', () => { + const body = { ...summary({ chart: { type: 'bar', xAxis: 'stage' } }), name: '' }; + expect(reportRefusals(body)).toEqual(['name', 'chart.yAxis']); + expect(objectHeldInput(body, ReportSchema, 'chart')).toBe('yAxis'); + }); +}); + +// Keeps `t` honest: the clause key exists in both catalogs. +it('the held chart clause key is catalogued in en and zh', () => { + expect(t('engine.studio.held.reportChartNeedsInput', 'en')).not.toBe('engine.studio.held.reportChartNeedsInput'); + expect(t('engine.studio.held.reportChartNeedsInput', 'zh-CN')).not.toBe('engine.studio.held.reportChartNeedsInput'); +}); From dae67768d7d337e74a421538083718675925f3f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:38:22 +0000 Subject: [PATCH 5/5] test(studio): an empty report name draws two issues Claude-Session: https://claude.ai/code/session_01Utp6CaQABe2o9G1TyQ3LTx Co-authored-by: Claude --- .../views/studio-design/metadataError.reportHeld-27785.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts index 849680acec5..48af0e74e4a 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts @@ -162,7 +162,7 @@ describe('the Interfaces pillar holds a report chart the spec refuses only for u it('`objectHeldInput` asks only the issues under its key: a refusal elsewhere is not the chart’s', () => { const body = { ...summary({ chart: { type: 'bar', xAxis: 'stage' } }), name: '' }; - expect(reportRefusals(body)).toEqual(['name', 'chart.yAxis']); + expect(reportRefusals(body)).toEqual(['name', 'name', 'chart.yAxis']); expect(objectHeldInput(body, ReportSchema, 'chart')).toBe('yAxis'); }); });