Skip to content

Commit b821287

Browse files
os-zhuangclaude
andauthored
feat(studio): first-class notify flow node in the Studio palette + inspector (#2808)
* feat(studio): first-class notify flow node in the Studio palette + inspector The notify flow node (ADR-0012 — outbound notification via the messaging service) is a live built-in but Studio had no static palette entry or config editor: fieldsForNodeType('notify') returned [], so it was only authorable by hand-editing JSON or when the running engine published its descriptor (framework#1878 / framework#1895). - NODE_PALETTE gains notify (Integration) + Bell icon, integration tone, canvas category, and a default-config seed (channels: ['inbox']). - FLOW_NODE_CONFIG gains a notify entry mirroring the built-in descriptor: recipients/channels (stringList), title, message (textarea), topic, severity (select), and click-through target (sourceObject/sourceId/url), all under node.config. Closes the last item of the designer-authoring-gaps issue (framework#1895). Unit + DOM tested; browser dogfood recommended before merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CMaDBhnZEUu1fcw8Rvo6Yq * test(studio): render-verify notify palette entry + inspector fields (#1895) End-to-end Studio render check mounting the real NodePalette (with the production NODE_PALETTE) and FlowNodeInspector: an author sees & picks `notify` from the Integration group, and its inspector renders the config fields (recipients/title/message/channels/topic/severity), writing edits back under node.config. Browser-DOM stand-in for a live dogfood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CMaDBhnZEUu1fcw8Rvo6Yq --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba642f8 commit b821287

6 files changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
feat(studio): first-class `notify` flow node in the Studio palette + inspector
6+
7+
The `notify` flow node (ADR-0012 — outbound notification via the messaging
8+
service) is a live built-in with a server descriptor, but Studio had no static
9+
palette entry or config editor for it: `fieldsForNodeType('notify')` returned
10+
`[]`, so it was only authorable by hand-editing JSON or when the running engine
11+
happened to publish its descriptor (framework#1878 / framework#1895).
12+
13+
- Added `notify` to `NODE_PALETTE` (Integration), with a Bell icon and the
14+
integration tone, canvas category, and a sensible default-config seed
15+
(`channels: ['inbox']`).
16+
- Added a `notify` entry to `FLOW_NODE_CONFIG` mirroring the built-in node's
17+
descriptor keys: `recipients`/`channels` (stringList), `title`, `message`
18+
(textarea), `topic`, `severity` (select info/warning/critical), and the
19+
click-through target (`sourceObject`/`sourceId`/`url`) — all written under
20+
`node.config`.
21+
22+
Closes the last item of the designer-authoring-gaps issue (framework#1895).
23+
Unit + DOM tested (palette entry, config field kinds/paths, no inspector
24+
regression). A browser dogfood pass of authoring a notify node end-to-end is
25+
recommended before merge.

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,34 @@ describe('loop / map collection is a template, not a CEL predicate', () => {
195195
expect(decisionCond.refMode).toBeUndefined();
196196
});
197197
});
198+
199+
describe('notify node — first-class static config editor (#1895)', () => {
200+
const fields = fieldsForNodeType('notify');
201+
const ids = fields.map((f) => f.id);
202+
203+
it('surfaces the core notify config fields (was empty — no static editor before)', () => {
204+
expect(fields.length).toBeGreaterThan(0);
205+
expect(ids).toEqual(
206+
expect.arrayContaining(['recipients', 'title', 'message', 'channels', 'topic', 'severity']),
207+
);
208+
});
209+
210+
it('models recipients + channels as stringList and message as textarea', () => {
211+
expect(fields.find((f) => f.id === 'recipients')!.kind).toBe('stringList');
212+
expect(fields.find((f) => f.id === 'channels')!.kind).toBe('stringList');
213+
expect(fields.find((f) => f.id === 'message')!.kind).toBe('textarea');
214+
});
215+
216+
it('writes each field under node.config (matching the built-in node descriptor)', () => {
217+
for (const f of fields) {
218+
expect(f.path[0]).toBe('config');
219+
}
220+
expect(fields.find((f) => f.id === 'title')!.path).toEqual(['config', 'title']);
221+
});
222+
223+
it('offers the descriptor severity levels', () => {
224+
const severity = fields.find((f) => f.id === 'severity')!;
225+
expect(severity.kind).toBe('select');
226+
expect(severity.options!.map((o) => o.value)).toEqual(['info', 'warning', 'critical']);
227+
});
228+
});

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,31 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
667667
cfg('outputVariable', 'Output variable', 'text', { placeholder: 'subResult' }),
668668
{ id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '60000' },
669669
],
670+
// `notify` — outbound notification (ADR-0012), dispatched via the messaging
671+
// service. Config keys mirror the built-in node's server descriptor
672+
// (service-automation `notify-node.ts` configSchema): title + ≥1 recipient
673+
// are required at execute time; channels default to inbox. Surfaced here as a
674+
// first-class static editor so the node is authorable offline, not only when
675+
// the running engine publishes its descriptor (framework#1878/#1895).
676+
notify: [
677+
cfg('recipients', 'Recipients', 'stringList', { help: 'User id(s) / audience selector(s) to notify. At least one is required.' }),
678+
cfg('title', 'Title', 'text', { placeholder: 'Your request was approved', help: 'Notification title (required).' }),
679+
cfg('message', 'Message', 'textarea', { placeholder: 'Supports {var} template references.', help: 'Notification body.' }),
680+
cfg('channels', 'Channels', 'stringList', { help: 'Channels to fan out to (default: inbox — e.g. inbox · email · push).' }),
681+
cfg('topic', 'Topic', 'text', { placeholder: 'notify', help: 'Event topic (default: "notify").' }),
682+
cfg('severity', 'Severity', 'select', {
683+
options: [
684+
{ value: 'info', label: 'Info' },
685+
{ value: 'warning', label: 'Warning' },
686+
{ value: 'critical', label: 'Critical' },
687+
],
688+
help: 'Notification severity.',
689+
}),
690+
// Click-through target (#2675): deep-link the notification to a record.
691+
cfg('sourceObject', 'Link object', 'text', { placeholder: 'sys_approval_request', help: 'Object of the record the notification links to (requires Link record id).' }),
692+
cfg('sourceId', 'Link record id', 'text', { help: 'Record id the notification links to (requires Link object).' }),
693+
cfg('url', 'Click-through URL', 'text', { help: 'Explicit link; overrides the one synthesized from Link object/record.' }),
694+
],
670695
connector_action: [
671696
at('connectorConfig', 'connectorId', 'Connector', 'reference', { ref: { kind: 'connector' }, placeholder: 'slack · email · salesforce' }),
672697
// actionId is polymorphic on the chosen connector: the picker lists THAT
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* notify flow node — end-to-end Studio render verification (#1895).
5+
*
6+
* Mounts the REAL user-facing components — the add-node palette popover and the
7+
* schema-driven FlowNodeInspector — with the production `NODE_PALETTE` /
8+
* `fieldsForNodeType`, and asserts an author can (1) see & pick `notify` from
9+
* the Integration group and (2) edit its config fields. This is the browser-DOM
10+
* stand-in for a live Studio dogfood (no live-app browser tooling in CI).
11+
*/
12+
13+
import * as React from 'react';
14+
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
15+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
16+
import { NodePalette, NODE_PALETTE, nodeIcon } from '../previews/flow-canvas-parts';
17+
18+
// The inspector's engine config-schema + object-field hooks are stubbed so it
19+
// falls back to the hardcoded `fieldsForNodeType` groups and resolves without a
20+
// network client (same pattern as FlowNodeInspector.test.tsx).
21+
vi.mock('../previews/useFlowNodePalette', () => ({
22+
useActionConfigSchemas: () => ({}),
23+
useFlowNodePalette: () => [],
24+
}));
25+
vi.mock('../previews/useObjectFields', () => ({
26+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
27+
}));
28+
29+
import { FlowNodeInspector } from './FlowNodeInspector';
30+
31+
beforeAll(() => {
32+
// Radix Popover / cmdk probe pointer-capture APIs the test DOM lacks.
33+
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
34+
if (!Element.prototype[m]) {
35+
// @ts-expect-error test shim
36+
Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {};
37+
}
38+
}
39+
});
40+
41+
afterEach(cleanup);
42+
43+
describe('notify node — add-node palette (real NODE_PALETTE)', () => {
44+
it('presents Notify in the Integration group and picks type="notify"', () => {
45+
const onPick = vi.fn();
46+
render(
47+
<NodePalette items={NODE_PALETTE} open onOpenChange={() => {}} onPick={onPick}>
48+
<button type="button">Add node</button>
49+
</NodePalette>,
50+
);
51+
52+
const notify = screen.getByText('Notify');
53+
expect(notify).toBeInTheDocument();
54+
fireEvent.click(notify);
55+
expect(onPick).toHaveBeenCalledWith('notify');
56+
});
57+
58+
it('maps notify to the Bell icon (distinct from the default CircleDot)', () => {
59+
expect(nodeIcon('notify')).toBe(nodeIcon('notify'));
60+
expect(nodeIcon('notify')).not.toBe(nodeIcon('some_unknown_type'));
61+
});
62+
});
63+
64+
describe('notify node — inspector renders its config fields', () => {
65+
const draft = { nodes: [{ id: 'ping', type: 'notify', label: 'Notify approver', config: {} }] };
66+
67+
it('shows the notify config editor (recipients / title / message / channels / topic / severity)', () => {
68+
render(
69+
<FlowNodeInspector
70+
type="flow"
71+
name="approval_flow"
72+
draft={draft}
73+
selection={{ kind: 'node', id: 'ping' }}
74+
onPatch={vi.fn()}
75+
onClearSelection={vi.fn()}
76+
readOnly={false}
77+
locale="en"
78+
/>,
79+
);
80+
81+
// The node label input confirms we're on the notify node's inspector.
82+
expect(screen.getByDisplayValue('Notify approver')).toBeInTheDocument();
83+
// Each authored config field label the built-in descriptor documents.
84+
for (const label of ['Recipients', 'Title', 'Message', 'Channels', 'Topic', 'Severity']) {
85+
expect(screen.getByText(label)).toBeInTheDocument();
86+
}
87+
});
88+
89+
it('writes an edited title back under node.config (matching the runtime)', () => {
90+
const onPatch = vi.fn();
91+
render(
92+
<FlowNodeInspector
93+
type="flow"
94+
name="approval_flow"
95+
draft={draft}
96+
selection={{ kind: 'node', id: 'ping' }}
97+
onPatch={onPatch}
98+
onClearSelection={vi.fn()}
99+
readOnly={false}
100+
locale="en"
101+
/>,
102+
);
103+
104+
const titleField = screen.getByPlaceholderText('Your request was approved');
105+
fireEvent.change(titleField, { target: { value: 'Approved!' } });
106+
const patch = onPatch.mock.calls.at(-1)![0] as { nodes: Array<{ config?: Record<string, unknown> }> };
107+
expect(patch.nodes[0].config!.title).toBe('Approved!');
108+
});
109+
});

packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as React from 'react';
1010
import {
1111
AlertCircle,
1212
AlertTriangle,
13+
Bell,
1314
ChevronRight,
1415
Code,
1516
CircleDot,
@@ -85,6 +86,8 @@ export function nodeIcon(type: string): LucideIcon {
8586
case 'http_request':
8687
case 'webhook':
8788
return Globe;
89+
case 'notify':
90+
return Bell;
8891
case 'script':
8992
case 'script_task':
9093
return Code;
@@ -248,6 +251,7 @@ export function nodeTone(type: string): NodeTone {
248251
return TONES.record;
249252
case 'http':
250253
case 'http_request':
254+
case 'notify':
251255
case 'connector_action':
252256
case 'script':
253257
case 'webhook':
@@ -312,6 +316,7 @@ export function nodeCategory(type: string): NodeCategory {
312316
return 'Human';
313317
case 'http':
314318
case 'http_request':
319+
case 'notify':
315320
case 'connector_action':
316321
case 'script':
317322
case 'webhook':
@@ -341,6 +346,7 @@ export const NODE_PALETTE: PaletteItem[] = [
341346
{ type: 'approval', label: 'Approval', hint: 'Pause for a human decision', category: 'Human' },
342347
{ type: 'screen', label: 'Screen', hint: 'Collect input from a user', category: 'Human' },
343348
{ type: 'http', label: 'HTTP request', hint: 'Call an external API', category: 'Integration' },
349+
{ type: 'notify', label: 'Notify', hint: 'Send a notification to users', category: 'Integration' },
344350
{ type: 'connector_action', label: 'Connector', hint: 'Run an integration action', category: 'Integration' },
345351
{ type: 'script', label: 'Script', hint: 'Run custom code', category: 'Integration' },
346352
{ type: 'subflow', label: 'Subflow', hint: 'Invoke another flow', category: 'Flow' },
@@ -385,6 +391,10 @@ export function defaultNodeExtras(type: string): Record<string, unknown> {
385391
// Seed a node-model approval: at least one approver + spec defaults. The
386392
// author wires the out-edges with labels `approve` / `reject`.
387393
return { config: { approvers: [{ type: 'manager' }], behavior: 'first_response', lockRecord: true } };
394+
case 'notify':
395+
// Seed a sensible notify node: inbox channel + one empty recipient slot,
396+
// matching the built-in node's defaults (framework#1895).
397+
return { config: { channels: ['inbox'], recipients: [] } };
388398
case 'http':
389399
case 'http_request':
390400
return { config: { method: 'GET' } };

packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ describe('mergePalette', () => {
99
expect(mergePalette(NODE_PALETTE, [])).toEqual(NODE_PALETTE);
1010
});
1111

12+
it('offers `notify` as a first-class Integration palette entry (#1895)', () => {
13+
const notify = NODE_PALETTE.find((i) => i.type === 'notify');
14+
expect(notify).toBeDefined();
15+
expect(notify!.category).toBe('Integration');
16+
// Authorable offline (static entry), not only when the engine publishes a
17+
// descriptor — mergePalette keeps it when no descriptors are present.
18+
expect(mergePalette(NODE_PALETTE, []).some((i) => i.type === 'notify')).toBe(true);
19+
});
20+
1221
it('overlays the engine label/description onto a matching base entry, keeping its position', () => {
1322
const base = [
1423
{ type: 'decision', label: 'Decision', hint: 'Branch on a condition' },

0 commit comments

Comments
 (0)