Skip to content

Commit 1e4841a

Browse files
fix(studio): hold a report switched to Joined until its first block is added (#29143)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbBoayD5zbzdwgCsmNUWVKsz)_ Fixes #29022 Clause-②: no Metadata-protocol: none Claim 6571686909, surface amendment 6571700500. ## 维护者速读 - **改了什么**:Studio 报表属性里把「报表类型」切换为「联合」后,在添加第一个块之前,该报表的修改先保留在编辑器里、不自动保存;顶部出现一行中性提示「尚未保存」,说明联合报表需要至少添加一个块,并带「定位」按钮跳到 Blocks 列表。添加块后沿用已有的块条目保留规则,填好名称和数据集后一次性保存期间的所有修改。 - **为什么改**:切换为联合会清掉报表的数据集与度量,而规范要求联合报表必须有 `blocks`。原来自动保存直接发出这个报表,服务端 422 拒收;之后每次编辑(如描述)都带着同样的报表被拒,刷新时没有任何提示,修改全部丢失。 - **风险与回滚**:只在报表类型为联合、没有块、且规范唯一的拒收就是 `blocks` 时保留;其它情况与之前完全一样,报表若还有别的错误照常发送并显示红色拒收。新增一个 Studio 文案键(中英)。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/metadataError.ts` 的 `joinedReportHeld`(保留条件与 `every` 判定);`InterfacesPillar.reportJoined-29022.test.tsx`(真实的报表检查器里切换类型)。 - **两分钟验证**:Studio 打开一个汇总报表,属性里把报表类型改为「联合」再改一下标签,等 2 秒:网络里没有报表的 PUT,顶部出现上述提示;刷新页面会弹出离开确认。 - **需要你做的**:无,席位落地。 ## What changes - **`reportHeldEdit` holds a joined report with no block yet.** The type picker's switch (`ReportDefaultInspector`'s `commitType`, objectui#10746) sends `type: 'joined'` and clears the container's dataset, values, rows, columns, chart and order, so the body has no `blocks`. `ReportSchema` refuses that with one issue at `blocks` itself, so no list item exists for the objectstack#27785 list-item rule to hold, and the leaf autosave sent it. A new arm, `joinedReportHeld`, holds the report when its `blocks` is missing or empty and that issue is the spec's only refusal. The clause names the report form's own label for the list (`Blocks`), and "Show me" focuses that list. Once a block is added, the existing list-item rule takes over (its Name, then its Dataset). - **Finished but wrong work is still sent.** A joined report with no blocks that also carries a container key the spec refuses (for example a `dataset`) is not held, so that refusal still shows in the red strip, as for every other held rule. - **One new Studio string**, `engine.studio.held.reportJoinedNeedsBlock`, in `ui/packages/i18n/src/namespaces/studio/en.ts` and `zh.ts`. These tables are plain string-to-string records under the separate `./namespaces/studio` export, so `Clause-②: no`, as the amendment rules. Because the edit is held rather than refused, the pillar already reports it to the surface (`onHeldEditChange`), whose `beforeunload` guard, header guard and Publish refusal read it. The card's step 4 (reload drops the edits with no prompt) therefore no longer happens on this path. The general gap, where a leaf save that is refused leaves later edits that no guard sees, is outside this card's surface (`StudioDesignSurface.tsx` is held by #29015). The seat files it as its own card. `ReportDefaultInspector.tsx` is unchanged. ## Tests - `metadataError.reportHeld-29022.test.ts` (new): the switch body, a later Description edit, and every block removed again are each held, each paired with `ReportSchema`'s own verdict (`blocks` alone). A first unbound block hands the hold to the list-item rule. The zh clause is checked. Controls: a joined report with a bound block, and a summary report, are not held and the spec accepts both. A joined report that also keeps a container `dataset` is not held. - `InterfacesPillar.reportJoined-29022.test.tsx` (new, mounted like `InterfacesPillar.reportHeld-27785.test.tsx`, with the real `ReportDefaultInspector`): picking Report type = Joined and then editing the Label sends no report save, shows no red strip, shows the held line, and reports the clause to the surface. "Show me" focuses the Blocks list. Blocks → Add item hands the hold to the new block. Control: Report type = Tabular saves at once. ## Acceptance notes - The card's Expected reads "the switch is held until a block is complete, or the refused edits are guarded on leave". This PR does the first. The held edit is reported to the surface, so its existing leave guard asks on reload while it is held. - Out of scope, listed for the seat: (1) the general refused-save guard gap in `InterfacesPillar` (a leaf save refused with a 422 leaves later edits that no guard sees), which the seat files as its own card; (2) `commitType` clears six container keys on the Joined switch but not `dateRange` or `exportOptions`, which a joined report also refuses (#26548) and whose "Period & export" section the form hides on a joined report. A report with either key set and switched to Joined is therefore sent and refused at a key no visible control reaches. This is source reading only, not measured in a browser. ## Verification All on head `21198aa4` (base `origin/main` `5d3667e2`). <details><summary>New tests, and the ablation that shows they fail without the fix</summary> - `vitest run metadataError.reportHeld-29022.test.ts InterfacesPillar.reportJoined-29022.test.tsx`: 2 files, 12/12 passed. - Ablation with `scripts/ablation-replace.mjs`, deleting `if (joined) return joined;` from `reportHeldEdit` (anchor 1 to 0, blob `f915fdc4` to `dea5df87`): 6 failed, 6 passed. The failures are the four held cases in the unit file, plus the pillar's "nothing is sent" and "Show me" cases. The six that still pass are the controls and the list-item handover, which do not depend on the new arm. Restored: blob equals HEAD and `git diff HEAD` is empty. </details> <details><summary>Sweep, type-check and gates</summary> - Test sweep under the verify lock: every app-shell test file naming `reportHeldEdit`, `engine.studio.held` or `ReportDefaultInspector` (35 files, including both 27785 files), plus `zh-base-term.i18n-13131.test.ts` and the whole `packages/i18n/` suite: 131 files, 1701 passed, 13 skipped. - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: 66/66 tasks, exit 0. - `dispatch-gates --commands`: 34 derived families, all exit 0, including `check-sdui-manifest`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-closing-keyword-parity` (run against this body) and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. - Narrowing: the whole app-shell vitest suite is CI's Test UI; it was not run locally. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUqfeyd3MJX6eCeZdA Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1c1ca5 commit 1e4841a

6 files changed

Lines changed: 375 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(studio): a report switched to Joined in Properties is held until its first block is added, instead of autosaving a report the draft door refuses
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Setting Report type = Joined clears the report's dataset and measures, because a joined report selects per block. Until a block is added, the report has no `blocks`, and the spec refuses it ("a `joined` report needs `blocks`"). Studio used to autosave that body and get a 422, and every later edit carried the same body and was refused too, then lost on reload. Now the Interfaces pillar holds it unsent, as it already holds an unfinished Order item, block or chart. A neutral line reads "Not saved yet: a Joined report needs at least one block in “Blocks”", its "Show me" focuses the Blocks list, and Studio's leave guard and Publish see the held edit. Adding a block hands the hold to that block until its Name and Dataset are set, and the next autosave then sends every edit kept meanwhile. A joined report with no blocks that also keeps a key the spec refuses is still sent, so that refusal stays on screen.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29022 — the Interfaces pillar holds a report switched to Joined
5+
* in its Properties until its first block is added, instead of autosaving a
6+
* body the draft door refuses.
7+
*
8+
* The card: Report type = Joined sent `type: 'joined'` with the dataset and
9+
* measures cleared and no `blocks` (422 at `blocks`); a Description edit then
10+
* sent the same body plus the edit (422 again), and a reload dropped both with
11+
* no prompt. Now nothing is sent, a neutral line names the Blocks list with a
12+
* "Show me", and the surface is told what is held (its leave guard and Publish
13+
* read that).
14+
*
15+
* Mounted as `InterfacesPillar.reportHeld-27785.test.tsx` mounts it: a metadata
16+
* client double that records every save, a stub canvas and the real
17+
* `ReportDefaultInspector` with its type picker.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
24+
import { MemoryRouter } from 'react-router-dom';
25+
26+
const NAV = [{ id: 'nav_rep', type: 'report', label: 'Sales report', reportName: 'sales_report' }];
27+
28+
const SUMMARY_REPORT = {
29+
name: 'sales_report',
30+
label: 'Sales report',
31+
type: 'summary',
32+
dataset: 'sales_ds',
33+
rows: ['stage'],
34+
values: ['revenue'],
35+
};
36+
37+
const DATASETS = [
38+
{
39+
name: 'sales_ds',
40+
label: 'Sales',
41+
object: 'opportunity',
42+
dimensions: [{ name: 'stage', label: 'Stage' }],
43+
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum' }],
44+
},
45+
];
46+
47+
/** The report the pillar opens, as served: it parses with the spec. */
48+
let stored: Record<string, unknown> = SUMMARY_REPORT;
49+
50+
const mockClient = {
51+
withPreviewDrafts: vi.fn((): unknown => mockClient),
52+
save: vi.fn(async (_type: string, _name: string, _body: Record<string, unknown>) => ({
53+
success: true,
54+
version: 'v1',
55+
state: 'draft',
56+
})),
57+
list: vi.fn(async (type: string) => {
58+
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
59+
if (type === 'dataset') return DATASETS;
60+
if (type === 'report') return [stored];
61+
return [];
62+
}),
63+
listDrafts: vi.fn(async () => []),
64+
layered: vi.fn(async (type: string) => {
65+
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', active: true, navigation: NAV } };
66+
if (type === 'report') return { effective: JSON.parse(JSON.stringify(stored)) };
67+
return { code: null, overlay: null, overlayScope: null, effective: null };
68+
}),
69+
getDraft: vi.fn(async () => null),
70+
get: vi.fn(async (type: string, name: string) =>
71+
type === 'dataset' ? DATASETS.find((d) => d.name === name) : undefined,
72+
),
73+
};
74+
75+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
76+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
77+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
78+
});
79+
80+
vi.mock('./packages-io', async (importOriginal) => {
81+
const mod = await importOriginal<typeof import('./packages-io')>();
82+
return { ...mod, fetchPackages: vi.fn(async () => []) };
83+
});
84+
85+
vi.mock('@object-ui/react', async (importOriginal) => {
86+
const mod = await importOriginal<typeof import('@object-ui/react')>();
87+
return { ...mod, useAdapter: () => ({}) };
88+
});
89+
90+
import { InterfacesPillar } from './StudioDesignSurface';
91+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
92+
import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry';
93+
import { ReportDefaultInspector } from '../metadata-admin/inspectors/ReportDefaultInspector';
94+
import { t, tFormat } from '../metadata-admin/i18n';
95+
96+
// A stub canvas: the leaf needs a registered preview to be draft-editable,
97+
// and the canvas's own drawing is not what this file pins.
98+
registerMetadataPreview('report', () => null);
99+
registerMetadataDefaultInspector('report', ReportDefaultInspector);
100+
101+
beforeEach(() => {
102+
stored = SUMMARY_REPORT;
103+
for (const fn of Object.values(mockClient)) fn.mockClear();
104+
});
105+
afterEach(cleanup);
106+
107+
const L = (key: string) => t(key, 'en');
108+
109+
function mountPillar(onHeldEditChange?: (clause: string | null) => void) {
110+
return render(
111+
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
112+
<InterfacesPillar packageId="com.acme.app" onHeldEditChange={onHeldEditChange} />
113+
</MemoryRouter>,
114+
);
115+
}
116+
117+
function reportSaves(): Array<Record<string, unknown>> {
118+
return mockClient.save.mock.calls.filter((c) => c[0] === 'report').map((c) => c[2]);
119+
}
120+
121+
/** Past the autosave's 1.5 s debounce. */
122+
async function outlastDebounce(): Promise<void> {
123+
await act(async () => {
124+
await new Promise((r) => setTimeout(r, 2300));
125+
});
126+
}
127+
128+
/** The report's properties are open, with the list property labelled `list` drawn. */
129+
async function ready(list: string): Promise<HTMLElement> {
130+
const inspector = await screen.findByTestId('if-inspector', undefined, { timeout: 8000 });
131+
await within(inspector).findByText(list, { selector: 'label' }, { timeout: 8000 });
132+
return inspector;
133+
}
134+
135+
/** The form row of the list property labelled `label`: its label and its repeater. */
136+
function listRow(inspector: HTMLElement, label: string): HTMLElement {
137+
const row = within(inspector).getByText(label, { selector: 'label' }).parentElement?.parentElement;
138+
if (!row) throw new Error(`no form row for ${label}`);
139+
return row;
140+
}
141+
142+
/** The report's own Label input. */
143+
function editLabel(inspector: HTMLElement, value: string): void {
144+
const input = within(inspector).getByLabelText(L('engine.inspector.report.label'));
145+
fireEvent.change(input, { target: { value } });
146+
fireEvent.blur(input);
147+
}
148+
149+
/** Picks `option` in the inspector select labelled `label`. */
150+
async function pick(inspector: HTMLElement, label: string, option: string | RegExp): Promise<void> {
151+
fireEvent.keyDown(within(inspector).getByRole('combobox', { name: label }), { key: 'ArrowDown' });
152+
await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0));
153+
fireEvent.click(screen.getByRole('option', { name: option }));
154+
}
155+
156+
const heldLine = (clause: string) => tFormat('engine.studio.held.line', 'en', { clause });
157+
const joinedClause = tFormat('engine.studio.held.reportJoinedNeedsBlock', 'en', { list: 'Blocks' });
158+
159+
describe('a report switched to Joined is held until its first block is added (objectstack#29022)', () => {
160+
it('Report type = Joined, then a Label edit: nothing is sent, no red strip, the line names Blocks', async () => {
161+
const report = vi.fn();
162+
mountPillar(report);
163+
const inspector = await ready('Order');
164+
await pick(inspector, L('engine.inspector.report.type'), L('engine.inspector.report.type.joined'));
165+
editLabel(inspector, 'Sales v2');
166+
await outlastDebounce();
167+
168+
expect(reportSaves(), 'the joined report without blocks is not sent, nor the Label edit with it').toEqual([]);
169+
expect(screen.queryByTestId('studio-refusal'), 'no red strip on the normal path').toBeNull();
170+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(joinedClause));
171+
// The surface's half: its leave guard and Publish read the held clause.
172+
expect(report).toHaveBeenLastCalledWith(joinedClause);
173+
});
174+
175+
it('"Show me" focuses the Blocks list in the report properties', async () => {
176+
mountPillar();
177+
const inspector = await ready('Order');
178+
await pick(inspector, L('engine.inspector.report.type'), L('engine.inspector.report.type.joined'));
179+
await within(inspector).findByText('Blocks', { selector: 'label' });
180+
const row = listRow(inspector, 'Blocks');
181+
const show = await within(await screen.findByTestId('studio-held')).findByRole('button', {
182+
name: L('engine.studio.refusal.show'),
183+
});
184+
(document.activeElement as HTMLElement | null)?.blur();
185+
fireEvent.click(show);
186+
await waitFor(() => expect(row.contains(document.activeElement)).toBe(true));
187+
});
188+
189+
it('Blocks → Add item hands the hold to the new block, still unsent', async () => {
190+
const report = vi.fn();
191+
mountPillar(report);
192+
const inspector = await ready('Order');
193+
await pick(inspector, L('engine.inspector.report.type'), L('engine.inspector.report.type.joined'));
194+
await within(inspector).findByText('Blocks', { selector: 'label' });
195+
fireEvent.click(within(listRow(inspector, 'Blocks')).getByRole('button', { name: L('engine.form.addItem') }));
196+
await outlastDebounce();
197+
198+
const clause = tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Name', n: 1, list: 'Blocks' });
199+
expect(reportSaves()).toEqual([]);
200+
expect(screen.queryByTestId('studio-refusal')).toBeNull();
201+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent(heldLine(clause));
202+
expect(report).toHaveBeenLastCalledWith(clause);
203+
});
204+
205+
// CONTROL: a type switch the door accepts saves at once.
206+
it('Report type = Tabular saves at once, with no line', async () => {
207+
mountPillar();
208+
const inspector = await ready('Order');
209+
await pick(inspector, L('engine.inspector.report.type'), L('engine.inspector.report.type.tabular'));
210+
await waitFor(() => expect(reportSaves()).toHaveLength(1), { timeout: 4000 });
211+
expect(reportSaves()[0].type).toBe('tabular');
212+
expect(screen.queryByTestId('studio-held')).toBeNull();
213+
});
214+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29022 — a report switched to Joined before its first block is
5+
* HELD by the Interfaces pillar's leaf autosave, not sent.
6+
*
7+
* The card: Properties → Report type = Joined sent `type: 'joined'` with the
8+
* container's dataset and measures cleared (objectui#10746) and no `blocks`;
9+
* the draft door answered 422 at `blocks` ("a `joined` report needs
10+
* `blocks`"), and every later edit carried the same body and was refused too,
11+
* then lost on reload. `reportHeldEdit` held list items and the chart
12+
* (objectstack#27785) but not a joined report with no list at all.
13+
*
14+
* Every "held" case is paired with the spec's own `ReportSchema` verdict on the
15+
* same body (it refuses it at `blocks` alone), and every "not held" control
16+
* with a body that judge accepts, or refuses for something the author wrote.
17+
* The pillar that acts on it is pinned in
18+
* `InterfacesPillar.reportJoined-29022.test.tsx`.
19+
*/
20+
21+
import { describe, expect, it } from 'vitest';
22+
import { ReportSchema } from '@objectstack/spec/ui';
23+
import { reportHeldEdit } from './metadataError';
24+
import { tFormat } from '../metadata-admin/i18n';
25+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
26+
27+
// The studio zh catalogue loads on demand; the locale case below reads it.
28+
await loadStudioCatalogue('zh');
29+
30+
/** The issue paths the spec's report parse refuses `report` at, as the door reports them. */
31+
function reportRefusals(report: Record<string, unknown>): string[] {
32+
const result = ReportSchema.safeParse(report);
33+
return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'));
34+
}
35+
36+
/** A summary report as Save as draft wrote it in the card's step 1. */
37+
const SUMMARY = {
38+
name: 'pipeline_report',
39+
label: 'Pipeline',
40+
type: 'summary',
41+
dataset: 'sales_ds',
42+
values: ['revenue'],
43+
drilldown: true,
44+
};
45+
46+
/**
47+
* The body the type picker's switch leaves (`ReportDefaultInspector`'s
48+
* `commitType`): `type: 'joined'`, the selection keys present and `undefined`.
49+
*/
50+
const SWITCHED = {
51+
...SUMMARY,
52+
type: 'joined',
53+
dataset: undefined,
54+
values: undefined,
55+
};
56+
57+
const joinedClause = (list: string, locale = 'en') =>
58+
tFormat('engine.studio.held.reportJoinedNeedsBlock', locale, { list });
59+
60+
describe('a report switched to Joined before its first block is held (objectstack#29022)', () => {
61+
it('the switch itself: held, as the door refuses it at blocks, placed on the report properties', () => {
62+
expect(reportRefusals(SWITCHED)).toEqual(['blocks']);
63+
expect(reportHeldEdit(SWITCHED, 'en')).toEqual({
64+
clause: joinedClause('Blocks'),
65+
target: null,
66+
input: 'Blocks',
67+
});
68+
});
69+
70+
it('a later edit (the card step 3 Description) stays held with it', () => {
71+
const body = { ...SWITCHED, description: 'Won and lost, side by side' };
72+
expect(reportRefusals(body)).toEqual(['blocks']);
73+
expect(reportHeldEdit(body, 'en')?.clause).toBe(joinedClause('Blocks'));
74+
});
75+
76+
it('every block removed again: held the same way', () => {
77+
const body = { ...SWITCHED, blocks: [] };
78+
expect(reportRefusals(body)).toEqual(['blocks']);
79+
expect(reportHeldEdit(body, 'en')?.clause).toBe(joinedClause('Blocks'));
80+
});
81+
82+
it('a first block added but not bound is held as a list item, by the 27785 rule', () => {
83+
const body = { ...SWITCHED, blocks: [{ name: 'won' }] };
84+
expect(reportRefusals(body)).toEqual(['blocks.0.dataset']);
85+
expect(reportHeldEdit(body, 'en')?.clause).toBe(
86+
tFormat('engine.studio.held.listItemNeedsInput', 'en', { input: 'Dataset', n: 1, list: 'Blocks' }),
87+
);
88+
});
89+
90+
it('names the list in the designer locale', () => {
91+
const held = reportHeldEdit(SWITCHED, 'zh-CN');
92+
expect(held?.clause).toBe(joinedClause(held?.input ?? '', 'zh-CN'));
93+
expect(held?.clause).toMatch(/^联合报表/);
94+
});
95+
96+
// CONTROL: the hold is never stricter than the contract.
97+
it('a joined report with a bound block: not held, and the spec accepts it', () => {
98+
const body = { ...SWITCHED, blocks: [{ name: 'won', dataset: 'sales_ds' }] };
99+
expect(reportRefusals(body)).toEqual([]);
100+
expect(reportHeldEdit(body, 'en')).toBeNull();
101+
});
102+
103+
it('a summary report with no blocks: not held, and the spec accepts it', () => {
104+
expect(reportRefusals(SUMMARY)).toEqual([]);
105+
expect(reportHeldEdit(SUMMARY, 'en')).toBeNull();
106+
});
107+
108+
// CONTROL: errors stay for finished work.
109+
it('a joined report with no blocks that also keeps a container key it refuses is not held', () => {
110+
const body = { ...SWITCHED, dataset: 'sales_ds' };
111+
expect(reportRefusals(body)).toEqual(['blocks', 'dataset']);
112+
expect(reportHeldEdit(body, 'en')).toBeNull();
113+
});
114+
});

‎ui/packages/app-shell/src/views/studio-design/metadataError.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,16 +1260,46 @@ const REPORT_CHART_INPUT_KEYS: Readonly<Record<string, string>> = {
12601260
yAxis: 'engine.inspector.report.chartY',
12611261
};
12621262

1263+
/**
1264+
* objectstack#29022 — a report switched to Joined before its first block,
1265+
* held. The type picker sends `type: 'joined'` and clears the container's
1266+
* selection (objectui#10746), so the body carries no `blocks`, and
1267+
* `ReportSchema` refuses it at `blocks` itself ("a `joined` report needs
1268+
* `blocks`"): no item of the list exists for {@link listItemHeldInput} to
1269+
* hold. Held while the list is missing or empty and that is the spec's only
1270+
* refusal; an added block is then judged as a list item. Named by the report
1271+
* form's label for the list, which "Show me" focuses.
1272+
*/
1273+
function joinedReportHeld(draft: SentBody, locale: string): StudioHeld<MetadataSelection | null> | null {
1274+
if (draft.type !== 'joined') return null;
1275+
const blocks = draft.blocks;
1276+
if (blocks !== undefined && !(Array.isArray(blocks) && blocks.length === 0)) return null;
1277+
const parsed = ReportSchema.safeParse(draft);
1278+
// Only when that is all it refuses: any other issue is finished but wrong work, whose refusal stays on screen.
1279+
if (parsed.success || !parsed.error.issues.every((issue) => issue.path.length === 1 && issue.path[0] === 'blocks')) {
1280+
return null;
1281+
}
1282+
const rows = (getReportForm()?.sections ?? []).flatMap((section) =>
1283+
'fields' in section && Array.isArray(section.fields) ? section.fields : [],
1284+
);
1285+
const list = formRowLabel(formRowOf(rows, 'blocks'), 'blocks', locale);
1286+
return { clause: tFormat('engine.studio.held.reportJoinedNeedsBlock', locale, { list }), target: null, input: list };
1287+
}
1288+
12631289
/**
12641290
* The edit the Interfaces pillar holds for `draft`, the report its leaf
12651291
* autosave would send, or `null`: the first item of one of its list properties
12661292
* `ReportSchema` refuses only for inputs it leaves unset, named by the report
1267-
* form's labels; else its chart, refused only for unset inputs, named by the
1268-
* chart panel's own label. Both are placed on the report's properties.
1293+
* form's labels; else a joined report with no block yet
1294+
* ({@link joinedReportHeld}); else its chart, refused only for unset inputs,
1295+
* named by the chart panel's own label. All are placed on the report's
1296+
* properties.
12691297
*/
12701298
export function reportHeldEdit(draft: SentBody, locale: string): StudioHeld<MetadataSelection | null> | null {
12711299
const item = listItemHeldInput(draft, ReportSchema);
12721300
if (item) return heldListItem(item, getReportForm(), locale);
1301+
const joined = joinedReportHeld(draft, locale);
1302+
if (joined) return joined;
12731303
const key = objectHeldInput(draft, ReportSchema, 'chart');
12741304
if (key === null) return null;
12751305
const labelKey = REPORT_CHART_INPUT_KEYS[key];

0 commit comments

Comments
 (0)