Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/27485-studio-formula-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

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)

Clause-②: no
Metadata-protocol: none

- 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.
- 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.
- 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.
10 changes: 10 additions & 0 deletions .changeset/27511-studio-rail-switch-sends-pending-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(studio): switching objects in the Data pillar's rail sends an edit made inside the autosave delay instead of dropping it (#27511)

Clause-②: no
Metadata-protocol: none

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27485 — a formula that does not parse keeps the field's stored
* `returnType`.
*
* The inspector stamps `returnType` from the formula's inferred CEL type once
* the author edits it. A source with a parse fault infers no type (`unknown`),
* so every keystroke that broke the formula cleared the stored type, and the
* broken body reached the server without it. The stamp is now settled from the
* editor's two verdicts on one source: a source with a lint error proves no
* type and the stored one stays; a source that lints clean stamps the proven
* type, or clears it when none is proven, as before.
*
* The engine is stubbed so both verdicts are deterministic; the live lint and
* inference are CelPredicateField.test.tsx's job.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';

vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({
list: vi.fn().mockResolvedValue([]),
listDrafts: vi.fn().mockResolvedValue([]),
}),
}));

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { ObjectFieldInspector } from './ObjectFieldInspector';
import { __setCelFormulaLoader } from '../celAuthoring';

afterEach(() => {
cleanup();
__setCelFormulaLoader(undefined);
});

