Skip to content

Commit 2df2f42

Browse files
committed
wip(app-shell): the Automations page provides the served flow verdict
Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1f47bfd commit 2df2f42

2 files changed

Lines changed: 230 additions & 15 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26858 — on the Studio Automations page, the flow edge condition
5+
* shows what the pending draft's publish will refuse at that condition.
6+
*
7+
* The page reads the flow's draft itself (it is not the metadata edit page),
8+
* so the verdict the draft read serves since objectstack#26707 never reached
9+
* an inspector. The page now keeps that verdict, provides it to the inspector
10+
* (`DraftVerdictContext`), and reads it again after each draft save.
11+
*
12+
* The inspector is the real registered `FlowInspector`, which opens
13+
* `FlowEdgeInspector` for an edge; the canvas is a stand-in that selects an
14+
* edge and edits the flow on demand. The client is a server double whose draft
15+
* read judges a stored draft the way `expression-invalid` does: it refuses an
16+
* edge condition that does not parse, at `edges.N.condition`.
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
22+
import { MemoryRouter } from 'react-router-dom';
23+
24+
const PKG = 'com.acme.app';
25+
const BROKEN = 'record.name ==';
26+
const REFUSAL = 'Unexpected token: EOF — source: `record.name ==`';
27+
28+
const FLOW = {
29+
name: 'edge_cel',
30+
label: 'Edge CEL',
31+
type: 'autolaunched',
32+
status: 'active',
33+
nodes: [
34+
{ id: 'start', type: 'start', label: 'Start' },
35+
{ id: 'decide', type: 'decision', label: 'Decide' },
36+
{ id: 'end', type: 'end', label: 'End' },
37+
],
38+
edges: [
39+
{ id: 'e1', source: 'start', target: 'decide' },
40+
{ id: 'e2', source: 'decide', target: 'end', type: 'conditional', condition: BROKEN },
41+
],
42+
};
43+
44+
const server = vi.hoisted(() => ({ draft: null as Record<string, unknown> | null, draftReads: 0 }));
45+
46+
/** The publish verdict on a stored draft: an edge condition that does not parse is refused at its path. */
47+
function verdictOf(body: Record<string, unknown>) {
48+
const edges = (body.edges ?? []) as Array<{ condition?: string }>;
49+
const errors = edges.flatMap((e, i) =>
50+
e.condition === BROKEN ? [{ path: `edges.${i}.condition`, message: REFUSAL, code: 'expression-invalid' }] : [],
51+
);
52+
return errors.length ? { valid: false, errors } : { valid: true };
53+
}
54+
55+
const mockClient = vi.hoisted(() => ({
56+
list: vi.fn(async () => [{ name: 'edge_cel', label: 'Edge CEL' }]),
57+
listDrafts: vi.fn(async () => []),
58+
listTypes: vi.fn(async () => ({ entries: [] })),
59+
get: vi.fn(async () => null),
60+
references: vi.fn(async () => []),
61+
layered: vi.fn(async () => ({ code: null, overlay: null, overlayScope: null, effective: null, editable: true })),
62+
getDraft: vi.fn(async (type: string, name: string) => {
63+
server.draftReads += 1;
64+
if (!server.draft) {
65+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
66+
}
67+
return { type, name, item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } };
68+
}),
69+
save: vi.fn(async (type: string, name: string, item: unknown) => {
70+
server.draft = JSON.parse(JSON.stringify(item)) as Record<string, unknown>;
71+
return { type, name, item };
72+
}),
73+
publish: vi.fn(async () => ({ success: true })),
74+
reset: vi.fn(async () => ({})),
75+
}));
76+
77+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
78+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
79+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
80+
});
81+
82+
vi.mock('./packages-io', async (importOriginal) => {
83+
const mod = await importOriginal<typeof import('./packages-io')>();
84+
return { ...mod, fetchPackages: vi.fn(async () => []) };
85+
});
86+
87+
vi.mock('@object-ui/react', async (importOriginal) => {
88+
const mod = await importOriginal<typeof import('@object-ui/react')>();
89+
return { ...mod, useAdapter: () => dataSource };
90+
});
91+
92+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
93+
94+
import { AutomationsPillar } from './StudioDesignSurface';
95+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
96+
import { getMetadataPreview, registerMetadataPreview } from '../metadata-admin/preview-registry';
97+
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
98+
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
99+
100+
const dataSource = createEmptyDataSource();
101+
102+
vi.stubGlobal(
103+
'fetch',
104+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
105+
);
106+
107+
/** Canvas stand-in: selects the conditional edge, or rewrites its condition. */
108+
function StubCanvas({
109+
onSelectionChange,
110+
onPatch,
111+
}: {
112+
onSelectionChange?: (s: { kind: 'edge'; id: string; label?: string }) => void;
113+
onPatch?: (patch: Record<string, unknown>) => void;
114+
}) {
115+
return (
116+
<>
117+
<button type="button" onClick={() => onSelectionChange?.({ kind: 'edge', id: 'e2', label: 'Decide → End' })}>
118+
select e2
119+
</button>
120+
<button type="button" onClick={() => onSelectionChange?.({ kind: 'edge', id: 'e1', label: 'Start → Decide' })}>
121+
select e1
122+
</button>
123+
<button
124+
type="button"
125+
onClick={() =>
126+
onPatch?.({ edges: [FLOW.edges[0], { ...FLOW.edges[1], condition: "record.name == 'x'" }] })
127+
}
128+
>
129+
fix e2
130+
</button>
131+
</>
132+
);
133+
}
134+
135+
const realFlow = getMetadataPreview('flow');
136+
registerMetadataInspector('flow', FlowInspector);
137+
138+
beforeEach(() => {
139+
server.draft = JSON.parse(JSON.stringify(FLOW));
140+
server.draftReads = 0;
141+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
142+
registerMetadataPreview('flow', StubCanvas as never);
143+
});
144+
145+
afterEach(() => {
146+
cleanup();
147+
if (realFlow) registerMetadataPreview('flow', realFlow);
148+
});
149+
150+
function renderPillar() {
151+
render(
152+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
153+
<AutomationsPillar packageId={PKG} />
154+
</MemoryRouter>,
155+
);
156+
}
157+
158+
describe('Automations page — the edge condition shows the draft\'s served verdict at its path (objectstack#26858)', () => {
159+
it('the refused edge shows the server message; the other edge shows none', async () => {
160+
renderPillar();
161+
fireEvent.click(await screen.findByRole('button', { name: 'select e2' }, { timeout: 8000 }));
162+
expect(await screen.findByTestId('flow-edge-server-error', {}, { timeout: 8000 })).toHaveTextContent(REFUSAL);
163+
164+
fireEvent.click(screen.getByRole('button', { name: 'select e1' }));
165+
await waitFor(() => expect(screen.queryByTestId('flow-edge-server-error')).toBeNull());
166+
});
167+
168+
it('the verdict follows the draft: after the autosave of a fixed condition the refusal goes', async () => {
169+
renderPillar();
170+
fireEvent.click(await screen.findByRole('button', { name: 'select e2' }, { timeout: 8000 }));
171+
await screen.findByTestId('flow-edge-server-error', {}, { timeout: 8000 });
172+
const readsBefore = server.draftReads;
173+
174+
fireEvent.click(screen.getByRole('button', { name: 'fix e2' }));
175+
176+
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 6000 });
177+
await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 6000 });
178+
await waitFor(() => expect(screen.queryByTestId('flow-edge-server-error')).toBeNull(), { timeout: 6000 });
179+
});
180+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ import { getMetadataResource } from '../metadata-admin/registry.js';
225225
import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js';
226226
// objectui#11773 — every draft save of an existing item sends the version its buffer was built on.
227227
import { readDraftForSave, useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js';
228+
// objectstack#26858 — the served verdict a flow's inspectors read at their own paths.
229+
import {
230+
DraftVerdictContext,
231+
draftEnvelopeDiagnostics,
232+
editedItemDiagnostics,
233+
type ServerDiagnostics,
234+
} from '../metadata-admin/draftVerdict.js';
228235
import {
229236
DESIGNER_SURFACE_PARAM,
230237
formatSurfaceParam,
@@ -7325,6 +7332,20 @@ export function AutomationsPillar({
73257332
const [loading, setLoading] = React.useState(false);
73267333
const [saving, setSaving] = React.useState<false | 'draft' | 'publish'>(false);
73277334
const [hasDraft, setHasDraft] = React.useState(false);
7335+
// objectstack#26858 — the server's verdict on the flow on screen: the pending
7336+
// draft's (what its publish will refuse), else the layered read's. Taken off
7337+
// each read, never computed here; the inspectors read it at their paths.
7338+
const [flowVerdict, setFlowVerdict] = React.useState<ServerDiagnostics | undefined>(undefined);
7339+
const flowVerdictSeq = React.useRef(0);
7340+
const rereadFlowVerdict = React.useCallback(
7341+
(flowName: string) => {
7342+
const seq = ++flowVerdictSeq.current;
7343+
void readDraftForSave(client, 'flow', flowName, draftPackageId).then((read) => {
7344+
if (seq === flowVerdictSeq.current) setFlowVerdict(draftEnvelopeDiagnostics(read.answer));
7345+
});
7346+
},
7347+
[client, draftPackageId],
7348+
);
73287349
// objectui#11785 — a load failure as it was (`plainRefusal`), a refused save
73297350
// as an author sentence naming the step and its input, raw text behind Details.
73307351
const [error, setError] = React.useState<StudioRefusal | null>(null);
@@ -7555,6 +7576,14 @@ export function AutomationsPillar({
75557576
// Served draft as-is, baseline only without one (objectui#10765).
75567577
setDraft(draftBody ?? baseline);
75577578
setDraftFor(`flow:${current.name}`);
7579+
flowVerdictSeq.current += 1;
7580+
setFlowVerdict(
7581+
editedItemDiagnostics({
7582+
hasDraft: !!draftBody,
7583+
draftDiagnostics: draftEnvelopeDiagnostics(draftRead.answer),
7584+
layered: layRaw,
7585+
}),
7586+
);
75587587
// objectui#11773 — the next save is pinned to what the draft read served.
75597588
recordFlowRead('flow', current.name, draftPackageId, draftRead.pin);
75607589
setHasDraft(!!draftBody);
@@ -7594,6 +7623,8 @@ export function AutomationsPillar({
75947623
// objectui#11773 — the author chose the saved version; the load replaces the buffer.
75957624
if (outcome === 'reloaded') return;
75967625
setHasDraft(true);
7626+
// objectstack#26858 — the stored draft carries its own verdict now.
7627+
rereadFlowVerdict(current.name);
75977628
// objectui#11204 — clean only if nothing was edited while it was in flight.
75987629
if (sent.unmoved()) setAutoDirty(false);
75997630
onDraftSaved?.();
@@ -7603,7 +7634,7 @@ export function AutomationsPillar({
76037634
} finally {
76047635
setSaving(false);
76057636
}
7606-
}, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale]);
7637+
}, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, rereadFlowVerdict]);
76077638
// objectui#11786 — an edit that leaves a step's required input out (a step
76087639
// just added, before it is filled in) is held, not sent: the server would
76097640
// refuse the flow with a 422 naming that input. The step's inspector hints the
@@ -7663,6 +7694,7 @@ export function AutomationsPillar({
76637694
// objectui#11773 — a reload replaced the buffer the flip was taken on.
76647695
if ((await saveFlowDraft('flow', flowName, nextDraft, { mode: 'draft', packageId: draftPackageId, surface: 'studio' })) === 'reloaded') return;
76657696
setHasDraft(true);
7697+
rereadFlowVerdict(flowName);
76667698
onDraftSaved?.();
76677699
toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale));
76687700
} catch (e) {
@@ -7680,7 +7712,7 @@ export function AutomationsPillar({
76807712
} finally {
76817713
setSaving(false);
76827714
}
7683-
}, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft]);
7715+
}, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft, rereadFlowVerdict]);
76847716

