Skip to content

Commit 165bc50

Browse files
fix(studio): hold a report whose new Order or Blocks item, or a chart picked before its axes, is unfinished (#27824)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:Studio 界面支柱里,报表属性面板「排序(Order)」或联合报表「Blocks」点「Add item」后、以及先选了图表类型还没选 X/Y 轴时,草稿不再自动保存;画布上方出现一行中性提示,点名还缺的输入(如 “Order By on item #1 of “Order” needs a value”、“X-Axis (dimension) on the report’s chart needs a value”),带「Show me」打开报表属性并定位到该列表或该轴的选择框,发布在暂缓期间拒绝;填好后下一次自动保存把期间所有改动一起发出。 - 为什么改:以前这三种半成品立刻被 PUT 出去(`order:[{}]`、`blocks:[{}]`、`chart:{type}`),草稿门 422;之后每次保存都带着它继续 422,期间改的其他内容重载后全丢。仪表盘同类问题已由 #27784 修好,这张卡把同一规则用到报表上。 - 风险与回滚:只改前端的暂缓判定,判定问 spec 自己的 `ReportSchema`,不另存必填清单;只有「所有报错都落在还没填的输入上」才暂缓,填了错值的(如排序字段不在报表里、图表类型非法)照常发送并显示红色拒绝条。最坏情况是多暂缓一次(提示行 + Show me 可解)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/studio-design/metadataError.ts`(`reportHeldEdit` / `objectHeldInput`)、`StudioDesignSurface.tsx`(界面支柱 `leafIncomplete` 加报表分支)、`InterfacesPillar.reportHeld-27785.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/metadataError.reportHeld-27785.test.ts packages/app-shell/src/views/studio-design/InterfacesPillar.reportHeld-27785.test.tsx`,预期 2 个文件 23 条全部通过。 - 需要你做的:无,席位落地。 Fixes #27785 Clause-②: no Metadata-protocol: none ## What changes **Built on PR #27784.** This branch started from #27784's head `7ff9cb5c` while it was in the merge queue, reusing its `listItemHeldInput` / `heldListItem`, and merged `origin/main` after #27784 landed (head `93747963`). The diff against `main` is this card's 7 files only. **Measured first, on the spec this branch builds** (`ReportSchema.safeParse`, `packages/spec/dist`), all three cases of the card reproduce: | Case | Body the autosave sent | `ReportSchema` refuses at | |:--|:--|:--| | Order → Add item | `order: [{}]` | `order.0.by` | | joined Blocks → Add item | `blocks: [{}]` | `blocks.0.name`, then `blocks.0.dataset` once named | | chart type before axes | `chart: { type: 'bar' }` | `chart.xAxis`, `chart.yAxis` | On the pillar, the gate was `leafType === 'dashboard'`, so a report was never held: with the new report branch removed (the ablation below) the pillar sends the `order: [{}]` body the card saw. **The hold.** `reportHeldEdit(draft, locale)` (in `metadataError.ts`, beside `dashboardHeldEdit`) is the report's leaf rule, and the Interfaces pillar's `leafIncomplete` now asks it for a `report` leaf: - **List items** (Order, joined Blocks): `listItemHeldInput(draft, ReportSchema)` from #27784, unchanged, with the report form (`getReportForm()`) for the labels. The line reads "Order By on item #1 of “Order” needs a value" with the existing `engine.studio.held.listItemNeedsInput` clause. - **The chart, held on its own** (`objectHeldInput(body, schema, key)`, asked for `chart`). Only issues under `chart` are this rule's; the chart is held only when every one of them names an input directly below it that the chart leaves unset. A chart refused for a value it holds (an unknown `type`) is sent and its refusal shows as before. - Why not generalise `listItemHeldInput` to every nested object: that would change what the dashboard holds too (its `header`, `dateRange` and other nested objects), with no measurement behind it; and the chart's inputs live in the report inspector's own chart panel, not in the spec form whose rows `heldListItem` reads its labels from. - The axis is named by the chart panel's own label (`engine.inspector.report.chartX` / `chartY`), the text "Show me" then focuses. That table names labels only; which inputs are required is still `ReportSchema`'s answer. One new clause, `engine.studio.held.reportChartNeedsInput` (en, zh), because `listItemNeedsInput` names an item number and a list a chart does not have. - Both are placed on the report's own properties (`target: null`), so "Show me" goes through the pillar's existing `showLeafRefusal`, as the dashboard's list items do. A list item is named before the chart when both are unfinished. The autosave block, the Publish refusal and the leave guard read the same `leafIncomplete` / reported clause as before, unchanged. ## Tests - New `metadataError.reportHeld-27785.test.ts` (16 cases). Every held case is paired with `ReportSchema`'s verdict on the same report: Order's new item (the repeater's all-`undefined` row and the stored `{}`), joined Blocks' new item (held for Name, then for Dataset), a chart with only its type (held for the X axis), then with only its X axis (held for Y), zh-CN naming. Controls: Order By filled, a block named and bound, both axes picked are accepted and not held; an Order By naming something the report does not select, a block with an unset Dataset and a malformed Name, and a chart with an unknown type are sent; a report with no chart holds nothing; `objectHeldInput` ignores a refusal outside its key. - New `InterfacesPillar.reportHeld-27785.test.tsx` (7 cases), the real `ReportDefaultInspector` over a recording metadata client: Order → Add item, then a Label edit sends no PUT past the debounce, shows no red strip, names the Order By, and reports the clause to Publish; "Show me" focuses a control in the Order row; filling Order By sends one save carrying the item and the Label edit, which `ReportSchema` accepts; joined Blocks → Add item is held for the block's Name; a chart type picked first is held for the X axis, "Show me" focuses the X-axis picker, the X axis alone is still held for Y, and both axes send one save with the whole chart; control: a Label edit alone saves at once. Readings below are on the final head `93747963` (this branch merged with `origin/main` after #27784 landed) unless a line says otherwise. <details><summary>Ablations (ablation-replace, restored blob == HEAD, pre-merge head dae6776)</summary> - `StudioDesignSurface.tsx`: the pillar's new `if (leafType === 'report') return reportHeldEdit(draft, locale);` line deleted (the gate as it was, dashboard only) → 5 failed / 7 in `InterfacesPillar.reportHeld-27785`: every case that needs a hold. The first one shows the body the card saw going out: `expected [ { name: 'sales_report', … "order": [ … ] } ] to deeply equal []`. The control (a Label edit alone saves at once) and "both axes picked: one save" stay green. Restored: blob `064578278d42` == HEAD, `git diff HEAD` empty. - `metadataError.ts`: in `objectHeldInput`, `if (input === undefined || !itemInputUnset(object[input])) return null;` replaced by `if (input === undefined) return null;` (a chart input holding a value no longer makes it finished but wrong) → 1 failed / 16 in `metadataError.reportHeld-27785`: "a chart refused for a value it holds is finished but wrong: not held". Restored: blob `90e17158edd5` == HEAD, `git diff HEAD` empty. - app-shell tests import `src` directly, so no dist build sits between the mutation and the run. </details> <details><summary>Affected suites: 120 files / 1631 tests passed (before and after the merge)</summary> Every app-shell test file naming `metadataError`, `reportHeldEdit`, `dashboardHeldEdit`, `StudioHeld`, `ReportDefaultInspector` or `held.listItem` (30 files, the two new ones included), plus the whole `packages/i18n/` directory, under the verification lock: `Test Files 120 passed (120) · Tests 1631 passed | 13 skipped (1644)`, lock VERDICT command-exit 0, on `dae67768` and again on `93747963`. The rest of the ui tree is left to CI `Test UI`. ``` pnpm --filter '@object-ui/app-shell^...' build :: exit 0 (9374796, after the merge) pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check :: exit 0 (9374796) node ui/scripts/check-new-cross-file-line-citations.mjs :: exit 0 (0 new) ``` </details> ## Acceptance notes - **The chart is held by its own rule, not by generalising the list-item rule** (the card left this to the implementer). Reasons in What changes: a general nested-object rule would also change what the dashboard holds, unmeasured, and the chart's inputs are named by the inspector's chart panel, not by spec form rows. - **A joined report with a chart type set before its axes is held for the axes, not refused for the chart.** `ReportSchema` judges a joined container's `chart` only once the chart parses, so it names the unset axes first. The report inspector hides the chart panel on a joined report and clears `chart` when the type switches to joined, so the Studio cannot produce this body; noted, not changed. - **An "Add item" inside a block's own `order` is not held.** `listItemHeldInput` places an issue on the outermost list item (the block), whose `order` input holds a value, so the block counts as finished but wrong and is sent. Whether the block repeater's Order column can append an empty nested sort key was not measured here; noted as the boundary of #27784's rule, not filed. - **"Show me" and collapsed sections.** The report form's Order row (Dataset binding) and Blocks row (Joined blocks) sit in sections that are not collapsed by default, and the chart panel is never collapsed, so "Show me" reaches every input this card holds. The dashboard's residual (its Filters section collapsed by default) is untouched: expanding a collapsed SchemaForm section from "Show me" is not the same code. - An Order By typed in but naming something the report does not select is sent, not held: the refusal names the value the author wrote (`order key "…" is not selected by this report`). <details><summary>Derived gates (dispatch-gates --commands on this diff, head 9374796): 34 / 34 exit 0</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `dispatch-gates --ran`: 34 derived families accounted for, 34 run, 0 NOT-MEASURED. `node scripts/check-sdui-manifest.mjs` (the ui card gate) is among them. The same 34 also ran 34 / 34 exit 0 on the pre-merge head `dae67768`. CI runs the type-check lanes, the artifact-roster and wide-population families and `Test UI`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaQABe2o9G1TyQ3LTx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b85162 commit 165bc50

7 files changed

Lines changed: 576 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
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.
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27785 — the Interfaces pillar holds a report unsent while an item
5+
* just added to one of its list properties, or a chart whose type was picked
6+
* before its axes, is unfinished, instead of autosaving it for the draft door
7+
* to refuse.
8+
*
9+
* The card: Order → "Add item" sent `order: [{}]` (422 `order.0.by`), a joined
10+
* report's Blocks → "Add item" sent `blocks: [{}]` (422), and a chart type
11+
* picked first sent `chart: { type }` (422 `chart.xAxis` / `chart.yAxis`).
12+
* Every later save carried the same body and was refused too. The pillar now
13+
* holds the report as it holds the dashboard's list items (objectstack#27459):
14+
* nothing is sent, a neutral line names the input with a "Show me", and
15+
* filling it in lets the next autosave send every edit kept meanwhile.
16+
*
17+
* Mounted with a metadata client double that records every save, a stub
18+
* canvas and the real `ReportDefaultInspector` (its spec-driven form and its
19+
* own chart panel).
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import * as React from 'react';
24+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
25+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
26+
import { MemoryRouter } from 'react-router-dom';
27+
import { ReportSchema } from '@objectstack/spec/ui';
28+
29+
const NAV = [{ id: 'nav_rep', type: 'report', label: 'Sales report', reportName: 'sales_report' }];
30+
31+
const SUMMARY_REPORT = {
32+
name: 'sales_report',
33+
label: 'Sales report',
34+
type: 'summary',
35+
dataset: 'sales_ds',
36+
rows: ['stage'],
37+
values: ['revenue'],
38+
};
39+
40+
const JOINED_REPORT = {
41+
name: 'sales_report',
42+
label: 'Sales report',
43+
type: 'joined',
44+
blocks: [{ name: 'won', dataset: 'sales_ds' }],
45+
};
46+
47+
const DATASETS = [
48+
{
49+
name: 'sales_ds',
50+
label: 'Sales',
51+
object: 'opportunity',
52+
dimensions: [{ name: 'stage', label: 'Stage' }],
53+
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum' }],
54+
},
55+
];
56+
57+
/** The report the pillar opens, as served: it parses with the spec. */
58+
let stored: Record<string, unknown> = SUMMARY_REPORT;
59+
60+
const mockClient = {
61+
withPreviewDrafts: vi.fn((): unknown => mockClient),
62+
save: vi.fn(async (_type: string, _name: string, _body: Record<string, unknown>) => ({
63+
success: true,
64+
version: 'v1',
65+
state: 'draft',
66+
})),
67+
list: vi.fn(async (type: string) => {
68+
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
69+
if (type === 'dataset') return DATASETS;
70+
if (type === 'report') return [stored];
71+
return [];
72+
}),
73+
listDrafts: vi.fn(async () => []),
74+
layered: vi.fn(async (type: string) => {
75+
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', active: true, navigation: NAV } };
76+
if (type === 'report') return { effective: JSON.parse(JSON.stringify(stored)) };
77+
return { code: null, overlay: null, overlayScope: null, effective: null };
78+
}),
79+
getDraft: vi.fn(async () => null),
80+
get: vi.fn(async (type: string, name: string) =>
81+
type === 'dataset' ? DATASETS.find((d) => d.name === name) : undefined,
82+
),
83+
};
84+
85+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
86+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
87+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
88+
});
89+
90+
vi.mock('./packages-io', async (importOriginal) => {
91+
const mod = await importOriginal<typeof import('./packages-io')>();
92+
return { ...mod, fetchPackages: vi.fn(async () => []) };
93+
});
94+
95+
vi.mock('@object-ui/react', async (importOriginal) => {
96+
const mod = await importOriginal<typeof import('@object-ui/react')>();
97+
return { ...mod, useAdapter: () => ({}) };
98+
});
99+
100+
import { InterfacesPillar } from './StudioDesignSurface';
101+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
102+
import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry';
103+
import { ReportDefaultInspector } from '../metadata-admin/inspectors/ReportDefaultInspector';
104+
import { t, tFormat } from '../metadata-admin/i18n';
105+
106+
// A stub canvas: the leaf needs a registered preview to be draft-editable,
107+
// and the canvas's own drawing is not what this file pins.
108+
registerMetadataPreview('report', () => null);
109+
registerMetadataDefaultInspector('report', ReportDefaultInspector);
110+
111+
beforeEach(() => {
112+
stored = SUMMARY_REPORT;
113+
for (const fn of Object.values(mockClient)) fn.mockClear();
114+
});
115+
afterEach(cleanup);
116+
117+
const L = (key: string) => t(key, 'en');
118+
119+
function mountPillar(onHeldEditChange?: (clause: string | null) => void) {
120+
return render(
121+
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
122+
<InterfacesPillar packageId="com.acme.app" onHeldEditChange={onHeldEditChange} />
123+
</MemoryRouter>,
124+
);
125+
}
126+
127+
function reportSaves(): Array<Record<string, unknown>> {
128+
return mockClient.save.mock.calls.filter((c) => c[0] === 'report').map((c) => c[2]);
129+
}
130+
131+
/** Past the autosave's 1.5 s debounce. */
132+
async function outlastDebounce(): Promise<void> {
133+
await act(async () => {
134+
await new Promise((r) => setTimeout(r, 2300));
135+
});
136+
}
137+
138+
/** The report's properties are open, with the list property labelled `list` drawn. */
139+
async function ready(list: string): Promise<HTMLElement> {
140+
const inspector = await screen.findByTestId('if-inspector', undefined, { timeout: 8000 });
141+
await within(inspector).findByText(list, { selector: 'label' }, { timeout: 8000 });
142+
return inspector;
143+
}
144+
145+
/** The form row of the list property labelled `label`: its label and its repeater. */
146+
function listRow(inspector: HTMLElement, label: string): HTMLElement {
147+
const row = within(inspector).getByText(label, { selector: 'label' }).parentElement?.parentElement;
148+
if (!row) throw new Error(`no form row for ${label}`);
149+
return row;
150+
}
151+
152+
/** The report's own Label input. */
153+
function editLabel(inspector: HTMLElement, value: string): void {
154+
const input = within(inspector).getByLabelText(L('engine.inspector.report.label'));
155+
fireEvent.change(input, { target: { value } });
156+
fireEvent.blur(input);
157+
}
158+
159+
/** Picks `option` in the inspector select labelled `label`. */
160+
async function pick(inspector: HTMLElement, label: string, option: string | RegExp): Promise<void> {
161+
fireEvent.keyDown(within(inspector).getByRole('combobox', { name: label }), { key: 'ArrowDown' });
162+
await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0));
163+
fireEvent.click(screen.getByRole('option', { name: option }));
164+
}
165+
166+
const orderClause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Order By', n: 1, list: 'Order' });
167+
const heldLine = (clause: string) => tFormat('engine.studio.held.line', 'en', { clause });
168+
169+
describe('an item just added to a report list property is held until it is filled in (objectstack#27785)', () => {
170+
it('Order → Add item, then a Label edit: nothing is sent, no red strip, the line names the Order By', async () => {
171+
const report = vi.fn();
172+
mountPillar(report);
173+
const inspector = await ready('Order');
174+
fireEvent.click(within(listRow(inspector, 'Order')).getByRole('button', { name: L('engine.form.addItem') }));
175+
editLabel(inspector, 'Sales v2');
176+
await outlastDebounce();
177+
178+
expect(reportSaves(), 'the unfinished item is not sent, nor the Label edit with it').toEqual([]);
179+
expect(screen.queryByTestId('studio-refusal'), 'no red strip on the normal path').toBeNull();
180+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(orderClause));
181+
// Publish's half: the surface is told what is held.
182+
expect(report).toHaveBeenLastCalledWith(orderClause);
183+
});
184+
185+
it('"Show me" focuses the list in the report properties', async () => {
186+
mountPillar();
187+
const inspector = await ready('Order');
188+
const row = listRow(inspector, 'Order');
189+
fireEvent.click(within(row).getByRole('button', { name: L('engine.form.addItem') }));
190+
const show = await within(await screen.findByTestId('studio-held')).findByRole('button', {
191+
name: L('engine.studio.refusal.show'),
192+
});
193+
(document.activeElement as HTMLElement | null)?.blur();
194+
fireEvent.click(show);
195+
await waitFor(() => expect(row.contains(document.activeElement)).toBe(true));
196+
});
197+
198+
it('filling the Order By in sends one save carrying the item and the Label edit kept meanwhile', async () => {
199+
const report = vi.fn();
200+
mountPillar(report);
201+
const inspector = await ready('Order');
202+
const row = listRow(inspector, 'Order');
203+
fireEvent.click(within(row).getByRole('button', { name: L('engine.form.addItem') }));
204+
editLabel(inspector, 'Sales v2');
205+
await outlastDebounce();
206+
expect(reportSaves()).toEqual([]);
207+
208+
const by = within(row).getByLabelText('Order By');
209+
fireEvent.change(by, { target: { value: 'stage' } });
210+
fireEvent.blur(by);
211+
await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 });
212+
const [sent] = reportSaves();
213+
expect(sent.label).toBe('Sales v2');
214+
expect(sent.order).toEqual([expect.objectContaining({ by: 'stage' })]);
215+
expect(
216+
ReportSchema.safeParse(JSON.parse(JSON.stringify(sent))).success,
217+
'the body sent is one the door accepts',
218+
).toBe(true);
219+
await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull());
220+
expect(report).toHaveBeenLastCalledWith(null);
221+
});
222+
223+
it('a joined report’s Blocks → Add item: nothing is sent, the line names the block’s Name', async () => {
224+
stored = JOINED_REPORT;
225+
const report = vi.fn();
226+
mountPillar(report);
227+
const inspector = await ready('Blocks');
228+
fireEvent.click(within(listRow(inspector, 'Blocks')).getByRole('button', { name: L('engine.form.addItem') }));
229+
await outlastDebounce();
230+
231+
const clause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Name', n: 2, list: 'Blocks' });
232+
expect(reportSaves()).toEqual([]);
233+
expect(screen.queryByTestId('studio-refusal')).toBeNull();
234+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(clause));
235+
expect(report).toHaveBeenLastCalledWith(clause);
236+
});
237+
238+
// CONTROL: an edit to a report with nothing unfinished saves at once.
239+
it('a Label edit alone saves at once, with no line', async () => {
240+
mountPillar();
241+
const inspector = await ready('Order');
242+
editLabel(inspector, 'Sales v2');
243+
await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 });
244+
expect(reportSaves()[0].label).toBe('Sales v2');
245+
expect(screen.queryByTestId('studio-held')).toBeNull();
246+
});
247+
});
248+
249+
describe('a chart type picked before its axes is held until both axes are picked (objectstack#27785)', () => {
250+
const x = L('engine.inspector.report.chartX');
251+
const y = L('engine.inspector.report.chartY');
252+
253+
it('nothing is sent while an axis is unset; the line names it, and "Show me" focuses its picker', async () => {
254+
const report = vi.fn();
255+
mountPillar(report);
256+
const inspector = await ready('Order');
257+
await pick(inspector, L('engine.inspector.report.chartType'), 'bar');
258+
await outlastDebounce();
259+
260+
const clauseX = tFormat('engine.studio.held.reportChartNeedsInput', 'en', { input: x });
261+
expect(reportSaves(), 'the chart without axes is not sent').toEqual([]);
262+
expect(screen.queryByTestId('studio-refusal')).toBeNull();
263+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(clauseX));
264+
expect(report).toHaveBeenLastCalledWith(clauseX);
265+
266+
(document.activeElement as HTMLElement | null)?.blur();
267+
fireEvent.click(within(screen.getByTestId('studio-held')).getByRole('button', { name: L('engine.studio.refusal.show') }));
268+
await waitFor(() => expect(document.activeElement).toBe(within(inspector).getByRole('combobox', { name: x })));
269+
270+
await pick(inspector, x, 'Stage (stage)');
271+
await outlastDebounce();
272+
expect(reportSaves(), 'one axis is not enough').toEqual([]);
273+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(
274+
heldLine(tFormat('engine.studio.held.reportChartNeedsInput', 'en', { input: y })),
275+
);
276+
});
277+
278+
it('both axes picked: one save carrying the whole chart, and the line clears', async () => {
279+
const report = vi.fn();
280+
mountPillar(report);
281+
const inspector = await ready('Order');
282+
await pick(inspector, L('engine.inspector.report.chartType'), 'bar');
283+
await pick(inspector, x, 'Stage (stage)');
284+
await pick(inspector, y, /^Revenue/);
285+
await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 });
286+
const [sent] = reportSaves();
287+
expect(sent.chart).toEqual(expect.objectContaining({ type: 'bar', xAxis: 'stage', yAxis: 'revenue' }));
288+
expect(ReportSchema.safeParse(JSON.parse(JSON.stringify(sent))).success).toBe(true);
289+
await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull());
290+
expect(report).toHaveBeenLastCalledWith(null);
291+
});
292+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ import {
261261
objectSaveRefusal,
262262
plainRefusal,
263263
publishRefusal,
264+
reportHeldEdit,
264265
type PublishFailure,
265266
type StudioHeld,
266267
type StudioRefusal,
@@ -4835,11 +4836,16 @@ export function InterfacesPillar({
48354836
// objectstack#27459 — so is an item just added to one of the dashboard's
48364837
// own list properties (Global Filters, the header's Actions), asked of the
48374838
// spec's dashboard schema, until its required inputs are filled in.
4839+
// objectstack#27785 — a report likewise: an item just added to Order or a
4840+
// joined report's Blocks, or a chart whose type is picked before its axes,
4841+
// asked of the spec's report schema (`reportHeldEdit`).
48384842
const leafType = current?.type;
4839-
const leafIncomplete = React.useMemo(
4840-
() => (ifDirty && leafType === 'dashboard' ? dashboardHeldEdit(draft, locale) : null),
4841-
[ifDirty, leafType, draft, locale],
4842-
);
4843+
const leafIncomplete = React.useMemo(() => {
4844+
if (!ifDirty) return null;
4845+
if (leafType === 'dashboard') return dashboardHeldEdit(draft, locale);
4846+
if (leafType === 'report') return reportHeldEdit(draft, locale);
4847+
return null;
4848+
}, [ifDirty, leafType, draft, locale]);
48434849
const { loaded: draftLoaded } = useDraftAutoSave({
48444850
// objectui#11232 — the leaf `doSave` addresses, `type:name`.
48454851
target: leafKey,

0 commit comments

Comments
 (0)