Skip to content

Commit 34dc09b

Browse files
fix(studio): hold a Rollup field until it is set up, and offer only the child fields the spec accepts for its function (#27720)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg3DyjUB8hX9kiYQXxy8duUV)_ ## 维护者速读 - 改了什么:Studio 数据页新加「汇总(Rollup)」字段时,在子对象、汇总方式、(sum/avg/min/max 时的)汇总子字段配齐之前不再自动保存草稿,页面上出现一行提示点名字段和还缺的那一项,带「Show me」;「汇总的子字段」下拉只列出 spec 规则接受的子字段(选 Sum 不再出现文本、审计、lookup、主从字段)。 - 为什么改:以前一点 Create 就把 `{ type: 'summary' }`、没有 `summaryOperations` 的字段写进草稿,服务端照收,这个字段处处读 0;子字段下拉不按汇总方式过滤,作者能选出运行时拒收的组合。 - 风险与回滚:只改前端的拦截判定与下拉候选,判定全部问 spec(`checkFieldCompleteness`、`FieldSchema`、`isAggregateCompatibleWithFieldType`),不另存类型清单;最坏情况是多拦一次(提示行 + Show me 可解)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/studio-design/metadataError.ts`(`summaryHeldInput` / `summaryHeldEdit`)、`ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx`(`aggregandOptions`)、`DataPillar.summaryHeld-27461.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/metadataError.summaryHeld-27461.test.ts packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.summaryAggregand-27461.test.tsx packages/app-shell/src/views/studio-design/DataPillar.summaryHeld-27461.test.tsx`,预期 3 个文件 13 条全部通过。 - 需要你做的:无,席位落地。 Fixes #27461 Clause-②: no Metadata-protocol: none ## What changes **Held half (card steps 2 to 4).** The Data pillar's autosave already holds an edit the object write guard would refuse (a choice field before its options, a relationship before its target). The guard lets a roll-up through at every stage, and the server saves a `summary` with no `summaryOperations` (it reads 0 everywhere), so the card's draft PUT went out the moment the field was created. `objectHeldEdit` now also holds the first named `summary` field that is still being set up, once the guard lets the fields through and before an unfinished action: - The judges are the spec's own, never a list kept here: its completeness rule `field/summary-without-operations` (`checkFieldCompleteness`, `@objectstack/spec/kernel`) for a roll-up with no `summaryOperations`, and `FieldSchema` for what a started one still lacks. Unfinished means every issue `FieldSchema` names under `summaryOperations` is the child object, the function, or the child field (which the schema requires exactly when `summaryFunctionReadsField` says the function reads one), each still absent. Any other issue (an unknown function, say) is finished but wrong: the save is sent and its refusal shows as before. - The held line names the field by its label and the input it needs, in the inspector's order, with the inspector's own label (`Child object`, `Aggregation`, `Child field to aggregate`). "Show me" reopens the field. The Studio Publish refusal and the held-edit report read the same `objHeld`, unchanged. - One new clause, `engine.studio.held.fieldNeedsInput` (en, zh): the existing `needsOptions` / `needsTarget` / step / action / widget clauses do not name a field input. **Picker half (card steps 5 to 7).** "Child field to aggregate" now offers only the child fields `isAggregateCompatibleWithFieldType` (`@objectstack/spec/data`, the aggregate × field-type table the dataset compiler and the engine's aggregate door read) accepts for the chosen function. Before a function is picked, it offers a field some value-reading summary function accepts. The filter rows keep offering every child field: a filter reads any. ## Tests - New `metadataError.summaryHeld-27461.test.ts` (8 cases): each held case is paired with the spec's verdict on the same field (the completeness rule fires, or `FieldSchema` refuses at that path); controls: a count without a field and a sum with one are accepted by the spec and not held, an unknown function is sent, and a choice field the guard refuses is named first. - New `ObjectFieldInspector.summaryAggregand-27461.test.tsx` (3 cases): the card's child object (text, textarea, datetime, lookup, master_detail, number, percent, boolean, formula); for each of sum, avg, min, max the picker offers exactly what the rule accepts, and before a function, the union. - New `DataPillar.summaryHeld-27461.test.tsx` (2 cases): the card's path (Form tab, Add field, Label "Child Count", Type Rollup, Create) sends no PUT past the debounce and names the child object; after the child object it names the aggregation and still sends nothing; picking Count sends one PUT with `{ object, function: 'count' }` and clears the line. "Show me" reopens the closed field. Readings below are on the final head `6f387b30` (this branch merged with `origin/main` at `e35db835`) unless a line says otherwise. <details><summary>Ablations (one per half, ablation-replace, restored blob == HEAD)</summary> - `metadataError.ts`: `return summaryHeldEdit(body, locale) ?? actionHeldEdit(body, locale);` replaced by `return actionHeldEdit(body, locale);` (the behaviour on `main`) → 7 failed / 10 across the two held suites (the 5 held predicate cases and both pillar cases; the 3 controls stay green); restored, `git diff HEAD` empty. - `ObjectFieldInspector.tsx`: the picker's `options={aggregandOptions}` replaced by the unfiltered `aggregateOptions` (the behaviour on `main`) → 3 failed / 3; restored, `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 (pre-merge head bfe95a6): 200 files / 2360 tests passed</summary> Every app-shell test file naming `ObjectFieldInspector`, `metadataError`, `objectHeldEdit` or `DataPillar`, plus the whole `packages/i18n/` directory, under the verification lock: `Test Files 200 passed (200) · Tests 2360 passed | 13 skipped (2373)`, lock VERDICT command-exit 0. The rest of the ui tree is left to CI `Test UI`. </details> <details><summary>After merging origin/main</summary> ``` pnpm install --frozen-lockfile :: exit 0 pnpm --filter '@object-ui/app-shell^...' build :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 pnpm --filter @object-ui/i18n run type-check :: exit 0 vitest: the three new suites, DataPillar.heldIncomplete-11786, metadataError.heldEdit-11786, ObjectFieldInspector.test :: Test Files 6 passed (6) · Tests 80 passed (80) ``` </details> <details><summary>Derived gates (dispatch-gates --commands on this diff)</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, 34 run, 0 NOT-MEASURED, 0 UNRUN. Also `node scripts/check-new-cross-file-line-citations.mjs` (ui): 0 new citations, exit 0. Families CI runs beyond the derived set are read from this PR's check-runs. </details> ## Acceptance notes 1. **Booleans are offered for Sum, Avg, Min and Max.** The card expected "only numeric child fields"; the triage ruled the picker reads the spec's rule and keeps no second list, and that rule accepts the boolean class in all four rows (booleans aggregate as numbers on every backend). Min and Max also accept the temporal class, and Sum refuses `percent` while Avg accepts it. The picker follows the rule as written. 2. **No inspector hint for a held roll-up.** A held choice or relationship field also shows a hint under its input in the inspector (`designer.field.hint.addOption` / `pickTarget`); a held roll-up shows the held line only. The acceptance asks for the held line and "Show me", which this delivers; an inspector hint would be a new string beside a different input per stage, left out of this card. 3. **A field already stored on a roll-up stays as it is.** If a stored `summaryOperations.field` names a child field the rule refuses for the function (for example after switching Sum to Max), the picker no longer lists it but the stored value is not rewritten; the server's diagnostics remain the judge of a finished roll-up. 4. **Attribution line 2** links this card thread, the block the harness requires, rather than the seat thread link the dispatch order quoted. ## Verification Run the three new suites (command in 维护者速读). In a running Console: Studio, Data, a parent object, Form, Add field, Label "Child Count", Type Rollup, Create: no draft PUT goes out and the line "Not saved yet: Child object on the field “Child Count” needs a value. …" appears; set Child object and Aggregation = Sum, then open "Child field to aggregate": only the child's number (and boolean) fields are listed. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZHqtPBzmpQCLhHKuR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e0aed00 commit 34dc09b

8 files changed

Lines changed: 491 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(studio): a new Rollup field is held until it is set up, and its child-field picker offers only the fields the spec accepts for the chosen function
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Held, not autosaved.** Adding a field with Type = Rollup used to send the draft at once as `{ type: 'summary' }` with no `summaryOperations`, which the server saves as a roll-up that reads 0 everywhere. The Data pillar now holds a roll-up like a choice field before its options: it stays on screen and unsent, the held line names the field and the input it still needs (Child object, Aggregation, or Child field to aggregate when the function reads one), "Show me" reopens it, and Publish refuses while it is held. The judges are the spec's: the `field/summary-without-operations` completeness rule and `FieldSchema`. A roll-up the spec refuses for any other reason is sent, so its refusal shows as before.
12+
- **Child field picker.** "Child field to aggregate" lists only the child fields `isAggregateCompatibleWithFieldType` accepts for the chosen function (Sum no longer offers text, audit, lookup or master-detail fields), and before a function is picked, a field some value-reading function accepts. The filter rows still offer every child field.
13+
14+
`@object-ui/i18n` gains `engine.studio.held.fieldNeedsInput` in the en and zh studio packs.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27461 — the roll-up's "Child field to aggregate" picker offers
5+
* only the child fields the spec accepts for the chosen function.
6+
*
7+
* The card: with Aggregation = Sum, the picker listed text fields, the audit
8+
* columns, the owner lookup and the master-detail parent beside the one
9+
* number. The spec already says which field types each aggregate accepts
10+
* (`isAggregateCompatibleWithFieldType`, the table both the dataset compiler
11+
* and the engine's aggregate door read), so the picker reads that rule: every
12+
* expectation below is paired with the rule's own verdict on the same child
13+
* fields, so the picker can be neither wider nor narrower than the spec.
14+
*/
15+
16+
import * as React from 'react';
17+
import { afterEach, describe, expect, it, vi } from 'vitest';
18+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
19+
import { isAggregateCompatibleWithFieldType } from '@objectstack/spec/data';
20+
21+
vi.mock('../useMetadata', () => ({
22+
useMetadataClient: () => ({
23+
list: vi.fn().mockResolvedValue([]),
24+
listDrafts: vi.fn().mockResolvedValue([]),
25+
}),
26+
}));
27+
28+
/** The card's child object: a number beside text, audit, relationship, a rate, a flag and a formula. */
29+
const CHILD_FIELDS = [
30+
{ name: 'name', label: 'Name', type: 'text', hidden: false },
31+
{ name: 'memo', label: 'Memo', type: 'textarea', hidden: false },
32+
{ name: 'created_at', label: 'Created At', type: 'datetime', hidden: false },
33+
{ name: 'owner', label: 'Owner', type: 'lookup', hidden: false },
34+
{ name: 'parent', label: 'Parent', type: 'master_detail', hidden: false },
35+
{ name: 'amount', label: 'Amount', type: 'number', hidden: false },
36+
{ name: 'rate', label: 'Rate', type: 'percent', hidden: false },
37+
{ name: 'done', label: 'Done', type: 'boolean', hidden: false },
38+
{ name: 'margin', label: 'Margin', type: 'formula', hidden: false },
39+
];
40+
41+
vi.mock('../previews/useObjectFields', () => ({
42+
useObjectFields: (obj?: string) => ({ fields: obj ? CHILD_FIELDS : [], loading: false, error: null }),
43+
}));
44+
45+
import { ObjectFieldInspector } from './ObjectFieldInspector';
46+
47+
afterEach(cleanup);
48+
49+
function renderRollup(summaryOperations: Record<string, unknown>) {
50+
render(
51+
<ObjectFieldInspector
52+
type="object"
53+
name="parent_obj"
54+
draft={{ name: 'parent_obj', fields: { total: { type: 'summary', label: 'Total', summaryOperations } } }}
55+
selection={{ kind: 'field', id: 'total' }}
56+
onPatch={vi.fn()}
57+
onClearSelection={vi.fn()}
58+
onSelectionChange={vi.fn()}
59+
readOnly={false}
60+
locale={'en-US'}
61+
/>,
62+
);
63+
}
64+
65+
/** The API names the "Child field to aggregate" picker offers, opened. */
66+
async function offeredAggregands(): Promise<string[]> {
67+
fireEvent.click(screen.getByLabelText('Child field to aggregate'));
68+
const list = await screen.findByRole('listbox');
69+
return within(list).getAllByRole('option').map((o) => o.getAttribute('data-value') ?? '');
70+
}
71+
72+
/** The child fields the spec's rule accepts for `fn`. */
73+
const specAccepts = (fn: string) =>
74+
CHILD_FIELDS.filter((f) => isAggregateCompatibleWithFieldType(fn, f.type)).map((f) => f.name);
75+
76+
describe('roll-up child-field picker follows the spec\'s aggregate × field-type rule (objectstack#27461)', () => {
77+
it('Sum: the card\'s text, audit and relationship fields are gone; the number is offered', async () => {
78+
renderRollup({ object: 'child_obj', function: 'sum' });
79+
const offered = await offeredAggregands();
80+
expect(offered).toEqual(specAccepts('sum'));
81+
expect(offered).toContain('amount');
82+
for (const refused of ['name', 'memo', 'created_at', 'owner', 'parent', 'margin']) {
83+
expect(offered).not.toContain(refused);
84+
}
85+
});
86+
87+
it('each value-reading function offers exactly what the rule accepts for it', async () => {
88+
for (const fn of ['sum', 'avg', 'min', 'max']) {
89+
renderRollup({ object: 'child_obj', function: fn });
90+
expect(await offeredAggregands(), fn).toEqual(specAccepts(fn));
91+
cleanup();
92+
}
93+
// The rule is what differs, not the picker: a rate averages but does not
94+
// add, and a date has an earliest but no total.
95+
expect(specAccepts('avg')).toContain('rate');
96+
expect(specAccepts('sum')).not.toContain('rate');
97+
expect(specAccepts('min')).toContain('created_at');
98+
});
99+
100+
it('before a function is picked: a field some value-reading function accepts', async () => {
101+
renderRollup({ object: 'child_obj' });
102+
const union = CHILD_FIELDS.filter((f) =>
103+
['sum', 'avg', 'min', 'max'].some((fn) => isAggregateCompatibleWithFieldType(fn, f.type)),
104+
).map((f) => f.name);
105+
expect(await offeredAggregands()).toEqual(union);
106+
});
107+
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import * as React from 'react';
5151
import {
5252
FieldSchema,
53+
isAggregateCompatibleWithFieldType,
5354
isBodyTableRelationship,
5455
MULTI_OPTION_TYPES,
5556
NUMERIC_VALUE_TYPES,
@@ -2562,6 +2563,20 @@ function SummaryConfigFields({
25622563
() => childFields.filter((f) => !f.hidden).map((f) => ({ value: f.name, label: f.label, hint: f.type })),
25632564
[childFields],
25642565
);
2566+
// objectstack#27461 — the child field to aggregate is offered only where the
2567+
// spec accepts it for the chosen function: its aggregate × field-type rule
2568+
// (`isAggregateCompatibleWithFieldType`), never a list kept here. Before a
2569+
// function is picked, a field any value-reading function accepts. The
2570+
// filter rows below still offer every child field: a filter reads any.
2571+
const aggregandOptions: InspectorComboOption[] = React.useMemo(() => {
2572+
const accepts = (type: string) =>
2573+
fn
2574+
? isAggregateCompatibleWithFieldType(fn, type)
2575+
: SUMMARY_FUNCTIONS.some((f) => summaryFunctionReadsField(f) && isAggregateCompatibleWithFieldType(f, type));
2576+
return childFields
2577+
.filter((f) => !f.hidden && accepts(f.type))
2578+
.map((f) => ({ value: f.name, label: f.label, hint: f.type }));
2579+
}, [childFields, fn]);
25652580
// The FK back to this parent lives on a child relation field.
25662581
const relationshipOptions: InspectorComboOption[] = React.useMemo(
25672582
() =>
@@ -2618,7 +2633,7 @@ function SummaryConfigFields({
26182633
label={tr('designer.field.summary.field')}
26192634
value={typeof ops.field === 'string' ? ops.field : ''}
26202635
onCommit={(v) => patchOps({ field: v || undefined })}
2621-
options={aggregateOptions}
2636+
options={aggregandOptions}
26222637
loading={loading}
26232638
placeholder={fieldPlaceholder}
26242639
searchPlaceholder={tr('designer.field.lookup.searchFields')}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27461 — on the Studio data page, a new Rollup field is held
5+
* until it is set up, not autosaved unconfigured.
6+
*
7+
* The card's step 4: "Add field", Type = Rollup, and the draft PUT went out at
8+
* once with `{ type: 'summary' }` and no `summaryOperations`, which the server
9+
* saved (a roll-up that reads 0 everywhere). Now the pillar asks the spec's
10+
* judges before sending (`objectHeldEdit`, pinned predicate by predicate in
11+
* `metadataError.summaryHeld-27461.test.ts`): the edit stays dirty and unsent,
12+
* the held line names the field and the input it still needs, with "Show me"
13+
* once the field is closed, and the save goes as soon as the roll-up is one
14+
* the spec accepts.
15+
*
16+
* The harness is `DataPillar.heldIncomplete-11786.test.tsx`'s: a real
17+
* `MetadataClient` over a transport double.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { afterEach, describe, expect, it, vi } from 'vitest';
23+
import { render, screen, fireEvent, cleanup, act, waitFor, within } from '@testing-library/react';
24+
import { MemoryRouter } from 'react-router-dom';
25+
import { MetadataClient } from '@object-ui/data-objectstack';
26+
27+
const objectDef = {
28+
name: 'showcase_task',
29+
label: 'Task',
30+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
31+
};
32+
33+
/** Every PUT the transport received, parsed. */
34+
const puts: Array<Record<string, unknown>> = [];
35+
36+
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
37+
if (init?.method === 'PUT') {
38+
puts.push(JSON.parse(String(init.body)) as Record<string, unknown>);
39+
return new Response(JSON.stringify({ success: true }), {
40+
status: 200,
41+
headers: { 'content-type': 'application/json' },
42+
});
43+
}
44+
return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } });
45+
});
46+
47+
const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch });
48+
Object.assign(client, {
49+
list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]),
50+
listDrafts: vi.fn(async () => []),
51+
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
52+
getDraft: vi.fn(async () => null),
53+
});
54+
55+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
56+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
57+
return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) };
58+
});
59+
60+
vi.mock('./packages-io', async (importOriginal) => {
61+
const mod = await importOriginal<typeof import('./packages-io')>();
62+
return { ...mod, fetchPackages: vi.fn(async () => []) };
63+
});
64+
65+
vi.mock('@object-ui/react', async (importOriginal) => {
66+
const mod = await importOriginal<typeof import('@object-ui/react')>();
67+
return { ...mod, useAdapter: () => dataSource };
68+
});
69+
70+
import { DataPillar } from './StudioDesignSurface';
71+
import { addFieldFromFormToolbar } from './__tests__/dataPillarAddField';
72+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
73+
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
74+
import { t, tFormat } from '../metadata-admin/i18n';
75+
76+
const dataSource = createEmptyDataSource();
77+
registerBuiltinInspectors();
78+
79+
afterEach(() => {
80+
cleanup();
81+
puts.length = 0;
82+
fetchImpl.mockClear();
83+
});
84+
85+
/** Past the autosave's 1.5 s debounce. */
86+
async function outlastDebounce(): Promise<void> {
87+
await act(async () => {
88+
await new Promise((r) => setTimeout(r, 2300));
89+
});
90+
}
91+
92+
/** The inspector control under a visible label. */
93+
function controlUnder(label: string): HTMLElement {
94+
const lab = screen.getByText(label, { selector: 'label' });
95+
return lab.parentElement!.querySelector('input, [role="combobox"]') as HTMLElement;
96+
}
97+
98+
/** The fields of the last PUT body, by name. */
99+
function lastSentField(name: string): Record<string, unknown> | undefined {
100+
const fields = puts[puts.length - 1]?.fields;
101+
return Array.isArray(fields)
102+
? (fields as Array<Record<string, unknown>>).find((f) => f.name === name)
103+
: (fields as Record<string, Record<string, unknown>> | undefined)?.[name];
104+
}
105+
106+
/**
107+
* Mount and add the card's field the card's way: Form tab, Add field, Label
108+
* "Child Count", Type Rollup, Create. Returns its API name and label.
109+
*/
110+
async function addRollup(): Promise<{ name: string; label: string }> {
111+
render(
112+
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
113+
<DataPillar packageId="com.example.showcase" />
114+
</MemoryRouter>,
115+
);
116+
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
117+
await addFieldFromFormToolbar(undefined, { label: 'Child Count', type: /^Rollup$/ });
118+
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
119+
const name = (controlUnder('API name') as HTMLInputElement).value;
120+
const label = (controlUnder('Label') as HTMLInputElement).value;
121+
expect(name, 'the new field has no API name: the harness is dead').not.toBe('');
122+
expect(label).toBe('Child Count');
123+
return { name, label };
124+
}
125+
126+
const heldLine = (clause: string) => tFormat('engine.studio.held.line', 'en', { clause });
127+
128+
describe('Studio data page — a Rollup is held until it is set up (objectstack#27461)', () => {
129+
it('sends no save for the bare roll-up; names each missing input in turn; a count lets the save go', async () => {
130+
const { name, label } = await addRollup();
131+
await outlastDebounce();
132+
133+
expect(puts, 'an unconfigured roll-up is not sent').toEqual([]);
134+
expect(screen.queryByTestId('studio-refusal')).toBeNull();
135+
const message = () => within(screen.getByTestId('studio-held')).getByTestId('studio-held-message');
136+
const needs = (inputKey: string) =>
137+
heldLine(tFormat('engine.studio.held.fieldNeedsInput', 'en', { input: t(inputKey, 'en'), field: label }));
138+
expect(message()).toHaveTextContent(needs('designer.field.summary.object'));
139+
140+
const child = controlUnder(t('designer.field.summary.object', 'en')) as HTMLInputElement;
141+
fireEvent.change(child, { target: { value: 'showcase_task' } });
142+
fireEvent.blur(child);
143+
await outlastDebounce();
144+
expect(puts, 'a child object alone is not a roll-up yet').toEqual([]);
145+
expect(message()).toHaveTextContent(needs('designer.field.summary.function'));
146+
147+
fireEvent.keyDown(controlUnder(t('designer.field.summary.function', 'en')), { key: 'ArrowDown' });
148+
fireEvent.click(
149+
within(await screen.findByRole('listbox')).getByRole('option', { name: t('designer.field.summary.fn.count', 'en') }),
150+
);
151+
await waitFor(() => expect(puts.length).toBe(1), { timeout: 4000 });
152+
expect(lastSentField(name)).toEqual(
153+
expect.objectContaining({ type: 'summary', summaryOperations: { object: 'showcase_task', function: 'count' } }),
154+
);
155+
await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull());
156+
expect(screen.queryByTestId('studio-refusal')).toBeNull();
157+
});
158+
159+
it('"Show me" on the line reopens the held roll-up once its inspector is closed', async () => {
160+
// The first case proves the roll-up is never sent; this one needs only the line.
161+
const { name } = await addRollup();
162+
163+
const railHeader = screen.getByText(t('engine.studio.data.fieldProps', 'en')).closest('header') as HTMLElement;
164+
fireEvent.click(within(railHeader).getByRole('button', { name: t('engine.studio.close', 'en') }));
165+
expect(screen.queryByText('API name', { selector: 'label' })).toBeNull();
166+
167+
fireEvent.click(within(screen.getByTestId('studio-held')).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }));
168+
expect(await screen.findByText('API name', { selector: 'label' })).toBeInTheDocument();
169+
expect((controlUnder('API name') as HTMLInputElement).value).toBe(name);
170+
});
171+
});

0 commit comments

Comments
 (0)