Skip to content

Commit aa874c8

Browse files
fix(studio): a Data rail switch sends the pending edit first; a broken CEL formula stays held after the inspector closes and keeps its returnType (#27511, #27485) (#27617)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - **改了什么**:Studio 数据栏切换对象前,先用 `useDraftAutoSave` 现有的 `flush` 把 1.5 秒防抖内的编辑发出去,存不了的先问;CEL 拦截从「检查器开着」改为跟草稿走,关检查器不再放行坏公式;坏公式不再清掉字段已存的 `returnType`。 - **为什么改**:#27511 切换对象会静默丢掉 1.5 秒内的编辑;#27485 关检查器会把解析不过的公式自动存成草稿(还丢了 `returnType`),切换对象会静默丢掉被拦的编辑。 - **风险与回滚**:只动 app-shell 的数据栏和字段检查器,加一条 i18n 文案;没有第二条自动保存路径。三个按卡的提交可单独 revert;关检查器那一臂是独立提交 73b0ad8(见 Acceptance notes 的 objectui#4306 子决定 A 冲突)。 - **先看**:`StudioDesignSurface.tsx` 的 `openObject` 与 CEL 判定保留(`celVerdicts` / `fieldCelStamp`);`ObjectFieldInspector.tsx` 的 `settleFormulaType`。 - **两分钟验证**:数据栏打开一个对象,加一个字段后 1 秒内点另一个对象,再点回来,字段还在;公式字段输入 `record.est_hours > (`,按 Esc 关检查器,出现「未保存」提示,点名该字段并带「Show me」,且没有发出保存。 - **需要你做的**:裁定 objectui#4306 子决定 A 与 #27485 关闭臂的冲突(Acceptance notes 第 2 条);维持原裁定就 revert 73b0ad8,其余照常合并。 Fixes #27511 Fixes #27485 Clause-②: no Metadata-protocol: none ## What changes **#27511, the rail switch inside the autosave delay.** The Data pillar's rail now opens an object through one `openObject` gate. With an edit pending inside the 1.5 s debounce, it calls the autosave's existing `flush` and opens the next object once that save has landed; a save already in flight is waited out the same way. An edit that cannot be sent (its save was refused, or Studio holds it as unfinished) goes through the existing `confirmDropUnsavedWork` question; declining keeps the open object and its edit. The `requestedSurface` deep-link path uses the same gate. No second autosave path: the timer, the target guard and the single-flight of `useDraftAutoSave` are untouched (only its comment now names the caller that flushes). **#27485, returnType.** The field inspector stamped `returnType` from the formula's inferred type on every edit, and a source that does not parse infers `unknown`, so each broken keystroke cleared the stored type. `settleFormulaType` now settles the stamp only once the editor's lint verdict and inference verdict are both in for the same source: a source with a lint error keeps the stored type; a clean source stamps its proven type, or clears it when none is proven, as before. The order of the two async callbacks does not matter. **#27485, switch arm.** A switch over a held edit (a CEL error) never flushes; it asks through the same gate as above. **#27485, close arm (own commit 73b0ad8).** The inspector's CEL count used to be keyed to the open field selection, so closing the inspector (Close, Escape, another field card, another tab) zeroed it and the autosave sent the broken expression. The pillar now keeps each inspector verdict with a stamp of the field's CEL-bearing properties and the field list (`fieldCelStamp`). A kept verdict holds the save while the draft still carries that exact text, and ends when that text is edited, the field is removed, or the object reloads. While it holds, the pillar shows the existing held line with a new clause (`engine.studio.held.celInvalid`, en + zh) and **Show me**, which reopens the field; Studio's Publish refusal names it through the existing held-edit report. ## Why Both cards are one defect form: a pending or held Studio draft edit was tied to the current selection (open inspector, object in the rail) rather than to the draft, so changing the selection dropped the edit or released its hold. Triage asked for exactly this: "The rail switch calls useDraftAutoSave's existing flush, or confirms first … with no second autosave path" and "Tie the CEL hold to the draft rather than to the inspector's selection, and preserve returnType." ## Tests - `DataPillar.railSwitchFlush-27511.test.tsx` (new, 4): flush then switch sends one save to the old object with the new field and none to the new one; a save in flight is waited out; a refused save asks, a declined ask stays, an agreed ask drops it unsent; a switch with nothing pending opens at once and sends nothing. - `ObjectFieldInspector.returnTypeHeld-27485.test.tsx` (new, 3): the card's repro keeps `number`; a source that parses again stamps `text`; a clean source that proves no type still clears it. - `DataPillar.celGate.test.tsx`: new switch-arm case (asks; declined stays; agreed drops unsent); close arm: the hold survives the Close button, Escape, another field card and the Records tab, and the held line names the field; **Show me** reopens it and a fix releases the hold; removing the field ends the hold. The old case "drops the count when the inspector closes, so the draft cannot wedge unsaved" is removed (see Acceptance notes). - `StudioDesignSurface.autosaveSwitch-11232.test.tsx`: the Data control case now expects the pending edit to go to its own object and still nothing to the newly opened one. Ablations, each through `scripts/ablation-replace.mjs`, restored to HEAD with an empty `git diff HEAD` afterwards: | Ablation | Result | |---|---| | Rail onClick back to a bare `setCurrent(o)` | railSwitchFlush 3 of 4 red (nothing-pending case stays green, as it should) | | `settleFormulaType` without its `errors > 0` guard | returnType repro red; the other two green | | `confirmDropUnsavedWork` line removed from `openObject` | refused-save case and the celGate switch-arm case red | | Kept-verdict condition forced false | celGate 5 red (four closers and Show me) | ## Acceptance notes 1. **File surface widened, seat-approved.** `ui/packages/i18n/src/namespaces/studio/en.ts` and `zh.ts` gain one key, `engine.studio.held.celInvalid`, for the held clause. The ui seat 3 seat approved this widening at 13:14:22Z on 2026-10-29. 2. **Conflict with objectui#4306 sub-decision A (ruled by the maintainer 2026-10-29T15:17Z: the close arm stays; sub-decision A's reset-on-close mechanism is replaced, its two invariants stand).** The base test file restated the ruling as: "the host RESETS its own count when the selection changes or the inspector unmounts (ruling sub-decision A) — writability must never depend on a child's teardown running, or closing the panel on a faulty formula leaves Save wedged shut with no editor on screen to fix it", and pinned it with the case "drops the count when the inspector closes, so the draft cannot wedge unsaved". #27485's triage asks for the opposite at the close: "Tie the CEL hold to the draft rather than to the inspector's selection". This PR follows the card and keeps the ruling's two invariants instead of its mechanism: writability does not depend on a child's teardown (the verdict lives in host state, stamped to the draft), and the draft never wedges with no editor on screen (the held line names the field and **Show me** reopens it; editing the text, removing the field or reloading ends the hold). objectui#4306 itself was not readable from this session (403), so the quote above is the in-repo restatement. The close arm is its own commit, 73b0ad8, separable: at its parent 19ceb5d the four touched suites pass 25/25, so if the ruling stands, reverting 73b0ad8 leaves #27511, returnType and the switch arm intact (#27485 would then stay open for its close arm). Ruling-ref: 6500685972 3. **Commit shape deviation.** The dispatch asked for one commit per card. A WIP commit holding both cards' code (5002d07) was pushed before the split; with no force push allowed, it stays, and 59a0510 takes the close arm back out so the following commits are one per card (e72223f #27511, 19ceb5d #27485 returnType and switch arm, 73b0ad8 #27485 close arm). A merge of `main` brings in #27340, which also touches `ObjectFieldInspector.tsx` in another region; no conflict. 4. **Residual windows (not fixed here).** The inspector lints 250 ms after the last keystroke. Escape or a switch inside that window can still read the previous verdict (a stale 0 releases nothing new, but the last keystroke's fault is unjudged). Reopening a held field: the inspector's mount report of 0 releases the hold for about 250 ms until its lint runs. Closing inside the window right after a fix can leave a false hold; reopening the field clears it. 5. **Same defect class, not measured.** The panel family's CEL hold (Validations and the other tabs, keyed by tab) has the same selection-keyed shape. Out of scope for both cards; noted, not filed. 6. `CelPredicateField` was not touched; the returnType fix is wholly in the inspector. ## Verification On head efb181a (after merging `main` at 1ed3360): - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design packages/app-shell/src/views/metadata-admin/inspectors`: 460 files, 4610 passed, 1 skipped, 0 failed (under the verification lock). - The four touched suites at 19ceb5d (the close arm's parent): 25/25; at head: 30/30. - `pnpm --filter @object-ui/app-shell type-check`: exit 0 (after rebuilding `@object-ui/core`, whose new `measureCellCurrency` export #27340 brought in; the first run read the stale dist). `pnpm --filter @object-ui/i18n type-check`: exit 0. - `eslint` on the 9 changed TS/TSX files: 0 errors (warnings are pre-existing `react-hooks/set-state-in-effect` lines elsewhere in the surface). - `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived, 34 run, 0 not measured. 33 exit 0. `pnpm --dir ui exec vitest run scripts/` exited 1 on five 15 s timeouts while the 460-file run held the box; alone under the lock it was 1 timeout (`check-vi-mock-inherit`, a whole-tree sweep at 16 s), and that file alone is 83/83 green. None of the five reads a file this PR touches. - `node scripts/check-sdui-manifest.mjs`: exit 0. `node scripts/check-nul-bytes.mjs`: exit 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the changed files has 0 errors (the full `pnpm lint` is left to CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (two folded cards, one PR, per the dispatch) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the maintainer brief is in Chinese by the requester's standing rule) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbCrKUjGeTReNEmUJx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e16a7af commit aa874c8

11 files changed

Lines changed: 673 additions & 47 deletions
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): closing the field inspector keeps a formula that does not parse unsaved, with the field's stored result type, and a switch to another object asks before it drops it (#27485)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- The Data pillar holds a field's draft while its CEL formula or condition does not parse. That hold used to end the moment the field inspector closed (Escape, Close, another field card, another tab), and the autosave then sent the broken expression as the object's draft, which publish refuses. The hold now belongs to the draft: it lasts while the draft still carries the text the inspector judged, and ends when that text is edited, the field is removed or the object reloads. While it lasts, the pillar shows the held line ("Not saved yet: an expression on the field “F Total” has an error to fix…") with a **Show me** that reopens the field, and the Studio's Publish names it.
12+
- Typing a formula that does not parse (`record.est_hours > (`) cleared the field's stored `returnType`, because a broken source proves no type. The stored type now stays while the formula has a lint error; a formula that parses stamps its proven type, or clears it when none is proven, as before.
13+
- With such an edit held, switching to another object in the Data pillar's rail dropped it silently. The switch now asks first; declining keeps the object and the edit.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(studio): switching objects in the Data pillar's rail sends an edit made inside the autosave delay instead of dropping it (#27511)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Studio autosave sends a draft 1.5 s after the last edit, and sends nothing once another object has been opened. An edit made less than 1.5 s before a click on another object in the Data pillar's rail (a field removed, a label renamed) was therefore dropped, with nothing on screen, and was gone after a reload. The switch now sends that edit to its own object first and opens the next object once the save has landed; a save already in flight is waited out the same way. An edit that cannot be sent (its save was refused, or Studio holds it as unfinished) is not dropped silently: the switch asks first, and declining keeps the open object and its edit.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27485 — a formula that does not parse keeps the field's stored
5+
* `returnType`.
6+
*
7+
* The inspector stamps `returnType` from the formula's inferred CEL type once
8+
* the author edits it. A source with a parse fault infers no type (`unknown`),
9+
* so every keystroke that broke the formula cleared the stored type, and the
10+
* broken body reached the server without it. The stamp is now settled from the
11+
* editor's two verdicts on one source: a source with a lint error proves no
12+
* type and the stored one stays; a source that lints clean stamps the proven
13+
* type, or clears it when none is proven, as before.
14+
*
15+
* The engine is stubbed so both verdicts are deterministic; the live lint and
16+
* inference are CelPredicateField.test.tsx's job.
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import * as React from 'react';
21+
import { describe, it, expect, vi, afterEach } from 'vitest';
22+
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
23+
24+
vi.mock('../useMetadata', () => ({
25+
useMetadataClient: () => ({
26+
list: vi.fn().mockResolvedValue([]),
27+
listDrafts: vi.fn().mockResolvedValue([]),
28+
}),
29+
}));
30+
31+
vi.mock('../previews/useObjectFields', () => ({
32+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
33+
}));
34+
35+
import { ObjectFieldInspector } from './ObjectFieldInspector';
36+
import { __setCelFormulaLoader } from '../celAuthoring';
37+
38+
afterEach(() => {
39+
cleanup();
40+
__setCelFormulaLoader(undefined);
41+
});
42+
43+
/** The card's repro shape: a source left open on a bracket or an operator. */
44+
const BROKEN = /[*+\-/&|=<>(]\s*$/;
45+
46+
function stubEngine() {
47+
__setCelFormulaLoader(() =>
48+
Promise.resolve({
49+
validateExpression: (_role: string, input: unknown) => {
50+
const src = typeof input === 'string' ? input : String((input as { source?: string })?.source ?? '');
51+
return BROKEN.test(src)
52+
? { ok: false, errors: [{ message: 'Parse error: unexpected end of input' }], warnings: [] }
53+
: { ok: true, errors: [], warnings: [] };
54+
},
55+
introspectScope: () => ({ fields: ['est_hours'], roots: ['record'], functions: ['has'] }),
56+
// A broken source proves no type, as the engine answers it.
57+
inferExpressionType: (input: unknown) => {
58+
const src = typeof input === 'string' ? input : '';
59+
if (BROKEN.test(src)) return 'unknown' as const;
60+
if (src.includes("'")) return 'text' as const;
61+
if (src.includes('+')) return 'unknown' as const;
62+
return 'number' as const;
63+
},
64+
}),
65+
);
66+
}
67+
68+
function Harness({ onDef }: { onDef: (def: Record<string, unknown>) => void }) {
69+
const [fields, setFields] = React.useState<Record<string, Record<string, unknown>>>({
70+
est_hours: { type: 'number' },
71+
f_total: { type: 'formula', label: 'F Total', expression: 'record.est_hours * 2', returnType: 'number' },
72+
});
73+
React.useEffect(() => {
74+
onDef(fields.f_total);
75+
});
76+
return (
77+
<ObjectFieldInspector
78+
type="object"
79+
name="work_order"
80+
draft={{ name: 'work_order', fields }}
81+
selection={{ kind: 'field', id: 'f_total' }}
82+
onPatch={(patch: Record<string, unknown>) => setFields(patch.fields as typeof fields)}
83+
onClearSelection={() => {}}
84+
onSelectionChange={() => {}}
85+
readOnly={false}
86+
locale={'en-US'}
87+
onBlockingIssuesChange={() => {}}
88+
/>
89+
);
90+
}
91+
92+
function renderFormula() {
93+
stubEngine();
94+
const defs: Array<Record<string, unknown>> = [];
95+
render(<Harness onDef={(def) => defs.push(def)} />);
96+
const box = screen.getByText('Formula (CEL)').parentElement!.querySelector('[role="combobox"]') as HTMLTextAreaElement;
97+
const latest = () => defs.at(-1)!;
98+
return { box, latest };
99+
}
100+
101+
const settle = () => new Promise((r) => setTimeout(r, 600));
102+
103+
describe('ObjectFieldInspector — a broken formula keeps the stored returnType (objectstack#27485)', () => {
104+
it("the card's repro: `record.est_hours > (` leaves returnType as stored", async () => {
105+
const { box, latest } = renderFormula();
106+
fireEvent.change(box, { target: { value: 'record.est_hours > (' } });
107+
await waitFor(() => expect(screen.getByText(/Parse error/)).toBeInTheDocument(), { timeout: 3000 });
108+
await settle();
109+
expect(latest().expression).toBe('record.est_hours > (');
110+
expect(latest().returnType).toBe('number');
111+
});
112+
113+
it('a source that parses again stamps its proven type', async () => {
114+
const { box, latest } = renderFormula();
115+
fireEvent.change(box, { target: { value: 'record.est_hours > (' } });
116+
await waitFor(() => expect(screen.getByText(/Parse error/)).toBeInTheDocument(), { timeout: 3000 });
117+
fireEvent.change(box, { target: { value: "'hours: ' + string(record.est_hours)" } });
118+
await waitFor(() => expect(latest().returnType).toBe('text'), { timeout: 3000 });
119+
});
120+
121+
it('a source that parses and proves no type still clears it, as before', async () => {
122+
const { box, latest } = renderFormula();
123+
fireEvent.change(box, { target: { value: 'record.est_hours + record.est_hours' } });
124+
await waitFor(() => expect(latest().returnType).toBeUndefined(), { timeout: 3000 });
125+
});
126+
});

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ import {
121121
useExternalWriteState,
122122
} from '../previews/external-object.js';
123123
import { CelPredicateField } from '../CelPredicateField.js';
124-
import type { CelLintIssue } from '../celAuthoring.js';
124+
import type { CelLintIssue, CelValueType } from '../celAuthoring.js';
125125
import { t, tFormat, type SupportedLocale } from '../i18n.js';
126126
import { FilterConditionField } from './FilterConditionField.js';
127127
import type { FilterCondition } from './datasetFilterCondition.js';
@@ -726,6 +726,20 @@ export function ObjectFieldInspector({
726726
React.useEffect(() => {
727727
formulaEdited.current = false;
728728
}, [name]);
729+
// objectstack#27485 — the formula editor's two verdicts on one source, its
730+
// lint and its inferred type, which arrive separately and in either order.
731+
// The stamp is settled once both are in for the source the draft holds: a
732+
// source with a lint error proves no type, so the stored `returnType` stays
733+
// (it was cleared on every keystroke that broke the formula, and the broken
734+
// body then reached the server without it); a source that lints clean
735+
// stamps the proven type, or clears it, as before.
736+
const formulaVerdictRef = React.useRef<{
737+
field: string;
738+
source: string;
739+
errors?: number;
740+
typed?: boolean;
741+
inferred?: CelValueType | null;
742+
}>({ field: '', source: '' });
729743

730744
/* ─── Who owns the API name (objectui#7615) ──────────────────────────────
731745
*
@@ -892,6 +906,20 @@ export function ObjectFieldInspector({
892906
writeView({ shape: view.shape, entries: nextEntries });
893907
};
894908

909+
/** objectstack#27485 — records one of the formula's verdicts and settles `returnType` (see `formulaVerdictRef`). */
910+
const settleFormulaType = (verdict: { errors: number } | { typed: true; inferred: CelValueType | null }) => {
911+
const source = readPredicate(def.expression ?? def.formula);
912+
const was = formulaVerdictRef.current;
913+
const same = was.field === entry.name && was.source === source;
914+
const now = { ...(same ? was : { field: entry.name, source }), ...verdict };
915+
formulaVerdictRef.current = now;
916+
if (!formulaEdited.current || now.errors === undefined || !now.typed || now.errors > 0) return;
917+
// Spec: `returnType` carries only a PROVEN concrete type —
918+
// an ambiguous (`unknown`) or unavailable verdict clears it.
919+
const next = now.inferred && now.inferred !== 'unknown' ? now.inferred : undefined;
920+
if (def.returnType !== next) patchDef({ returnType: next });
921+
};
922+
895923
/**
896924
* objectui#10202 — "use picklist". Naming a list sets `picklist` and removes
897925
* `options` outright (the key is ABSENT, as `unsetDefKey` leaves it): the
@@ -1384,14 +1412,11 @@ export function ObjectFieldInspector({
13841412
...(v.trim() ? {} : { returnType: undefined }),
13851413
});
13861414
}}
1387-
onLintChange={(issues) => reportCel('formula', issues)}
1388-
onInferredTypeChange={(inferred) => {
1389-
if (!formulaEdited.current) return;
1390-
// Spec: `returnType` carries only a PROVEN concrete type —
1391-
// an ambiguous (`unknown`) or unavailable verdict clears it.
1392-
const next = inferred && inferred !== 'unknown' ? inferred : undefined;
1393-
if (def.returnType !== next) patchDef({ returnType: next });
1415+
onLintChange={(issues) => {
1416+
reportCel('formula', issues);
1417+
settleFormulaType({ errors: issues.filter((i) => i.severity === 'error').length });
13941418
}}
1419+
onInferredTypeChange={(inferred) => settleFormulaType({ typed: true, inferred })}
13951420
disabled={readOnly}
13961421
placeholder="record.amount * 0.2"
13971422
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}

‎ui/packages/app-shell/src/views/studio-design/DataPillar.celGate.test.tsx‎

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,25 @@
1515
*
1616
* - the count actually reaches the button and disables it, with the same
1717
* message the RLS editor already shows;
18-
* - the host RESETS its own count when the selection changes or the inspector
19-
* unmounts (ruling sub-decision A) — writability must never depend on a
20-
* child's teardown running, or closing the panel on a faulty formula leaves
21-
* Save wedged shut with no editor on screen to fix it.
18+
* - objectstack#27485 — the verdict belongs to the DRAFT, not to the
19+
* selection: closing the inspector (Close, Escape, another field, another
20+
* tab) keeps the hold while the draft still carries the text it judged, and
21+
* a switch to another object asks before it drops the held edit. The
22+
* maintainer's ruling on #27485 (comment 6500685972, 2026-10-29T15:17Z)
23+
* replaced sub-decision A's reset-on-close mechanism with this hold; the
24+
* objectui#4306 ruling's sub-decision A stands as its two invariants:
25+
* writability never depends on a child's teardown running (the kept verdict
26+
* is read off the draft, and an edit, the field's removal or a load ends
27+
* it), and Save is never wedged with nothing on screen to fix (the held line
28+
* names the field, and its "Show me" reopens the editor).
2229
*
2330
* The engine is stubbed so the verdict is deterministic; the live lint is
2431
* CelPredicateField.test.tsx's job.
2532
*/
2633

2734
import '@testing-library/jest-dom/vitest';
2835
import * as React from 'react';
29-
import { afterEach, describe, expect, it, vi } from 'vitest';
36+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3037
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
3138
import { MemoryRouter } from 'react-router-dom';
3239

@@ -39,11 +46,18 @@ const objectDef = {
3946
],
4047
};
4148

49+
const siteDef = { name: 'showcase_site', label: 'Site', fields: [{ name: 'address', label: 'Address', type: 'text' }] };
50+
4251
const mockClient = {
4352
save: vi.fn(async () => ({})),
44-
list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]),
53+
list: vi.fn(async () => [
54+
{ name: 'showcase_task', label: 'Task' },
55+
{ name: 'showcase_site', label: 'Site' },
56+
]),
4557
listDrafts: vi.fn(async () => []),
46-
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
58+
layered: vi.fn(async (_type: string, name: string) =>
59+
name === 'showcase_site' ? { effective: siteDef, code: siteDef } : { effective: objectDef, code: objectDef },
60+
),
4761
getDraft: vi.fn(async () => null),
4862
};
4963

@@ -103,6 +117,13 @@ failOnAbsorbedFetchError();
103117
// The right rail resolves the field inspector through the registry.
104118
registerBuiltinInspectors();
105119

120+
// This environment ships no window.confirm: the pillar's discard question.
121+
let confirmSpy: ReturnType<typeof vi.fn>;
122+
beforeEach(() => {
123+
confirmSpy = vi.fn(() => false);
124+
window.confirm = confirmSpy as unknown as typeof window.confirm;
125+
});
126+
106127
afterEach(() => {
107128
cleanup();
108129
__setCelFormulaLoader(undefined);
@@ -172,23 +193,74 @@ describe('DataPillar — auto-save is gated on the field inspector’s CEL verdi
172193
fireEvent.change(box, { target: { value: 'record.est_hours * 2' } });
173194
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
174195
});
196+
});
175197