/** The card's repro shape: a source left open on a bracket or an operator. */
const BROKEN = /[*+\-/&|=<>(]\s*$/;

function stubEngine() {
__setCelFormulaLoader(() =>
Promise.resolve({
validateExpression: (_role: string, input: unknown) => {
const src = typeof input === 'string' ? input : String((input as { source?: string })?.source ?? '');
return BROKEN.test(src)
? { ok: false, errors: [{ message: 'Parse error: unexpected end of input' }], warnings: [] }
: { ok: true, errors: [], warnings: [] };
},
introspectScope: () => ({ fields: ['est_hours'], roots: ['record'], functions: ['has'] }),
// A broken source proves no type, as the engine answers it.
inferExpressionType: (input: unknown) => {
const src = typeof input === 'string' ? input : '';
if (BROKEN.test(src)) return 'unknown' as const;
if (src.includes("'")) return 'text' as const;
if (src.includes('+')) return 'unknown' as const;
return 'number' as const;
},
}),
);
}

function Harness({ onDef }: { onDef: (def: Record<string, unknown>) => void }) {
const [fields, setFields] = React.useState<Record<string, Record<string, unknown>>>({
est_hours: { type: 'number' },
f_total: { type: 'formula', label: 'F Total', expression: 'record.est_hours * 2', returnType: 'number' },
});
React.useEffect(() => {
onDef(fields.f_total);
});
return (
<ObjectFieldInspector
type="object"
name="work_order"
draft={{ name: 'work_order', fields }}
selection={{ kind: 'field', id: 'f_total' }}
onPatch={(patch: Record<string, unknown>) => setFields(patch.fields as typeof fields)}
onClearSelection={() => {}}
onSelectionChange={() => {}}
readOnly={false}
locale={'en-US'}
onBlockingIssuesChange={() => {}}
/>
);
}

function renderFormula() {
stubEngine();
const defs: Array<Record<string, unknown>> = [];
render(<Harness onDef={(def) => defs.push(def)} />);
const box = screen.getByText('Formula (CEL)').parentElement!.querySelector('[role="combobox"]') as HTMLTextAreaElement;
const latest = () => defs.at(-1)!;
return { box, latest };
}

const settle = () => new Promise((r) => setTimeout(r, 600));

describe('ObjectFieldInspector — a broken formula keeps the stored returnType (objectstack#27485)', () => {
it("the card's repro: `record.est_hours > (` leaves returnType as stored", async () => {
const { box, latest } = renderFormula();
fireEvent.change(box, { target: { value: 'record.est_hours > (' } });
await waitFor(() => expect(screen.getByText(/Parse error/)).toBeInTheDocument(), { timeout: 3000 });
await settle();
expect(latest().expression).toBe('record.est_hours > (');
expect(latest().returnType).toBe('number');
});

it('a source that parses again stamps its proven type', async () => {
const { box, latest } = renderFormula();
fireEvent.change(box, { target: { value: 'record.est_hours > (' } });
await waitFor(() => expect(screen.getByText(/Parse error/)).toBeInTheDocument(), { timeout: 3000 });
fireEvent.change(box, { target: { value: "'hours: ' + string(record.est_hours)" } });
await waitFor(() => expect(latest().returnType).toBe('text'), { timeout: 3000 });
});

it('a source that parses and proves no type still clears it, as before', async () => {
const { box, latest } = renderFormula();
fireEvent.change(box, { target: { value: 'record.est_hours + record.est_hours' } });
await waitFor(() => expect(latest().returnType).toBeUndefined(), { timeout: 3000 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ import {
useExternalWriteState,
} from '../previews/external-object.js';
import { CelPredicateField } from '../CelPredicateField.js';
import type { CelLintIssue } from '../celAuthoring.js';
import type { CelLintIssue, CelValueType } from '../celAuthoring.js';
import { t, tFormat, type SupportedLocale } from '../i18n.js';
import { FilterConditionField } from './FilterConditionField.js';
import type { FilterCondition } from './datasetFilterCondition.js';
Expand Down Expand Up @@ -726,6 +726,20 @@ export function ObjectFieldInspector({
React.useEffect(() => {
formulaEdited.current = false;
}, [name]);
// objectstack#27485 — the formula editor's two verdicts on one source, its
// lint and its inferred type, which arrive separately and in either order.
// The stamp is settled once both are in for the source the draft holds: a
// source with a lint error proves no type, so the stored `returnType` stays
// (it was cleared on every keystroke that broke the formula, and the broken
// body then reached the server without it); a source that lints clean
// stamps the proven type, or clears it, as before.
const formulaVerdictRef = React.useRef<{
field: string;
source: string;
errors?: number;
typed?: boolean;
inferred?: CelValueType | null;
}>({ field: '', source: '' });

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

/** objectstack#27485 — records one of the formula's verdicts and settles `returnType` (see `formulaVerdictRef`). */
const settleFormulaType = (verdict: { errors: number } | { typed: true; inferred: CelValueType | null }) => {
const source = readPredicate(def.expression ?? def.formula);
const was = formulaVerdictRef.current;
const same = was.field === entry.name && was.source === source;
const now = { ...(same ? was : { field: entry.name, source }), ...verdict };
formulaVerdictRef.current = now;
if (!formulaEdited.current || now.errors === undefined || !now.typed || now.errors > 0) return;
// Spec: `returnType` carries only a PROVEN concrete type —
// an ambiguous (`unknown`) or unavailable verdict clears it.
const next = now.inferred && now.inferred !== 'unknown' ? now.inferred : undefined;
if (def.returnType !== next) patchDef({ returnType: next });
};

/**
* objectui#10202 — "use picklist". Naming a list sets `picklist` and removes
* `options` outright (the key is ABSENT, as `unsetDefKey` leaves it): the
Expand Down Expand Up @@ -1384,14 +1412,11 @@ export function ObjectFieldInspector({
...(v.trim() ? {} : { returnType: undefined }),
});
}}
onLintChange={(issues) => reportCel('formula', issues)}
onInferredTypeChange={(inferred) => {
if (!formulaEdited.current) return;
// Spec: `returnType` carries only a PROVEN concrete type —
// an ambiguous (`unknown`) or unavailable verdict clears it.
const next = inferred && inferred !== 'unknown' ? inferred : undefined;
if (def.returnType !== next) patchDef({ returnType: next });
onLintChange={(issues) => {
reportCel('formula', issues);
settleFormulaType({ errors: issues.filter((i) => i.severity === 'error').length });
}}
onInferredTypeChange={(inferred) => settleFormulaType({ typed: true, inferred })}
disabled={readOnly}
placeholder="record.amount * 0.2"
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,25 @@
*
* - the count actually reaches the button and disables it, with the same
* message the RLS editor already shows;
* - 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.
* - objectstack#27485 — the verdict belongs to the DRAFT, not to the
* selection: closing the inspector (Close, Escape, another field, another
* tab) keeps the hold while the draft still carries the text it judged, and
* a switch to another object asks before it drops the held edit. The
* maintainer's ruling on #27485 (comment 6500685972, 2026-10-29T15:17Z)
* replaced sub-decision A's reset-on-close mechanism with this hold; the
* objectui#4306 ruling's sub-decision A stands as its two invariants:
* writability never depends on a child's teardown running (the kept verdict
* is read off the draft, and an edit, the field's removal or a load ends
* it), and Save is never wedged with nothing on screen to fix (the held line
* names the field, and its "Show me" reopens the editor).
*
* The engine is stubbed so the verdict is deterministic; the live lint is
* CelPredicateField.test.tsx's job.
*/

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

Expand All @@ -39,11 +46,18 @@ const objectDef = {
],
};

const siteDef = { name: 'showcase_site', label: 'Site', fields: [{ name: 'address', label: 'Address', type: 'text' }] };

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]),
list: vi.fn(async () => [
{ name: 'showcase_task', label: 'Task' },
{ name: 'showcase_site', label: 'Site' },
]),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
layered: vi.fn(async (_type: string, name: string) =>
name === 'showcase_site' ? { effective: siteDef, code: siteDef } : { effective: objectDef, code: objectDef },
),
getDraft: vi.fn(async () => null),
};

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

// This environment ships no window.confirm: the pillar's discard question.
let confirmSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
confirmSpy = vi.fn(() => false);
window.confirm = confirmSpy as unknown as typeof window.confirm;
});

afterEach(() => {
cleanup();
__setCelFormulaLoader(undefined);
Expand Down Expand Up @@ -172,23 +193,74 @@ describe('DataPillar — auto-save is gated on the field inspector’s CEL verdi
fireEvent.change(box, { target: { value: 'record.est_hours * 2' } });
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
});
});

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

fireEvent.click(screen.getByRole('button', { name: /Site/ }));
expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(box.value).toBe('record.est_hours *');

confirmSpy.mockReturnValue(true);
fireEvent.click(screen.getByRole('button', { name: /Site/ }));
await waitFor(() => expect(screen.queryByText('Est hours')).toBeNull(), { timeout: 3000 });
await new Promise((r) => setTimeout(r, 2300));
expect(mockClient.save).not.toHaveBeenCalled();
});
});

