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/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.reportHeld-27785.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts new file mode 100644 index 00000000000..48af0e74e4a --- /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', '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'); +}); 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}」的重命名尚未完成',