diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index bc38fd82c..291027f6e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -19,6 +19,7 @@ import { Label } from '@object-ui/components'; import { FlowKeyValueField } from './FlowKeyValueField'; import { FlowStringListField } from './FlowStringListField'; import { FlowObjectListField } from './FlowObjectListField'; +import { FlowReferenceField, type FlowReferenceContext } from './FlowReferenceField'; export interface FlowNodeConfigFieldProps { field: FlowConfigField; @@ -26,11 +27,23 @@ export interface FlowNodeConfigFieldProps { onCommit: (value: unknown) => void; disabled?: boolean; locale?: string; + /** Draft + node context so `reference` fields can resolve their options. */ + context?: FlowReferenceContext; } -export function FlowNodeConfigField({ field, value, onCommit, disabled, locale }: FlowNodeConfigFieldProps) { +export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context }: FlowNodeConfigFieldProps) { const control = (() => { switch (field.kind) { + case 'reference': + return ( + onCommit(v)} + disabled={disabled} + context={context} + /> + ); case 'keyValue': return ( n?.id === selection.id); const node = index >= 0 ? nodes[index] : null; - const fields = fieldsForNodeType(node?.type); + // Server-driven property form: when the running engine publishes a config + // JSON Schema for this node type (ADR-0018 §configSchema — e.g. the ADR-0019 + // approval node), derive the form from it so the designer stays in lock-step + // with the backend. Falls back to the hardcoded field group when no schema is + // published (offline / plugin absent / older backend). + const configSchemas = useActionConfigSchemas(); + const fields = React.useMemo(() => { + const schema = node?.type ? configSchemas[node.type] : undefined; + const serverFields = schema !== undefined ? jsonSchemaToFlowFields(schema) : null; + return serverFields ?? fieldsForNodeType(node?.type); + }, [configSchemas, node?.type]); const config = asConfig(node); const visibleFields = fields.filter((f) => isFieldVisible(f, node, fields)); // Only fields stored under `config` "own" a config key; spec-structured @@ -202,6 +214,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, onCommit={(v) => setField(field.path, v)} disabled={readOnly} locale={locale} + context={{ draft, node }} /> ))} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx new file mode 100644 index 000000000..69ae3c17a --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowReferenceField — an *editable combobox* for flow-node config values that + * are really references (an object's field, an object/flow/role by name, or + * another node in this flow) rather than free-form strings. + * + * Why a combobox and not a strict dropdown: the designer must never trap the + * author. The control suggests known values (fetched per {@link ReferenceKind}) + * but always accepts free text, so a field that doesn't exist yet, a role the + * current tenant hasn't populated, or an empty catalog all still let the author + * type a value. Implemented with a native `` for exactly that + * suggest-but-allow-anything behaviour, zero extra dependencies, and built-in + * accessibility. + * + * Data sources are resolved lazily from the running backend (the same source of + * truth as the rest of the designer); `object-field` additionally needs to know + * *which* object — resolved from the reference's `objectSource` against the + * flow draft (trigger object) or the node's sibling config. + */ + +import * as React from 'react'; +import { Input, Label } from '@object-ui/components'; +import type { FlowConfigField, FlowReferenceSpec } from './flow-node-config'; +import { useMetadataClient } from '../useMetadata'; +import { useObjectFields } from '../previews/useObjectFields'; + +/** Context the reference picker needs to resolve dynamic option sources. */ +export interface FlowReferenceContext { + /** The whole flow draft — used for `$trigger` object + the node list. */ + draft: Record; + /** The node currently being edited — used to resolve sibling config keys. */ + node: Record | null; +} + +interface Option { + value: string; + label: string; +} + +/** Read `node.config[key]` as a non-empty string, else undefined. */ +function configString(node: Record | null | undefined, key: string): string | undefined { + const cfg = node?.config; + if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return undefined; + const v = (cfg as Record)[key]; + return typeof v === 'string' && v ? v : undefined; +} + +/** Resolve the target object name for an `object-field` reference. */ +function resolveObjectName(ref: FlowReferenceSpec | undefined, ctx: FlowReferenceContext): string | undefined { + if (!ref || ref.kind !== 'object-field') return undefined; + const src = ref.objectSource || '$trigger'; + if (src === '$trigger') { + const nodes = Array.isArray(ctx.draft.nodes) ? (ctx.draft.nodes as Array>) : []; + const start = nodes.find((n) => n?.type === 'start'); + return configString(start, 'objectName'); + } + // A sibling config key on the same node (CRUD nodes carry their own objectName). + return configString(ctx.node, src); +} + +/** + * Fetch a metadata type's items as combobox options. `type === undefined` + * disables the fetch (returns empty), so the hook can be called + * unconditionally regardless of the reference kind. + */ +function useMetadataListOptions(type: string | undefined): { options: Option[]; loading: boolean } { + const client = useMetadataClient(); + const [state, setState] = React.useState<{ options: Option[]; loading: boolean }>({ + options: [], + loading: !!type, + }); + React.useEffect(() => { + if (!type) { + setState({ options: [], loading: false }); + return; + } + let cancelled = false; + setState((s) => ({ ...s, loading: true })); + client + .list<{ name?: string; label?: string }>(type) + .then((rows) => { + if (cancelled) return; + const options = (Array.isArray(rows) ? rows : []) + .filter((r) => r && typeof r.name === 'string' && r.name) + .map((r) => ({ + value: String(r.name), + label: typeof r.label === 'string' && r.label && r.label !== r.name ? `${r.label} (${r.name})` : String(r.name), + })); + setState({ options, loading: false }); + }) + .catch(() => { + if (!cancelled) setState({ options: [], loading: false }); + }); + return () => { + cancelled = true; + }; + }, [client, type]); + return state; +} + +export interface FlowReferenceFieldProps { + field: FlowConfigField; + value: unknown; + onCommit: (value: unknown) => void; + disabled?: boolean; + context?: FlowReferenceContext; +} + +export function FlowReferenceField({ field, value, onCommit, disabled, context }: FlowReferenceFieldProps) { + const listId = React.useId(); + const ref = field.ref; + const ctx: FlowReferenceContext = context ?? { draft: {}, node: null }; + const kind = ref?.kind; + + // object-field: resolve the target object, then its field catalog. + const objectName = resolveObjectName(ref, ctx); + const { fields: objectFields } = useObjectFields(kind === 'object-field' ? objectName : undefined); + + // object / flow / role: list the metadata type. + const listType = kind === 'object' ? 'object' : kind === 'flow' ? 'flow' : kind === 'role' ? 'role' : undefined; + const { options: listOptions } = useMetadataListOptions(listType); + + const options = React.useMemo(() => { + switch (kind) { + case 'object-field': + return objectFields.map((f) => ({ + value: f.name, + label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name, + })); + case 'object': + case 'flow': + case 'role': + return listOptions; + case 'node': { + const nodes = Array.isArray(ctx.draft.nodes) ? (ctx.draft.nodes as Array>) : []; + const currentId = typeof ctx.node?.id === 'string' ? ctx.node.id : undefined; + return nodes + .filter((n) => typeof n?.id === 'string' && n.id && n.id !== currentId) + .map((n) => { + const id = String(n.id); + const lbl = typeof n.label === 'string' && n.label ? `${n.label} (${id})` : id; + return { value: id, label: lbl }; + }); + } + default: + return []; + } + }, [kind, objectFields, listOptions, ctx.draft, ctx.node]); + + // For an object-field whose object can't be resolved, tell the author why the + // suggestions are empty — but still let them type a value. + const unresolvedObject = kind === 'object-field' && !objectName; + + return ( +
+ + onCommit(e.target.value)} + placeholder={field.placeholder} + disabled={disabled} + className="h-8 text-sm" + /> + {options.length > 0 && ( + + {options.map((o) => ( + + ))} + + )} + {kind === 'object-field' && objectName && ( +

Fields of {objectName}.

+ )} + {unresolvedObject && ( +

+ Set the flow’s trigger object (on the Start node) to list fields. +

+ )} +
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index 711e2f298..96879e2a1 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -33,7 +33,35 @@ export type FlowConfigFieldKind = | 'textarea' | 'keyValue' | 'stringList' - | 'objectList'; + | 'objectList' + | 'reference'; + +/** + * What a `reference` field points at — the picker's data source. The control + * is always an *editable* combobox (suggestions + free text), so an unknown / + * not-yet-created value is never rejected and an empty catalog degrades to a + * plain text box. + * + * • `object` → a business object, by API name (`client.list('object')`) + * • `object-field` → a field of some object; the object is resolved via + * {@link FlowReferenceSpec.objectSource} + * • `flow` → a flow, by name (`client.list('flow')`) + * • `role` → a security role (`client.list('role')`) + * • `node` → another node in *this* flow, by id (read from the draft) + */ +export type ReferenceKind = 'object' | 'object-field' | 'flow' | 'role' | 'node'; + +export interface FlowReferenceSpec { + kind: ReferenceKind; + /** + * For `object-field` only: where to find the target object's name. + * • `'$trigger'` (default) → the flow trigger object, read from the start + * node's `config.objectName` (the record an approval / record node acts on). + * • any other string → a sibling config key on the *same* node holding + * the object name (e.g. CRUD nodes resolve from their own `objectName`). + */ + objectSource?: string; +} /** Column descriptor for an `objectList` repeater row. */ export interface FlowConfigColumn { @@ -76,6 +104,8 @@ export interface FlowConfigField { showWhen?: { field: string; equals: string[] }; /** Column schema for `objectList` fields (array-of-objects repeater). */ columns?: FlowConfigColumn[]; + /** Reference target for `reference` fields — drives the combobox data source. */ + ref?: FlowReferenceSpec; } /** Convenience: a `['config', key]`-rooted field (the common case). */ @@ -129,7 +159,8 @@ const FLOW_NODE_CONFIG: Record = { { value: 'event', label: 'Platform event' }, ], }), - cfg('objectName', 'Object', 'text', { + cfg('objectName', 'Object', 'reference', { + ref: { kind: 'object' }, placeholder: 'crm_lead', help: 'Target object for record / scheduled-scan triggers.', }), @@ -183,21 +214,21 @@ const FLOW_NODE_CONFIG: Record = { cfg('iteratorVariable', 'Item variable', 'text', { placeholder: 'currentItem' }), ], create_record: [ - cfg('objectName', 'Object', 'text', { placeholder: 'contract' }), + cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, placeholder: 'contract' }), cfg('fields', 'Field values', 'keyValue', { help: 'Field values to write on the new record.' }), cfg('outputVariable', 'Output variable', 'text', { placeholder: 'newRecord' }), ], update_record: [ - cfg('objectName', 'Object', 'text', { placeholder: 'contract' }), + cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, placeholder: 'contract' }), cfg('filter', 'Filter', 'keyValue', { help: 'Field/value pairs identifying the record(s) to update (e.g. id → {recordId}).' }), cfg('fields', 'Field values', 'keyValue', { help: 'Field values to write.' }), ], delete_record: [ - cfg('objectName', 'Object', 'text', { placeholder: 'contract' }), + cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, placeholder: 'contract' }), cfg('filter', 'Filter', 'keyValue', { help: 'Field/value pairs identifying the record(s) to delete.' }), ], get_record: [ - cfg('objectName', 'Object', 'text', { placeholder: 'contract' }), + cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, placeholder: 'contract' }), cfg('filter', 'Filter', 'keyValue', { help: 'Field/value pairs to match (e.g. status → active). Operator values like {"$ne": null} are preserved.' }), cfg('limit', 'Limit', 'number', { placeholder: '100' }), cfg('outputVariable', 'Output variable', 'text', { placeholder: 'records' }), @@ -299,7 +330,8 @@ const FLOW_NODE_CONFIG: Record = { cfg('lockRecord', 'Lock record', 'boolean', { help: 'Lock the triggering record from edits while this node is pending.', }), - cfg('approvalStatusField', 'Status field', 'text', { + cfg('approvalStatusField', 'Status field', 'reference', { + ref: { kind: 'object-field', objectSource: '$trigger' }, placeholder: 'approval_status', help: 'Business-object field to mirror request status onto (pending/approved/rejected). Should be readonly.', }), @@ -350,7 +382,7 @@ const FLOW_NODE_CONFIG: Record = { }), ], subflow: [ - cfg('flowName', 'Flow', 'text', { placeholder: 'escalation_flow' }), + cfg('flowName', 'Flow', 'reference', { ref: { kind: 'flow' }, placeholder: 'escalation_flow' }), cfg('input', 'Input mapping', 'keyValue', { help: 'Values passed to the subflow\u2019s input variables.' }), cfg('outputVariable', 'Output variable', 'text', { placeholder: 'subResult' }), { id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '60000' }, @@ -364,7 +396,7 @@ const FLOW_NODE_CONFIG: Record = { parallel_gateway: [], join_gateway: [], boundary_event: [ - at('boundaryConfig', 'attachedToNodeId', 'Attached to', 'text', { placeholder: 'host node id', help: 'Host node this boundary event monitors.' }), + at('boundaryConfig', 'attachedToNodeId', 'Attached to', 'reference', { ref: { kind: 'node' }, placeholder: 'host node id', help: 'Host node this boundary event monitors.' }), at('boundaryConfig', 'eventType', 'Event type', 'select', { options: [ { value: 'error', label: 'Error' }, @@ -397,7 +429,7 @@ const FLOW_NODE_CONFIG: Record = { */ legacy_action: [ cfg('action', 'Action', 'text', { placeholder: 'sendEmail · createTask · update · query' }), - cfg('objectName', 'Object', 'text', { placeholder: 'contract' }), + cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, placeholder: 'contract' }), cfg('recordId', 'Record', 'expression', { placeholder: 'record.id' }), cfg('params', 'Parameters', 'keyValue', { help: 'Action inputs. Values auto-typed: 3 \u2192 number, true \u2192 boolean.' }), cfg('fields', 'Field values', 'keyValue' ), diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts new file mode 100644 index 000000000..9de09b7f4 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { jsonSchemaToFlowFields, humanizeKey } from './json-schema-to-fields'; + +/** + * The exact JSON Schema the engine publishes for the Approval node config + * (`z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' })`), captured from + * `GET /api/v1/automation/actions`. Drives the server-driven property form. + */ +const APPROVAL_CONFIG_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + approvers: { + minItems: 1, + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['user', 'role', 'team', 'department', 'manager', 'field', 'queue'] }, + value: { description: 'User id / role / team / department / field / queue — per `type`', type: 'string' }, + }, + required: ['type'], + }, + description: 'Allowed approvers for this node', + }, + behavior: { + default: 'first_response', + description: 'How to combine multiple approvers', + type: 'string', + enum: ['first_response', 'unanimous'], + }, + lockRecord: { default: true, description: 'Lock the record from editing while pending', type: 'boolean' }, + approvalStatusField: { + description: 'Business-object field to mirror request status onto', + type: 'string', + xRef: { kind: 'object-field', objectSource: '$trigger' }, + }, + escalation: { + description: 'Per-node SLA escalation', + type: 'object', + properties: { + enabled: { default: false, description: 'Enable SLA-based escalation for this node', type: 'boolean' }, + timeoutHours: { type: 'number', minimum: 1, description: 'Hours before escalation triggers' }, + action: { default: 'notify', description: 'Action on escalation timeout', type: 'string', enum: ['reassign', 'auto_approve', 'auto_reject', 'notify'] }, + escalateTo: { description: 'User id, role, or manager level to escalate to', type: 'string' }, + notifySubmitter: { default: true, description: 'Notify the original submitter on escalation', type: 'boolean' }, + }, + required: ['timeoutHours'], + }, + }, + required: ['approvers'], +}; + +describe('humanizeKey', () => { + it('title-cases camelCase and snake_case', () => { + expect(humanizeKey('approvalStatusField')).toBe('Approval Status Field'); + expect(humanizeKey('first_response')).toBe('First Response'); + expect(humanizeKey('lockRecord')).toBe('Lock Record'); + }); +}); + +describe('jsonSchemaToFlowFields', () => { + it('returns null for non-object schemas (caller falls back to hardcoded fields)', () => { + expect(jsonSchemaToFlowFields(undefined)).toBeNull(); + expect(jsonSchemaToFlowFields({ type: 'string' })).toBeNull(); + expect(jsonSchemaToFlowFields({ type: 'object' })).toBeNull(); // no properties + }); + + it('preserves property order from the schema', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + expect(fields.map((f) => f.id)).toEqual([ + 'approvers', + 'behavior', + 'lockRecord', + 'approvalStatusField', + // escalation flattens into config.escalation.* sub-fields + 'escalation.enabled', + 'escalation.timeoutHours', + 'escalation.action', + 'escalation.escalateTo', + 'escalation.notifySubmitter', + ]); + }); + + it('maps an array-of-object into an objectList with columns', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + const approvers = fields.find((f) => f.id === 'approvers')!; + expect(approvers.kind).toBe('objectList'); + expect(approvers.path).toEqual(['config', 'approvers']); + expect(approvers.label).toBe('Approvers'); + expect(approvers.help).toBe('Allowed approvers for this node'); + const colKeys = approvers.columns!.map((c) => c.key); + expect(colKeys).toEqual(['type', 'value']); + const typeCol = approvers.columns!.find((c) => c.key === 'type')!; + expect(typeCol.kind).toBe('select'); + expect(typeCol.options!.map((o) => o.value)).toEqual(['user', 'role', 'team', 'department', 'manager', 'field', 'queue']); + const valueCol = approvers.columns!.find((c) => c.key === 'value')!; + expect(valueCol.kind).toBe('text'); + expect(valueCol.placeholder).toBe('User id / role / team / department / field / queue — per `type`'); + }); + + it('maps an enum string into a select with humanized option labels + default', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + const behavior = fields.find((f) => f.id === 'behavior')!; + expect(behavior.kind).toBe('select'); + expect(behavior.defaultValue).toBe('first_response'); + expect(behavior.options).toEqual([ + { value: 'first_response', label: 'First Response' }, + { value: 'unanimous', label: 'Unanimous' }, + ]); + }); + + it('maps boolean scalars', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + const lock = fields.find((f) => f.id === 'lockRecord')!; + expect(lock.kind).toBe('boolean'); + expect(lock.defaultValue).toBe('true'); + }); + + it('maps an xRef string into a reference field (picker, not free text)', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + const statusField = fields.find((f) => f.id === 'approvalStatusField')!; + expect(statusField.kind).toBe('reference'); + expect(statusField.path).toEqual(['config', 'approvalStatusField']); + expect(statusField.ref).toEqual({ kind: 'object-field', objectSource: '$trigger' }); + // Description still flows through as help. + expect(statusField.help).toBe('Business-object field to mirror request status onto'); + }); + + it('falls back to plain text for a string with no xRef', () => { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { note: { type: 'string', description: 'free text' } }, + })!; + const note = fields.find((f) => f.id === 'note')!; + expect(note.kind).toBe('text'); + expect(note.ref).toBeUndefined(); + }); + + it('ignores an xRef with an unknown kind (treats as plain text)', () => { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { weird: { type: 'string', xRef: { kind: 'bogus' } } }, + })!; + const weird = fields.find((f) => f.id === 'weird')!; + expect(weird.kind).toBe('text'); + expect(weird.ref).toBeUndefined(); + }); + + it('flattens a nested object and gates siblings behind its enabled toggle', () => { + const fields = jsonSchemaToFlowFields(APPROVAL_CONFIG_SCHEMA)!; + const enabled = fields.find((f) => f.id === 'escalation.enabled')!; + expect(enabled.kind).toBe('boolean'); + expect(enabled.path).toEqual(['config', 'escalation', 'enabled']); + // The gate adopts the parent group's label, not "Enabled". + expect(enabled.label).toBe('Escalation'); + expect(enabled.showWhen).toBeUndefined(); + + const timeout = fields.find((f) => f.id === 'escalation.timeoutHours')!; + expect(timeout.kind).toBe('number'); + expect(timeout.showWhen).toEqual({ field: 'escalation.enabled', equals: ['true'] }); + + const action = fields.find((f) => f.id === 'escalation.action')!; + expect(action.kind).toBe('select'); + expect(action.defaultValue).toBe('notify'); + expect(action.options!.map((o) => o.value)).toEqual(['reassign', 'auto_approve', 'auto_reject', 'notify']); + expect(action.showWhen).toEqual({ field: 'escalation.enabled', equals: ['true'] }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts new file mode 100644 index 000000000..8b46ee542 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * json-schema-to-fields — adapt an engine-published config JSON Schema into the + * inspector's {@link FlowConfigField} model, so the flow designer renders a + * node's property form **from the server** rather than a hardcoded client form. + * + * The automation engine owns each node type's config contract: built-in node + * packs and plugins publish an `ActionDescriptor` whose `configSchema` is the + * JSON Schema compiled from the executor's Zod (ADR-0018 §configSchema). The + * approval plugin (ADR-0019) is the first to publish one. Driving the inspector + * from that schema keeps the property form in lock-step with what the running + * backend actually validates — when a plugin evolves its config, the designer + * updates with no client release. + * + * We map onto `FlowConfigField[]` (rather than rendering JSON Schema directly) + * so the existing, polished field widgets — select, boolean, the `objectList` + * repeater (e.g. approvers), the optional Advanced-JSON escape hatch — are + * reused unchanged. Anything the mapping can't express (deeply nested objects, + * unions) is simply left off the form and remains editable in the Advanced + * block, so authors are never locked out. + * + * Scope mirrors what `z.toJSONSchema` emits for real node configs: + * • string → text (enum → select) + * • number / integer → number + * • boolean → boolean + * • array of string → stringList + * • array of object → objectList (columns from item props) + * • object (one level) → flattened sub-fields under config..* + * (a nested `enabled: boolean` makes the + * group's other fields reveal when enabled) + */ + +import type { FlowConfigField, FlowConfigColumn, FlowConfigFieldKind, FlowReferenceSpec, ReferenceKind } from './flow-node-config'; + +/** Loose JSON Schema node shape — we only read the keys we map. */ +interface JsonSchemaNode { + type?: string | string[]; + enum?: unknown[]; + const?: unknown; + default?: unknown; + description?: string; + title?: string; + format?: string; + properties?: Record; + items?: JsonSchemaNode; + required?: string[]; + /** + * Reference annotation carried from the executor's Zod `.meta({ xRef })` + * (ADR-0018). Marks a string as a typed reference (object field, object, + * flow, role, node) so the inspector renders a picker instead of free text. + */ + xRef?: { kind?: string; objectSource?: string }; +} + +const REFERENCE_KINDS: ReadonlySet = new Set([ + 'object', + 'object-field', + 'flow', + 'role', + 'node', +]); + +/** Read a valid `xRef` annotation off a schema node, or undefined. */ +function refOf(node: JsonSchemaNode): FlowReferenceSpec | undefined { + const x = node.xRef; + if (!x || typeof x !== 'object' || typeof x.kind !== 'string' || !REFERENCE_KINDS.has(x.kind)) return undefined; + return { + kind: x.kind as ReferenceKind, + ...(typeof x.objectSource === 'string' && x.objectSource ? { objectSource: x.objectSource } : {}), + }; +} + +/** "approvalStatusField" → "Approval Status Field"; "approve" → "Approve". */ +export function humanizeKey(key: string): string { + const spaced = key + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .trim(); + if (!spaced) return key; + return spaced + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +function isObject(v: unknown): v is JsonSchemaNode { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** The JSON Schema `type`, normalized to a single string (first non-null of a union). */ +function schemaType(node: JsonSchemaNode): string | undefined { + if (Array.isArray(node.type)) return node.type.find((t) => t !== 'null'); + if (typeof node.type === 'string') return node.type; + // Infer from shape when `type` is omitted (common with enum-only schemas). + if (Array.isArray(node.enum)) return 'string'; + if (node.properties) return 'object'; + if (node.items) return 'array'; + return undefined; +} + +/** Build `{ value, label }` options from a string enum. */ +function enumOptions(values: unknown[]): Array<{ value: string; label: string }> { + return values + .filter((v): v is string => typeof v === 'string') + .map((v) => ({ value: v, label: humanizeKey(v) })); +} + +/** Default coerced to the string form the inspector's `defaultValue` expects. */ +function defaultString(node: JsonSchemaNode): string | undefined { + if (node.default === undefined || node.default === null) return undefined; + if (typeof node.default === 'boolean') return String(node.default); + if (typeof node.default === 'number') return String(node.default); + if (typeof node.default === 'string') return node.default; + return undefined; +} + +/** Scalar (non-object, non-array) → field kind. */ +function scalarKind(node: JsonSchemaNode): FlowConfigFieldKind | undefined { + if (Array.isArray(node.enum)) return 'select'; + const t = schemaType(node); + if (t === 'boolean') return 'boolean'; + if (t === 'number' || t === 'integer') return 'number'; + if (t === 'string') return node.format === 'multiline' ? 'textarea' : 'text'; + return undefined; +} + +/** Derive `objectList` columns from an item object's properties. */ +function columnsFor(item: JsonSchemaNode): FlowConfigColumn[] { + const props = item.properties ?? {}; + const cols: FlowConfigColumn[] = []; + for (const [key, prop] of Object.entries(props)) { + if (!isObject(prop)) continue; + const t = schemaType(prop); + let kind: FlowConfigColumn['kind']; + let options: Array<{ value: string; label: string }> | undefined; + if (Array.isArray(prop.enum)) { + kind = 'select'; + options = enumOptions(prop.enum); + } else if (t === 'boolean') { + kind = 'boolean'; + } else { + kind = 'text'; + } + cols.push({ + key, + label: prop.title || humanizeKey(key), + kind, + ...(options ? { options } : {}), + // Columns have no help slot — surface the schema description as a hint. + ...(prop.description ? { placeholder: prop.description } : {}), + }); + } + return cols; +} + +/** Common field metadata derived from a schema node. */ +function meta(node: JsonSchemaNode, key: string): { label: string; help?: string; defaultValue?: string } { + return { + label: node.title || humanizeKey(key), + ...(node.description ? { help: node.description } : {}), + ...(defaultString(node) ? { defaultValue: defaultString(node) } : {}), + }; +} + +/** + * Convert a published config JSON Schema (an object schema) into the inspector's + * `FlowConfigField[]`. Property order is preserved. Returns `null` when the + * schema is not a usable object schema, so callers fall back to their hardcoded + * field group. + */ +export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | null { + if (!isObject(schema) || schemaType(schema) !== 'object' || !isObject(schema.properties)) { + return null; + } + const fields: FlowConfigField[] = []; + + for (const [key, prop] of Object.entries(schema.properties)) { + if (!isObject(prop)) continue; + const t = schemaType(prop); + + // ── arrays ──────────────────────────────────────────────────────────── + if (t === 'array') { + const item = isObject(prop.items) ? prop.items : undefined; + const itemType = item ? schemaType(item) : undefined; + if (item && itemType === 'object' && isObject(item.properties)) { + fields.push({ id: key, path: ['config', key], kind: 'objectList', columns: columnsFor(item), ...meta(prop, key) }); + } else if (itemType === 'string') { + fields.push({ id: key, path: ['config', key], kind: 'stringList', ...meta(prop, key) }); + } + // arrays of anything else fall through to the Advanced block. + continue; + } + + // ── nested object → flatten one level under config..* ────────────── + if (t === 'object' && isObject(prop.properties)) { + const subProps = Object.entries(prop.properties).filter(([, p]) => isObject(p)); + // A boolean `enabled` toggle gates the rest of the group (mirrors the SLA + // escalation UX): the group's other fields reveal only when it is on. + const hasEnabled = subProps.some(([k, p]) => k === 'enabled' && schemaType(p as JsonSchemaNode) === 'boolean'); + for (const [subKey, subProp] of subProps) { + const sp = subProp as JsonSchemaNode; + const subRef = refOf(sp); + const kind = subRef ? 'reference' : scalarKind(sp); + if (!kind) continue; // deeper nesting / unsupported → Advanced block + const id = `${key}.${subKey}`; + const isGate = hasEnabled && subKey === 'enabled'; + const field: FlowConfigField = { + id, + path: ['config', key, subKey], + kind, + // The gate adopts the parent group's label; siblings keep their own. + ...(isGate ? { label: prop.title || humanizeKey(key), ...(prop.description ? { help: prop.description } : {}), ...(defaultString(sp) ? { defaultValue: defaultString(sp) } : {}) } : meta(sp, subKey)), + ...(subRef ? { ref: subRef } : {}), + ...(kind === 'select' && Array.isArray(sp.enum) ? { options: enumOptions(sp.enum) } : {}), + ...(hasEnabled && !isGate ? { showWhen: { field: `${key}.enabled`, equals: ['true'] } } : {}), + }; + fields.push(field); + } + continue; + } + + // ── scalars ───────────────────────────────────────────────────────────── + // A reference annotation (xRef) wins over the plain scalar mapping — the + // string is really a typed reference and gets a picker. + const ref = refOf(prop); + if (ref) { + fields.push({ id: key, path: ['config', key], kind: 'reference', ref, ...meta(prop, key) }); + continue; + } + const kind = scalarKind(prop); + if (!kind) continue; // unrepresentable → Advanced block + fields.push({ + id: key, + path: ['config', key], + kind, + ...meta(prop, key), + ...(kind === 'select' && Array.isArray(prop.enum) ? { options: enumOptions(prop.enum) } : {}), + }); + } + + return fields; +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index 5f6240e69..f64e74358 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -41,6 +41,7 @@ import { type Point, } from './flow-canvas-layout'; import { NodeCard, NodePalette, defaultNodeLabel, defaultNodeExtras } from './flow-canvas-parts'; +import { useFlowNodePalette } from './useFlowNodePalette'; const MIN_ZOOM = 0.4; const MAX_ZOOM = 1.6; @@ -96,6 +97,11 @@ export function FlowCanvas({ const [zoom, setZoom] = React.useState(1); const [pan, setPan] = React.useState({ x: 0, y: 0 }); const [paletteOpen, setPaletteOpen] = React.useState(false); + // Node types offered by the add-node palette, driven by the engine's + // published descriptors (`GET /api/v1/automation/actions`) merged with the + // hardcoded base — so the palette reflects what the backend actually supports + // (e.g. the `approval` node, third-party connector actions). + const paletteItems = useFlowNodePalette(); // Transient drag position override (commit-on-drop) so rapid pointer moves // never spam onPatch and never diverge from the persisted draft. @@ -334,6 +340,7 @@ export function FlowCanvas({ {paletteOpen && ( setPaletteOpen(false)} onPick={(type) => addNode(type, { from: selectedId ?? undefined })} /> diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx index 4fc10007b..ea8d6e26e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx @@ -369,17 +369,19 @@ export function NodeCard({ export interface NodePaletteProps { locale?: string; + /** Node types to offer. Defaults to the hardcoded {@link NODE_PALETTE}. */ + items?: PaletteItem[]; onPick: (type: string) => void; onClose: () => void; } /** Compact popover listing the node types an author can add. */ -export function NodePalette({ onPick, onClose }: NodePaletteProps) { +export function NodePalette({ items = NODE_PALETTE, onPick, onClose }: NodePaletteProps) { return ( <>
-
- {NODE_PALETTE.map((item) => { +
+ {items.map((item) => { const tone = nodeTone(item.type); return (