Skip to content

Commit b06f78a

Browse files
os-zhuangclaude
andauthored
fix(app-shell): inspectors read and write the expression envelope (#3218) (#3228)
`HookSchema.condition` is `ExpressionInputSchema`, so a persisted hook carries `{ dialect, source }` — the Hook inspector's guard box read a bare string only and rendered empty, and `ConditionBuilder.emit` compiles only the rows on screen, so the author's next edit replaced a guard they were never shown. Read side converges on `conditionText` (the one reader #3216 settled on) via a shared `expressionSource` / `writeExpressionSource` pair — no fourth `typeof c === 'string'`. Write side follows the #3218 ruling: preserve `dialect` and `meta`, replace `source`, DISCARD `ast` (derived from the old source; `objectstack compile` refills it). With no prior envelope the commit stays the bare-string shorthand, which the spec's pipe normalizes to `{ dialect: 'cel', source }`. Swept the rest of the family: action `visible` / `disabled`, the generic SchemaForm condition widget (which put a literal `[object Object]` in the editor), the object-validations rule `condition` plus its type-switch carry (a third narrow read that dropped the guard), and the flow-edge inspector's write, whose read #3216 had already fixed. Fixtures are authored input fed through `HookSchema.parse`, never hand-written envelopes, so they cannot drift from the spec. Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d868e1 commit b06f78a

9 files changed

Lines changed: 508 additions & 12 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
Inspectors read AND write the `{ dialect, source }` expression envelope (objectui#3218).
6+
7+
The Hook inspector's "Run only when (optional CEL)" box rendered **empty** for a
8+
hook that had a guard. `HookSchema.condition` is `ExpressionInputSchema` — the
9+
same `ZodPipe` as `FlowEdgeSchema.condition` — so parsing `condition: 'amount > 10'`
10+
**rewrites it into** `{ dialect: 'cel', source: 'amount > 10' }`. The envelope is
11+
what a persisted hook carries; the inspector read `typeof draft.condition ===
12+
'string'` and fell through to `''`.
13+
14+
An empty box is not a cosmetic defect here. `ConditionBuilder.emit` compiles only
15+
the rows currently on screen, so the author's next edit **replaced** a guard they
16+
were never shown (clearing it committed `condition: undefined`). Opening the
17+
panel is safe on its own — `onCommit` fires only on a real edit — but the empty
18+
box is what induces that edit.
19+
20+
**Read.** Every one of these surfaces now goes through `conditionText`, the one
21+
reader objectui#3216 settled on, via a shared `expressionSource` /
22+
`writeExpressionSource` pair. No new `typeof c === 'string'` was written.
23+
24+
**Write.** `source` was the only key the commit path preserved — everything else
25+
in the envelope was discarded, because the commit sent a bare string and the
26+
spec's pipe hardcodes `dialect: 'cel'`. Editing one character of a
27+
`dialect: 'cron'` or `dialect: 'template'` guard silently moved it to a different
28+
evaluation engine, and dropped `ast` and ADR-0089 `meta` (`rationale` /
29+
`generatedBy` — the keys AI-authored metadata fills and nobody restores by hand).
30+
An edit now:
31+
32+
| key | behaviour |
33+
|:--|:--|
34+
| `dialect` | **preserved** |
35+
| `meta` | **preserved** |
36+
| `source` | replaced |
37+
| `ast` | **discarded** — it was compiled from the OLD source, so keeping it would leave the engine evaluating the old guard while the UI shows the new one. `objectstack compile` refills it, and `ExpressionSchema`'s `source \|\| ast` refinement still holds. |
38+
39+
With no prior envelope to preserve, the commit stays the bare-string shorthand —
40+
which the spec's pipe normalizes to exactly `{ dialect: 'cel', source }`, so
41+
nothing is lost and plain-`string` predicate fields keep round-tripping as
42+
strings.
43+
44+
Four surfaces were in this family, not one:
45+
46+
- **Hook inspector**`condition` (the reported defect).
47+
- **Action inspector**`visible` and `disabled` (`boolean | ExpressionInput`),
48+
same empty-box read.
49+
- **The generic SchemaForm condition widget** — every predicate-named field
50+
(`visible` / `hidden` / `disabled` / `condition` / `predicate` / `*When`) routes
51+
here, and it did `String(value)`: an envelope reached the editor as the literal
52+
text `[object Object]`.
53+
- **Object validations panel** — the rule `condition`, plus a third narrow read
54+
in the type switcher that dropped a persisted guard on the floor and left the
55+
skeleton's never-firing `'false'` in its place. `ValidationRuleDraft.condition`
56+
is now `ExpressionInput` instead of `string`.
57+
58+
The flow-edge inspector's **write** is fixed the same way; objectui#3216 had
59+
converged only its read.
60+
61+
Fixtures in the new tests are authored input fed through `HookSchema.parse` — no
62+
envelope is hand-written — so they cannot drift from the spec.

packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { useObjectOptions } from '../previews/useObjectOptions';
5050
import { useObjectFields } from '../previews/useObjectFields';
5151
import { useMetaOptions } from '../previews/useMetaOptions';
5252
import { ConditionBuilder } from './ConditionBuilder';
53+
import { expressionSource, writeExpressionSource } from './expression-envelope';
5354
import { IconPickerWidget } from '../widgets';
5455

5556
/* ─────────────── constants ─────────────── */
@@ -464,8 +465,11 @@ export function ActionDefaultInspector({
464465
{/* 6 ─ Conditions */}
465466
<div className="border-t pt-3 space-y-3">
466467
<SectionHeader title="Conditions" hint="No-code predicates over the record / user / ctx (compiled to CEL)." />
467-
<ConditionBuilder label="Visible when" value={typeof draft.visible === 'string' ? (draft.visible as string) : ''} onCommit={(v) => onPatch({ visible: v || undefined })} objectName={objectName} disabled={readOnly} />
468-
<ConditionBuilder label="Disabled when" value={typeof draft.disabled === 'string' ? (draft.disabled as string) : ''} onCommit={(v) => onPatch({ disabled: v || undefined })} objectName={objectName} disabled={readOnly} />
468+
{/* Both are `ExpressionInputSchema` in the spec (`disabled` as
469+
`boolean | ExpressionInput`), so a persisted action carries the
470+
ADR-0089 envelope — same read/write pair as the hook guard (#3218). */}
471+
<ConditionBuilder label="Visible when" value={expressionSource(draft.visible)} onCommit={(v) => onPatch({ visible: writeExpressionSource(draft.visible, v) })} objectName={objectName} disabled={readOnly} />
472+
<ConditionBuilder label="Disabled when" value={expressionSource(draft.disabled)} onCommit={(v) => onPatch({ disabled: writeExpressionSource(draft.disabled, v) })} objectName={objectName} disabled={readOnly} />
469473
</div>
470474

471475
{/* 7 ─ AI exposure */}

packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { validateExpressionClient } from './expression-validate';
3232
import { useFlowScope } from './useFlowScope';
3333
import { VariableTextInput } from './VariableTextInput';
3434
import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check';
35+
import { writeExpressionSource } from './expression-envelope';
3536
import type { ExpressionInput } from '@objectstack/spec/shared';
3637

3738
/**
@@ -232,11 +233,16 @@ export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection,
232233
/>
233234
<div className="space-y-1">
234235
<Label className="text-xs text-muted-foreground">{t('engine.inspector.flowEdge.condition', locale)}</Label>
236+
{/* #3216 converged the READ on `conditionText`; the write stayed a
237+
bare string, which the spec's pipe would have re-stamped as
238+
`dialect: 'cel'` — silently swapping the engine of a `cron` /
239+
`template` guard and dropping its `ast` / `meta`. Same rule as the
240+
hook guard now (#3218). */}
235241
<VariableTextInput
236242
mode="expression"
237243
mono
238244
value={conditionText(edge.condition) ?? ''}
239-
onValueChange={(v) => patchEdge({ condition: v || undefined })}
245+
onValueChange={(v) => patchEdge({ condition: writeExpressionSource(edge.condition, v) })}
240246
groups={scopeGroups}
241247
placeholder={t('engine.inspector.flowEdge.conditionHint', locale)}
242248
disabled={readOnly || isDefault}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#3218 — the Hook inspector's "Run only when" guard must read AND
5+
* write the ADR-0089 expression envelope.
6+
*
7+
* `HookSchema.condition` is `ExpressionInputSchema`, the same pipe
8+
* `FlowEdgeSchema.condition` uses: a bare authored string is NORMALIZED at
9+
* parse time into `{ dialect: 'cel', source }`, so the envelope is what a
10+
* persisted hook actually carries. The inspector used to read
11+
* `typeof draft.condition === 'string'`, so a persisted guard rendered EMPTY —
12+
* and because `ConditionBuilder.emit` compiles only the rows currently on
13+
* screen, the author's next edit REPLACED a guard they were never shown.
14+
*
15+
* FIXTURE DISCIPLINE (objectui#3216's method): no envelope is hand-written
16+
* here. Every fixture is the AUTHORED input fed through `HookSchema.parse`, so
17+
* a fixture cannot drift from the spec — if the spec stops normalizing, these
18+
* tests change shape with it instead of quietly testing a shape nothing emits.
19+
*
20+
* The write rule (issue ruling, option B) is what the edit cases pin:
21+
*
22+
* | key | when `source` is edited |
23+
* |:----------|:-------------------------------------------------------|
24+
* | `dialect` | PRESERVED (only a value with no prior envelope is 'cel')|
25+
* | `meta` | PRESERVED (ADR-0089 rationale / generatedBy) |
26+
* | `source` | replaced |
27+
* | `ast` | DISCARDED — derived from the OLD source |
28+
*/
29+
30+
import { describe, it, expect, vi, afterEach } from 'vitest';
31+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
32+
import { HookSchema } from '@objectstack/spec/data';
33+
import { HookDefaultInspector } from './HookDefaultInspector';
34+
35+
afterEach(cleanup);
36+
37+
/**
38+
* Author a hook the way a user does, parse it with the spec, and hand the
39+
* RESULT to the inspector — exactly what the metadata editor loads.
40+
*/
41+
function hookDraft(condition: unknown): Record<string, unknown> {
42+
return HookSchema.parse({
43+
name: 'guard_hook',
44+
// '*' keeps ConditionBuilder in raw/no-catalog mode: the guard's read and
45+
// write are what is under test, not the field picker's network fetch.
46+
object: '*',
47+
events: ['beforeInsert'],
48+
handler: 'guard_fn',
49+
condition,
50+
}) as unknown as Record<string, unknown>;
51+
}
52+
53+
function renderInspector(draft: Record<string, unknown>, onPatch = vi.fn()) {
54+
render(
55+
<HookDefaultInspector
56+
type="hook"
57+
name="guard_hook"
58+
draft={draft}
59+
onPatch={onPatch}
60+
readOnly={false}
61+
locale={'en-US' as never}
62+
/>,
63+
);
64+
return onPatch;
65+
}
66+
67+
/** The no-code builder's value box for the single parsed row. */
68+
const rowValueInput = () => screen.getByPlaceholderText('value') as HTMLInputElement;
69+
/**
70+
* The raw-expression editor. CelPredicateField renders a combobox TEXTAREA
71+
* (autocomplete host); the panel's Radix selects are combobox buttons.
72+
*/
73+
const rawEditor = () =>
74+
screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement;
75+
76+
describe('HookDefaultInspector — condition envelope (#3218)', () => {
77+
it('renders the `source` of an envelope condition instead of an empty box', () => {
78+
const draft = hookDraft('amount > 10');
79+
// Pin what the platform actually stores, so this test fails loudly if the
80+
// spec ever stops normalizing rather than passing on a stale assumption.
81+
expect(draft.condition).toEqual({ dialect: 'cel', source: 'amount > 10' });
82+
83+
renderInspector(draft);
84+
85+
// The guard is visible: the no-code builder adopted `amount > 10`, so its
86+
// value box carries `10` and the compiled preview echoes the whole guard.
87+
expect(rowValueInput().value).toBe('10');
88+
expect(screen.getByText('amount > 10')).toBeInTheDocument();
89+
});
90+
91+
it('preserves `dialect` and `meta` verbatim across an edit', () => {
92+
const draft = hookDraft({
93+
dialect: 'cel',
94+
source: 'amount > 10',
95+
meta: { rationale: 'Only large deals need approval', generatedBy: 'agent:deal-guard' },
96+
});
97+
const onPatch = renderInspector(draft);
98+
99+
fireEvent.change(rowValueInput(), { target: { value: '20' } });
100+
101+
expect(onPatch).toHaveBeenCalledWith({
102+
condition: {
103+
dialect: 'cel',
104+
source: 'amount > 20',
105+
meta: { rationale: 'Only large deals need approval', generatedBy: 'agent:deal-guard' },
106+
},
107+
});
108+
});
109+
110+
it('DISCARDS a stale `ast` on edit (it was compiled from the old source)', () => {
111+
const draft = hookDraft({
112+
dialect: 'cel',
113+
source: 'amount > 10',
114+
ast: { op: '>', left: 'amount', right: 10 },
115+
});
116+
expect((draft.condition as Record<string, unknown>).ast).toBeDefined();
117+
118+
const onPatch = renderInspector(draft);
119+
fireEvent.change(rowValueInput(), { target: { value: '20' } });
120+
121+
const patched = onPatch.mock.calls.at(-1)![0].condition as Record<string, unknown>;
122+
expect(patched.source).toBe('amount > 20');
123+
// Keeping it would leave the engine evaluating the OLD guard while the UI
124+
// shows the new one. `objectstack compile` refills it.
125+
expect(patched).not.toHaveProperty('ast');
126+
// Dropping `ast` is safe: `source` is present, so ExpressionSchema's
127+
// "one of source | ast" refinement still holds.
128+
expect(HookSchema.parse({ ...draft, condition: patched }).condition).toEqual({
129+
dialect: 'cel',
130+
source: 'amount > 20',
131+
});
132+
});
133+
134+
it('keeps a `template` guard on the template dialect (the A/B dividing line)', () => {
135+
const draft = hookDraft({ dialect: 'template', source: 'Hello {{record.name}}' });
136+
const onPatch = renderInspector(draft);
137+
138+
// Not simple-CEL, so the builder opens the raw expression editor — which
139+
// is also proof the envelope's `source` reached the control.
140+
expect(rawEditor().value).toBe('Hello {{record.name}}');
141+
142+
fireEvent.change(rawEditor(), { target: { value: 'Hello {{record.title}}' } });
143+
144+
// Committing a bare string here (option A) would have let the spec's pipe
145+
// rewrite this guard to `dialect: 'cel'` — a different evaluation engine
146+
// for an author who believes they only retyped the text.
147+
expect(onPatch).toHaveBeenCalledWith({
148+
condition: { dialect: 'template', source: 'Hello {{record.title}}' },
149+
});
150+
});
151+
152+
it('still clears the guard to `undefined` when the author empties it', () => {
153+
const draft = hookDraft('amount > 10');
154+
const onPatch = renderInspector(draft);
155+
156+
fireEvent.click(screen.getByLabelText('Remove condition'));
157+
158+
expect(onPatch).toHaveBeenCalledWith({ condition: undefined });
159+
});
160+
161+
it('writes the bare-string shorthand when there was no prior envelope', () => {
162+
// A hook authored without a guard: nothing to preserve, so the shorthand
163+
// (which the spec's pipe normalizes to `dialect: 'cel'`) is what is sent.
164+
const draft = hookDraft(undefined);
165+
expect(draft.condition).toBeUndefined();
166+
167+
const onPatch = renderInspector(draft);
168+
fireEvent.click(screen.getByText('Add condition'));
169+
// A subject-less row compiles to '', i.e. still no guard.
170+
expect(onPatch).toHaveBeenLastCalledWith({ condition: undefined });
171+
});
172+
});

packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
} from './_shared';
3838
import { useObjectOptions } from '../previews/useObjectOptions';
3939
import { ConditionBuilder } from './ConditionBuilder';
40+
import { expressionSource, writeExpressionSource } from './expression-envelope';
4041

4142
/* ─────────────── constants ─────────────── */
4243

@@ -251,10 +252,14 @@ export function HookDefaultInspector({
251252
<InspectorCheckboxField label="Run asynchronously (after commit)" value={draft.async === true} onCommit={(v) => onPatch({ async: v })} disabled={readOnly} />
252253
</div>
253254
</div>
255+
{/* `HookSchema.condition` is `ExpressionInputSchema`: a persisted hook
256+
carries the ADR-0089 envelope, not the authored string. Read and
257+
write it through the shared pair so the guard is visible and an
258+
edit cannot rewrite its dialect or drop its `meta` (#3218). */}
254259
<ConditionBuilder
255260
label="Run only when (optional CEL)"
256-
value={typeof draft.condition === 'string' ? (draft.condition as string) : ''}
257-
onCommit={(v) => onPatch({ condition: v || undefined })}
261+
value={expressionSource(draft.condition)}
262+
onCommit={(v) => onPatch({ condition: writeExpressionSource(draft.condition, v) })}
258263
objectName={conditionObject}
259264
disabled={readOnly}
260265
/>

0 commit comments

Comments
 (0)