76857717
// The rail's "+" — and the empty block's button, which runs this same
76867718
// handler (objectui#12514). objectui#11553 — no "New" in the package-less
@@ -7738,19 +7770,22 @@ export function AutomationsPillar({
77387770
// row, in the drawer over it (objectui#11795).
77397771
const configBodyEl =
77407772
selection && inspector && current && flowLoaded ? (
7741-
React.createElement(inspector, {
7742-
type: 'flow',
7743-
name: current.name,
7744-
draft,
7745-
selection,
7746-
onPatch,
7747-
onClearSelection: () => setSelection(null),
7748-
onSelectionChange: setSelection,
7749-
// objectui#11124 — the pillar's real flag, threaded exactly as
7750-
// the Data pillar threads it (objectui#2259).
7751-
readOnly,
7752-
locale,
7753-
})
7773+
// objectstack#26858 — the inspectors read the served verdict at their paths.
7774+
<DraftVerdictContext.Provider value={flowVerdict}>
7775+
{React.createElement(inspector, {
7776+
type: 'flow',
7777+
name: current.name,
7778+
draft,
7779+
selection,
7780+
onPatch,
7781+
onClearSelection: () => setSelection(null),
7782+
onSelectionChange: setSelection,
7783+
// objectui#11124 — the pillar's real flag, threaded exactly as
7784+
// the Data pillar threads it (objectui#2259).
7785+
readOnly,
7786+
locale,
7787+
})}
7788+
</DraftVerdictContext.Provider>
77547789
) : designersUnregistered ? (
77557790
<div className="flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground">
77567791
<Ban className="h-5 w-5" />

0 commit comments

Comments
 (0)