176-
/**
177-
* Sub-decision A. Closing the panel unmounts the inspector, so nothing will
178-
* ever report `0` for it — the host must drop the count on its own, or the
179-
* dirty draft stays wedged UNSAVED forever with no editor on screen to fix.
180-
*/
181-
it('drops the count when the inspector closes, so the draft cannot wedge unsaved', async () => {
198+
/**
199+
* objectstack#27485, the switch arm — shared with objectstack#27511's
200+
* flush-or-ask switch: an edit the CEL verdict holds cannot be sent, so a
201+
* switch to another object asks before its load replaces the buffer.
202+
*/
203+
describe('DataPillar — a switch to another object over a held formula (objectstack#27485)', () => {
204+
it('asks; declined, the edit stays; agreed, it is dropped unsent', async () => {
182205
const box = await openFormulaField();
206+
fireEvent.change(box, { target: { value: 'record.est_hours *' } });
207+
await new Promise((r) => setTimeout(r, 2300));
208+
expect(mockClient.save).not.toHaveBeenCalled();
183209

210+
fireEvent.click(screen.getByRole('button', { name: /Site/ }));
211+
expect(confirmSpy).toHaveBeenCalledTimes(1);
212+
expect(box.value).toBe('record.est_hours *');
213+
214+
confirmSpy.mockReturnValue(true);
215+
fireEvent.click(screen.getByRole('button', { name: /Site/ }));
216+
await waitFor(() => expect(screen.queryByText('Est hours')).toBeNull(), { timeout: 3000 });
217+
await new Promise((r) => setTimeout(r, 2300));
218+
expect(mockClient.save).not.toHaveBeenCalled();
219+
});
220+
});
221+
222+
/** The ways the field inspector closes (the card's step 4). */
223+
const CLOSERS: Array<[string, () => void]> = [
224+
['its Close button', () => fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0])],
225+
['Escape', () => fireEvent.keyDown(document.body, { key: 'Escape' })],
226+
['another field card', () => fireEvent.click((screen.getByText('Est hours').closest('.cursor-grab') as HTMLElement))],
227+
['the Records tab', () => fireEvent.click(screen.getByRole('button', { name: 'Records' }))],
228+
];
229+
230+
describe('DataPillar — the CEL hold belongs to the draft, not to the open inspector (objectstack#27485)', () => {
231+
async function breakFormula(): Promise<HTMLTextAreaElement> {
232+
const box = await openFormulaField();
184233
fireEvent.change(box, { target: { value: 'record.est_hours *' } });
234+
await waitFor(() => expect(screen.getByTestId('studio-held-message')).toHaveTextContent('Total'), { timeout: 3000 });
235+
return box;
236+
}
237+
238+
it.each(CLOSERS)('closing the inspector by %s keeps the hold, and the held line names the field', async (_how, close) => {
239+
await breakFormula();
240+
close();
185241
await new Promise((r) => setTimeout(r, 2300));
186242
expect(mockClient.save).not.toHaveBeenCalled();
243+
expect(screen.getByTestId('studio-held-message')).toHaveTextContent('Total');
244+
});
187245

188-
// Two "Close" buttons dismiss the panel — the rail header's and the
189-
// inspector shell's own; either clears the selection (and the block).
246+
it('"Show me" reopens the field, and fixing it there releases the hold', async () => {
247+
await breakFormula();
190248
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
191249
await waitFor(() => expect(screen.queryByText('Formula (CEL)')).toBeNull(), { timeout: 3000 });
250+
fireEvent.click(screen.getByRole('button', { name: 'Show me' }));
251+
const label = await screen.findByText('Formula (CEL)');
252+
const box = label.parentElement!.querySelector('[role="combobox"]') as HTMLTextAreaElement;
253+
expect(box.value).toBe('record.est_hours *');
254+
fireEvent.change(box, { target: { value: 'record.est_hours * 2' } });
255+
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
256+
expect(screen.queryByTestId('studio-held')).toBeNull();
257+
});
258+
259+
it('removing the field ends the hold: nothing judged is left in the draft', async () => {
260+
await breakFormula();
261+
fireEvent.click(screen.getByRole('button', { name: 'Remove “Total”' }));
192262
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
263+
const body = (mockClient.save.mock.calls.at(-1) as unknown[] | undefined)?.[2] as { fields: Array<{ name: string }> };
264+
expect(body.fields.map((f) => f.name)).toEqual(['est_hours']);
193265
});
194266
});

0 commit comments

Comments
 (0)