/** The ways the field inspector closes (the card's step 4). */
const CLOSERS: Array<[string, () => void]> = [
['its Close button', () => fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0])],
['Escape', () => fireEvent.keyDown(document.body, { key: 'Escape' })],
['another field card', () => fireEvent.click((screen.getByText('Est hours').closest('.cursor-grab') as HTMLElement))],
['the Records tab', () => fireEvent.click(screen.getByRole('button', { name: 'Records' }))],
];

describe('DataPillar — the CEL hold belongs to the draft, not to the open inspector (objectstack#27485)', () => {
async function breakFormula(): Promise<HTMLTextAreaElement> {
const box = await openFormulaField();
fireEvent.change(box, { target: { value: 'record.est_hours *' } });
await waitFor(() => expect(screen.getByTestId('studio-held-message')).toHaveTextContent('Total'), { timeout: 3000 });
return box;
}

it.each(CLOSERS)('closing the inspector by %s keeps the hold, and the held line names the field', async (_how, close) => {
await breakFormula();
close();
await new Promise((r) => setTimeout(r, 2300));
expect(mockClient.save).not.toHaveBeenCalled();
expect(screen.getByTestId('studio-held-message')).toHaveTextContent('Total');
});

// Two "Close" buttons dismiss the panel — the rail header's and the
// inspector shell's own; either clears the selection (and the block).
it('"Show me" reopens the field, and fixing it there releases the hold', async () => {
await breakFormula();
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
await waitFor(() => expect(screen.queryByText('Formula (CEL)')).toBeNull(), { timeout: 3000 });
fireEvent.click(screen.getByRole('button', { name: 'Show me' }));
const label = await screen.findByText('Formula (CEL)');
const box = label.parentElement!.querySelector('[role="combobox"]') as HTMLTextAreaElement;
expect(box.value).toBe('record.est_hours *');
fireEvent.change(box, { target: { value: 'record.est_hours * 2' } });
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
expect(screen.queryByTestId('studio-held')).toBeNull();
});

it('removing the field ends the hold: nothing judged is left in the draft', async () => {
await breakFormula();
fireEvent.click(screen.getByRole('button', { name: 'Remove “Total”' }));
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 4000 });
const body = (mockClient.save.mock.calls.at(-1) as unknown[] | undefined)?.[2] as { fields: Array<{ name: string }> };
expect(body.fields.map((f) => f.name)).toEqual(['est_hours']);
});
});
Loading
Loading