diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 85da94621..5b4f0371e 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -325,6 +325,20 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.flowNode.list.remove': 'Remove item', 'engine.inspector.flowNode.list.empty': 'No items yet.', 'engine.inspector.flowNode.remove': 'Remove node', + // Flow edge (connection) inspector + 'engine.inspector.flowEdge.kind': 'Connection', + 'engine.inspector.flowEdge.close': 'Close connection', + 'engine.inspector.flowEdge.missing': 'This connection no longer exists.', + 'engine.inspector.flowEdge.source': 'From', + 'engine.inspector.flowEdge.target': 'To', + 'engine.inspector.flowEdge.routing': 'Routing', + 'engine.inspector.flowEdge.label': 'Branch label', + 'engine.inspector.flowEdge.labelHint': 'e.g. approve / reject', + 'engine.inspector.flowEdge.condition': 'Condition', + 'engine.inspector.flowEdge.conditionHint': 'CEL expression — taken when it evaluates true', + 'engine.inspector.flowEdge.isDefault': 'Default branch (else)', + 'engine.inspector.flowEdge.hint': 'The engine follows this connection when its branch label is selected or its condition is met. The default branch is taken when no other matches.', + 'engine.inspector.flowEdge.remove': 'Remove connection', // Workflow action inspector 'engine.inspector.workflowAction.kind': 'Action', 'engine.inspector.workflowAction.close': 'Close action', @@ -646,6 +660,20 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.flowNode.list.remove': '删除项', 'engine.inspector.flowNode.list.empty': '暂无项。', 'engine.inspector.flowNode.remove': '删除节点', + // Flow edge (connection) inspector + 'engine.inspector.flowEdge.kind': '连线', + 'engine.inspector.flowEdge.close': '关闭连线', + 'engine.inspector.flowEdge.missing': '该连线已不存在。', + 'engine.inspector.flowEdge.source': '起点', + 'engine.inspector.flowEdge.target': '终点', + 'engine.inspector.flowEdge.routing': '路由', + 'engine.inspector.flowEdge.label': '分支标签', + 'engine.inspector.flowEdge.labelHint': '例如 approve / reject', + 'engine.inspector.flowEdge.condition': '条件', + 'engine.inspector.flowEdge.conditionHint': 'CEL 表达式 —— 为真时走此连线', + 'engine.inspector.flowEdge.isDefault': '默认分支(else)', + 'engine.inspector.flowEdge.hint': '当此连线的分支标签被选中、或其条件成立时,引擎会走这条连线。其余都不匹配时走默认分支。', + 'engine.inspector.flowEdge.remove': '删除连线', // Workflow action inspector 'engine.inspector.workflowAction.kind': '动作', 'engine.inspector.workflowAction.close': '关闭动作', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx new file mode 100644 index 000000000..b4c9a6f3e --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowEdgeInspector — scoped editor for the selected flow connection (edge). + * + * Selection shape: { kind: 'edge', id: } + * Patches: draft.edges[i] = {...edge, ...updates} + * + * An edge carries the flow's routing semantics between two nodes: an optional + * branch `label` (e.g. an Approval node's `approve` / `reject` out-edge, a + * Decision branch name), a guard `condition` (a CEL expression the engine + * evaluates to pick the branch), and an `isDefault` flag marking the fallback + * ("else") branch. Source / target are shown read-only — rewiring is done on + * the canvas, not here — so the edge's identity key stays stable across edits. + */ + +import * as React from 'react'; +import type { MetadataInspectorProps } from '../inspector-registry'; +import { t } from '../i18n'; +import { + InspectorShell, + InspectorTextField, + InspectorCheckboxField, + InspectorRemoveButton, + InspectorEmptyState, + spliceArray, +} from './_shared'; +import { Label } from '@object-ui/components'; +import { edgeKey, conditionText } from '../previews/flow-canvas-layout'; + +interface FlowEdge { + id?: string; + source: string; + target: string; + condition?: string | { source?: string }; + type?: string; + label?: string; + isDefault?: boolean; + [k: string]: unknown; +} + +/** Read-only display of an edge endpoint (source / target node id). */ +function EndpointRow({ label, value }: { label: string; value: string }) { + return ( +
+ +
+ {value} +
+
+ ); +} + +export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection, locale, readOnly }: MetadataInspectorProps) { + const edges = Array.isArray((draft as any).edges) ? ((draft as any).edges as FlowEdge[]) : []; + const index = edges.findIndex((e, i) => edgeKey(e, i) === selection.id); + const edge = index >= 0 ? edges[index] : null; + + if (!edge) { + return ( + + + + ); + } + + // Splice an updated edge in place. A field edit never moves the edge in the + // array, so the row index is stable; but an edge without an explicit `id` + // keys off `source->target#index`, so we re-point the selection to the fresh + // key after the patch to keep the panel attached to the same edge. + const patchEdge = (updates: Partial) => { + const next: FlowEdge = { ...edge, ...updates }; + // Prune empty optional keys so a cleared field doesn't linger in the draft. + for (const k of ['label', 'condition', 'isDefault'] as const) { + const v = next[k]; + if (v === undefined || v === '' || v === false) delete next[k]; + } + onPatch({ edges: spliceArray(edges, index, next) }); + }; + + const isDefault = edge.isDefault === true; + + return ( + { + onPatch({ edges: spliceArray(edges, index, null) }); + onClearSelection(); + }} + disabled={readOnly} + /> + } + > + + + +
+ + {t('engine.inspector.flowEdge.routing', locale)} + + +
+ + patchEdge({ label: v })} + placeholder={t('engine.inspector.flowEdge.labelHint', locale)} + disabled={readOnly || isDefault} + /> + patchEdge({ condition: v || undefined })} + placeholder={t('engine.inspector.flowEdge.conditionHint', locale)} + disabled={readOnly || isDefault} + mono + /> + patchEdge(v ? { isDefault: true, condition: undefined, label: undefined } : { isDefault: false })} + disabled={readOnly} + /> +

+ {t('engine.inspector.flowEdge.hint', locale)} +

+
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowInspector.tsx new file mode 100644 index 000000000..5a2ea74b7 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowInspector.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowInspector — the single inspector registered for the `flow` metadata type. + * + * The flow canvas emits two kinds of selection (see `FlowPreview` / + * `FlowCanvas`): a node (`{ kind: 'node' }`) or a connection edge + * (`{ kind: 'edge' }`). This thin router forwards each to its focused editor so + * neither component has to know about the other. Anything else falls through to + * the node inspector (which renders an empty-state for an unknown id). + */ + +import * as React from 'react'; +import type { MetadataInspectorProps } from '../inspector-registry'; +import { FlowNodeInspector } from './FlowNodeInspector'; +import { FlowEdgeInspector } from './FlowEdgeInspector'; + +export function FlowInspector(props: MetadataInspectorProps) { + if (props.selection.kind === 'edge') { + return ; + } + return ; +} 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 291027f6e..13874ad76 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -82,6 +82,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, addLabel={t('engine.inspector.flowNode.list.add', locale)} removeLabel={t('engine.inspector.flowNode.list.remove', locale)} emptyLabel={t('engine.inspector.flowNode.list.empty', locale)} + context={context} /> ); case 'number': diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx index d382372c9..4edf2f17c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx @@ -16,6 +16,7 @@ import { Plus, X } from 'lucide-react'; import { Button, Input, Label, Checkbox } from '@object-ui/components'; import { uniqueId } from './_shared'; import type { FlowConfigColumn } from './flow-node-config'; +import { ReferenceCombobox, resolveRefKind, type FlowReferenceContext } from './FlowReferenceField'; type Cell = string | boolean; interface Row { @@ -70,6 +71,8 @@ export interface FlowObjectListFieldProps { addLabel: string; removeLabel: string; emptyLabel: string; + /** Draft + node context so `reference` columns can resolve their options. */ + context?: FlowReferenceContext; } export function FlowObjectListField({ @@ -81,6 +84,7 @@ export function FlowObjectListField({ addLabel, removeLabel, emptyLabel, + context, }: FlowObjectListFieldProps) { const external = React.useMemo( () => @@ -169,6 +173,19 @@ export function FlowObjectListField({ }} disabled={disabled} /> + ) : col.kind === 'reference' ? ( +
+ row.values[k])} + value={typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : ''} + onCommit={(v) => setCell(row.id, col.key, typeof v === 'string' ? v : '')} + onBlur={() => flush(rows)} + placeholder={col.placeholder} + disabled={disabled} + context={context} + showHint={false} + /> +
) : ( > = { + object: 'object', + flow: 'flow', + role: 'role', + user: 'user', + team: 'team', + queue: 'queue', + department: 'department', + connector: 'connector', + 'email-template': 'email_template', +}; + +/** A concrete (non-polymorphic) reference resolution. */ +export interface ResolvedRef { + kind: ReferenceKind; + objectSource?: string; +} + +/** + * Resolve a (possibly polymorphic) reference spec to a concrete kind. For a + * polymorphic spec, `sibling(key)` supplies the discriminator value (the row's + * `type`, or a sibling config key). Returns undefined when nothing resolves — + * the caller then renders plain free text. + */ +export function resolveRefKind( + ref: FlowReferenceSpec | undefined, + sibling: (key: string) => unknown, +): ResolvedRef | undefined { + if (!ref) return undefined; + if (ref.kind) return { kind: ref.kind, objectSource: ref.objectSource }; + if (ref.kindFrom && ref.map) { + const disc = sibling(ref.kindFrom); + const k = typeof disc === 'string' ? ref.map[disc] : undefined; + if (k) return { kind: k, objectSource: ref.objectSource }; + } + return undefined; +} + /** 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; @@ -47,9 +98,9 @@ function configString(node: Record | null | undefined, key: str } /** 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'; +function resolveObjectName(kind: ReferenceKind, objectSource: string | undefined, ctx: FlowReferenceContext): string | undefined { + if (kind !== 'object-field') return undefined; + const src = 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'); @@ -99,67 +150,72 @@ function useMetadataListOptions(type: string | undefined): { options: Option[]; return state; } -export interface FlowReferenceFieldProps { - field: FlowConfigField; +export interface ReferenceComboboxProps { + /** The resolved concrete reference, or undefined → plain free text. */ + resolved: ResolvedRef | undefined; value: unknown; onCommit: (value: unknown) => void; + /** Optional blur handler (the `objectList` repeater flushes rows on blur). */ + onBlur?: () => void; disabled?: boolean; + placeholder?: string; context?: FlowReferenceContext; + /** Show the "Fields of X." / unresolved hint under the control (default true). */ + showHint?: boolean; } -export function FlowReferenceField({ field, value, onCommit, disabled, context }: FlowReferenceFieldProps) { +/** + * The bare reference combobox — suggestions for `resolved.kind`, always + * free-text editable. Hooks are called unconditionally (kind-gated args) so the + * component is safe to use in a repeater where the kind changes per row. + */ +export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled, placeholder, context, showHint = true }: ReferenceComboboxProps) { const listId = React.useId(); - const ref = field.ref; const ctx: FlowReferenceContext = context ?? { draft: {}, node: null }; - const kind = ref?.kind; + const kind = resolved?.kind; // object-field: resolve the target object, then its field catalog. - const objectName = resolveObjectName(ref, ctx); + const objectName = resolved ? resolveObjectName(resolved.kind, resolved.objectSource, ctx) : undefined; 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; + // Flat metadata-list kinds (object / flow / role / user / team / …). + const listType = kind && kind !== 'object-field' && kind !== 'node' ? KIND_TO_META_TYPE[kind] : 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 []; + if (kind === 'object-field') { + return objectFields.map((f) => ({ + value: f.name, + label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name, + })); } - }, [kind, objectFields, listOptions, ctx.draft, ctx.node]); + if (kind === '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 }; + }); + } + if (listType) return listOptions; + return []; + }, [kind, listType, 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} + onBlur={onBlur} + placeholder={placeholder} disabled={disabled} className="h-8 text-sm" /> @@ -172,10 +228,10 @@ export function FlowReferenceField({ field, value, onCommit, disabled, context } ))} )} - {kind === 'object-field' && objectName && ( + {showHint && kind === 'object-field' && objectName && (

Fields of {objectName}.

)} - {unresolvedObject && ( + {showHint && unresolvedObject && (

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

@@ -183,3 +239,34 @@ export function FlowReferenceField({ field, value, onCommit, disabled, context }
); } + +export interface FlowReferenceFieldProps { + field: { label: string; placeholder?: string; ref?: FlowReferenceSpec }; + value: unknown; + onCommit: (value: unknown) => void; + disabled?: boolean; + context?: FlowReferenceContext; +} + +/** + * Inspector field wrapper: a labelled reference combobox. A polymorphic ref is + * resolved against the node's own sibling config keys (e.g. the script node's + * `template` follows `actionType`). + */ +export function FlowReferenceField({ field, value, onCommit, disabled, context }: FlowReferenceFieldProps) { + const node = context?.node ?? null; + const resolved = resolveRefKind(field.ref, (key) => configString(node, key)); + return ( +
+ + +
+ ); +} 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 96879e2a1..4b227a64d 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 @@ -48,11 +48,33 @@ export type FlowConfigFieldKind = * • `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) + * • `user` / `team` / `queue` / `department` → the matching metadata list + * (`client.list(kind)`); empty in dev, populated per tenant + * • `connector` → an installed connector (`client.list('connector')`) + * • `email-template`→ an email template (`client.list('email_template')`) + * + * Kinds that have no catalog in the current tenant simply degrade to a plain + * text box — the control is always an editable combobox, never a hard dropdown. */ -export type ReferenceKind = 'object' | 'object-field' | 'flow' | 'role' | 'node'; +export type ReferenceKind = + | 'object' + | 'object-field' + | 'flow' + | 'role' + | 'node' + | 'user' + | 'team' + | 'queue' + | 'department' + | 'connector' + | 'email-template'; export interface FlowReferenceSpec { - kind: ReferenceKind; + /** + * Concrete reference kind. Omit when the kind is *polymorphic* — chosen at + * render time from a sibling value (see {@link kindFrom}). + */ + kind?: ReferenceKind; /** * For `object-field` only: where to find the target object's name. * • `'$trigger'` (default) → the flow trigger object, read from the start @@ -61,15 +83,26 @@ export interface FlowReferenceSpec { * the object name (e.g. CRUD nodes resolve from their own `objectName`). */ objectSource?: string; + /** + * Polymorphic reference: the kind is selected at render time by the value of + * a sibling field/column named `kindFrom`, looked up in {@link map}. A value + * with no mapping (or an empty sibling) falls back to free text. Used by the + * approval node's `approvers[].value` (kind follows the row's `type`) and the + * script node's `template` (follows `actionType`). + */ + kindFrom?: string; + map?: Record; } /** Column descriptor for an `objectList` repeater row. */ export interface FlowConfigColumn { key: string; label: string; - kind: 'text' | 'expression' | 'boolean' | 'select'; + kind: 'text' | 'expression' | 'boolean' | 'select' | 'reference'; placeholder?: string; options?: Array<{ value: string; label: string }>; + /** For `kind: 'reference'` — the picker data source (may be polymorphic). */ + ref?: FlowReferenceSpec; } export interface FlowConfigField { @@ -256,7 +289,10 @@ const FLOW_NODE_CONFIG: Record = { defaultValue: 'code', help: 'How this step runs. Leave as Code for a raw script.', }), - cfg('template', 'Template', 'text', { + cfg('template', 'Template', 'reference', { + // Polymorphic: an email step picks from the email-template catalog; sms / + // notification have no flat catalog yet, so they degrade to free text. + ref: { kindFrom: 'actionType', map: { email: 'email-template' } }, placeholder: 'case_escalated', help: 'Message template id.', showWhen: { field: 'actionType', equals: ['email', 'sms', 'notification'] }, @@ -316,7 +352,27 @@ const FLOW_NODE_CONFIG: Record = { { value: 'queue', label: 'Queue' }, ], }, - { key: 'value', label: 'Value', kind: 'text', placeholder: 'user id / role / field — per type' }, + { + // Polymorphic: the picker follows the row's `type`. `manager` takes no + // value (resolved from the submitter's manager_id) so it stays unmapped + // → free text; unmapped/empty types likewise fall back to free text. + key: 'value', + label: 'Value', + kind: 'reference', + placeholder: 'user id / role / field — per type', + ref: { + kindFrom: 'type', + objectSource: '$trigger', + map: { + user: 'user', + role: 'role', + team: 'team', + department: 'department', + field: 'object-field', + queue: 'queue', + }, + }, + }, ], }), cfg('behavior', 'Behavior', 'select', { @@ -349,7 +405,7 @@ const FLOW_NODE_CONFIG: Record = { ], showWhen: { field: 'escalation.enabled', equals: ['true'] }, }, - { id: 'escalation.escalateTo', path: ['config', 'escalation', 'escalateTo'], label: 'Escalate to', kind: 'text', placeholder: 'user id / role / manager level', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, + { id: 'escalation.escalateTo', path: ['config', 'escalation', 'escalateTo'], label: 'Escalate to', kind: 'reference', ref: { kind: 'role' }, placeholder: 'user id / role / manager level', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, { id: 'escalation.notifySubmitter', path: ['config', 'escalation', 'notifySubmitter'], label: 'Notify submitter', kind: 'boolean', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, ], wait: [ @@ -388,7 +444,9 @@ const FLOW_NODE_CONFIG: Record = { { id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '60000' }, ], connector_action: [ - at('connectorConfig', 'connectorId', 'Connector', 'text', { placeholder: 'slack · email · salesforce' }), + at('connectorConfig', 'connectorId', 'Connector', 'reference', { ref: { kind: 'connector' }, placeholder: 'slack · email · salesforce' }), + // actionId is polymorphic on the chosen connector and has no flat catalog + // (a deliberate open extension point) — stays free text. at('connectorConfig', 'actionId', 'Action', 'text', { placeholder: 'sendMessage · send' }), at('connectorConfig', 'input', 'Input', 'keyValue', { help: 'Mapped inputs for the connector action.' }), { id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '30000' }, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts index 501832cfb..abf650d61 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts @@ -8,7 +8,7 @@ import { registerMetadataInspector } from '../inspector-registry'; import { registerMetadataDefaultInspector } from '../default-inspector-registry'; import { DashboardWidgetInspector } from './DashboardWidgetInspector'; -import { FlowNodeInspector } from './FlowNodeInspector'; +import { FlowInspector } from './FlowInspector'; import { WorkflowActionInspector } from './WorkflowActionInspector'; import { AppNavInspector } from './AppNavInspector'; import { ViewInspector, ViewDefaultInspector } from './ViewInspector'; @@ -19,8 +19,9 @@ import { ObjectFieldInspector } from './ObjectFieldInspector'; export function registerBuiltinInspectors(): void { registerMetadataInspector('dashboard', DashboardWidgetInspector); // Approval is authored as a flow node (`type: 'approval'`) since ADR-0019 — - // edited through FlowNodeInspector, not a standalone step inspector. - registerMetadataInspector('flow', FlowNodeInspector); + // edited through FlowInspector, not a standalone step inspector. FlowInspector + // routes node vs. edge selections to the right scoped editor. + registerMetadataInspector('flow', FlowInspector); registerMetadataInspector('workflow', WorkflowActionInspector); registerMetadataInspector('app', AppNavInspector); registerMetadataInspector('view', ViewInspector); 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 index 9de09b7f4..4e65118b4 100644 --- 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 @@ -19,7 +19,15 @@ const APPROVAL_CONFIG_SCHEMA = { 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' }, + value: { + description: 'User id / role / team / department / field / queue — per `type`', + type: 'string', + xRef: { + kindFrom: 'type', + objectSource: '$trigger', + map: { user: 'user', role: 'role', team: 'team', department: 'department', field: 'object-field', queue: 'queue' }, + }, + }, }, required: ['type'], }, @@ -44,7 +52,7 @@ const APPROVAL_CONFIG_SCHEMA = { 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' }, + escalateTo: { description: 'User id, role, or manager level to escalate to', type: 'string', xRef: { kind: 'role' } }, notifySubmitter: { default: true, description: 'Notify the original submitter on escalation', type: 'boolean' }, }, required: ['timeoutHours'], @@ -97,10 +105,52 @@ describe('jsonSchemaToFlowFields', () => { 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'); + // Polymorphic reference: the picker follows the row's `type`. + expect(valueCol.kind).toBe('reference'); + expect(valueCol.ref).toEqual({ + kindFrom: 'type', + objectSource: '$trigger', + map: { user: 'user', role: 'role', team: 'team', department: 'department', field: 'object-field', queue: 'queue' }, + }); expect(valueCol.placeholder).toBe('User id / role / team / department / field / queue — per `type`'); }); + it('maps a static-kind xRef column into a reference column', () => { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { + rows: { + type: 'array', + items: { + type: 'object', + properties: { connector: { type: 'string', xRef: { kind: 'connector' } } }, + }, + }, + }, + })!; + const col = fields.find((f) => f.id === 'rows')!.columns!.find((c) => c.key === 'connector')!; + expect(col.kind).toBe('reference'); + expect(col.ref).toEqual({ kind: 'connector' }); + }); + + it('drops a polymorphic xRef whose map has no known kinds (column stays text)', () => { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { + rows: { + type: 'array', + items: { + type: 'object', + properties: { value: { type: 'string', xRef: { kindFrom: 'type', map: { foo: 'bogus' } } } }, + }, + }, + }, + })!; + const col = fields.find((f) => f.id === 'rows')!.columns!.find((c) => c.key === 'value')!; + expect(col.kind).toBe('text'); + expect(col.ref).toBeUndefined(); + }); + 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')!; @@ -167,5 +217,11 @@ describe('jsonSchemaToFlowFields', () => { 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'] }); + + // A nested xRef string flattens into a reference field, still gated. + const escalateTo = fields.find((f) => f.id === 'escalation.escalateTo')!; + expect(escalateTo.kind).toBe('reference'); + expect(escalateTo.ref).toEqual({ kind: 'role' }); + expect(escalateTo.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 index 8b46ee542..ba9e936b9 100644 --- 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 @@ -47,10 +47,12 @@ interface 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. + * (ADR-0018). Marks a string as a typed reference so the inspector renders a + * picker instead of free text. Either static (`kind`) or **polymorphic** + * (`kindFrom` + `map`): the concrete kind is chosen at render time from a + * sibling field/column value (e.g. an approver's `value` follows its `type`). */ - xRef?: { kind?: string; objectSource?: string }; + xRef?: { kind?: string; objectSource?: string; kindFrom?: string; map?: Record }; } const REFERENCE_KINDS: ReadonlySet = new Set([ @@ -59,16 +61,40 @@ const REFERENCE_KINDS: ReadonlySet = new Set([ 'flow', 'role', 'node', + 'user', + 'team', + 'queue', + 'department', + 'connector', + 'email-template', ]); -/** Read a valid `xRef` annotation off a schema node, or undefined. */ +/** + * Read a valid `xRef` annotation off a schema node, or undefined. Accepts both + * the static shape (`{ kind }`) and the polymorphic shape (`{ kindFrom, map }`), + * validating every referenced kind against {@link REFERENCE_KINDS} so an unknown + * kind degrades to free text rather than a broken picker. + */ 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 } : {}), - }; + if (!x || typeof x !== 'object') return undefined; + const objectSource = typeof x.objectSource === 'string' && x.objectSource ? { objectSource: x.objectSource } : {}; + + // Polymorphic: kindFrom + a map of discriminator value → kind. + if (typeof x.kindFrom === 'string' && x.kindFrom && x.map && typeof x.map === 'object') { + const map: Record = {}; + for (const [disc, kind] of Object.entries(x.map)) { + if (typeof kind === 'string' && REFERENCE_KINDS.has(kind)) map[disc] = kind as ReferenceKind; + } + if (Object.keys(map).length === 0) return undefined; + return { kindFrom: x.kindFrom, map, ...objectSource }; + } + + // Static: a single concrete kind. + if (typeof x.kind === 'string' && REFERENCE_KINDS.has(x.kind)) { + return { kind: x.kind as ReferenceKind, ...objectSource }; + } + return undefined; } /** "approvalStatusField" → "Approval Status Field"; "approve" → "Approve". */ @@ -134,7 +160,12 @@ function columnsFor(item: JsonSchemaNode): FlowConfigColumn[] { const t = schemaType(prop); let kind: FlowConfigColumn['kind']; let options: Array<{ value: string; label: string }> | undefined; - if (Array.isArray(prop.enum)) { + // A reference annotation wins over the plain scalar mapping — the column is + // a typed reference and gets a picker (static or polymorphic via kindFrom). + const ref = refOf(prop); + if (ref) { + kind = 'reference'; + } else if (Array.isArray(prop.enum)) { kind = 'select'; options = enumOptions(prop.enum); } else if (t === 'boolean') { @@ -147,6 +178,7 @@ function columnsFor(item: JsonSchemaNode): FlowConfigColumn[] { label: prop.title || humanizeKey(key), kind, ...(options ? { options } : {}), + ...(ref ? { ref } : {}), // Columns have no help slot — surface the schema description as a hint. ...(prop.description ? { placeholder: prop.description } : {}), }); 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 f64e74358..d07414e4b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -33,6 +33,7 @@ import { topAnchor, edgePath, edgeMidpoint, + edgeKey, conditionText, NODE_H, V_GAP, @@ -69,6 +70,8 @@ export interface FlowCanvasProps { editable: boolean; designMode: boolean; selectedId: string | null; + /** Stable key (see `edgeKey`) of the currently-selected edge, or null. */ + selectedEdgeId?: string | null; locale?: string; /** Simulation overlay: currently-executing node. */ activeNodeId?: string | null; @@ -77,6 +80,8 @@ export interface FlowCanvasProps { /** Simulation overlay: ids of edges that were traversed. */ traversedEdgeIds?: string[]; onSelect: (node: FlowNode | null) => void; + /** Select an edge (its `edgeKey`), or clear selection with `null`. */ + onSelectEdge?: (edge: FlowEdge | null, key: string) => void; onPatch?: (partial: Record) => void; } @@ -86,11 +91,13 @@ export function FlowCanvas({ editable, designMode, selectedId, + selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, + onSelectEdge, onPatch, }: FlowCanvasProps) { const viewportRef = React.useRef(null); @@ -438,23 +445,63 @@ export function FlowCanvas({ const mid = edgeMidpoint(from, to); const cond = conditionText(edge.condition); const branchLabel = edge.isDefault ? 'else' : cond ? `if ${cond}` : edge.label; - const eid = edge.id || `${edge.source}->${edge.target}#${i}`; + const eid = edgeKey(edge, i); const traversed = traversedSet.has(eid); + const selected = selectedEdgeId === eid; + const d = edgePath(from, to); + // Edges are selectable in design mode; the host opens the edge + // inspector. A wide transparent hit-path widens the click target + // beyond the 1.5px visible stroke without altering the visuals. + const selectable = designMode && !!onSelectEdge; return ( + {selectable && ( + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + onSelectEdge!(edge, eid); + }} + > + {`${edge.source} → ${edge.target}`} + + )} {branchLabel && ( - +
- + e.stopPropagation() : undefined} + onClick={selectable ? (e) => { e.stopPropagation(); onSelectEdge!(edge, eid); } : undefined} + className={cn( + 'max-w-full truncate rounded border bg-background px-1.5 py-0.5 text-[10px] font-medium shadow-sm', + selectable && 'cursor-pointer', + selected ? 'border-primary text-primary' : 'text-muted-foreground', + )} + > {branchLabel}
diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx index f8538a991..138a04d38 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx @@ -34,6 +34,7 @@ import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShe import { uniqueId, appendArray } from '../inspectors/_shared'; import { t as tr } from '../i18n'; import { FlowCanvas } from './FlowCanvas'; +import { edgeKey } from './flow-canvas-layout'; import { FlowSimulatorPanel } from './FlowSimulatorPanel'; interface FlowNode { @@ -73,6 +74,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa const designMode = !!(editing && onSelectionChange); const canEdit = designMode && !!onPatch; const selectedId = selection && selection.kind === 'node' ? selection.id : null; + const selectedEdgeId = selection && selection.kind === 'edge' ? selection.id : null; const [showDebug, setShowDebug] = React.useState(false); const [showVars, setShowVars] = React.useState(true); @@ -170,6 +172,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa editable={canEdit} designMode={designMode} selectedId={selectedId} + selectedEdgeId={selectedEdgeId} locale={locale} activeNodeId={runHL?.activeNodeId ?? null} visitedNodeIds={runHL?.visitedNodeIds} @@ -179,6 +182,11 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa ? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id }) : onSelectionChange?.(null) } + onSelectEdge={(e, key) => + e + ? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` }) + : onSelectionChange?.(null) + } onPatch={onPatch} />
diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts index 27dbe9696..c64e80234 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts @@ -216,6 +216,18 @@ export function edgeMidpoint(from: Point, to: Point): Point { return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; } +/** + * Stable identity for an edge. Prefers an explicit `edge.id`; otherwise falls + * back to a `source->target#index` composite so an unsaved edge still has a + * deterministic key. Used for selection, traversal highlighting, and inspector + * lookup — all of which read the same `draft.edges` array, so the index is + * consistent across them. Editing label/condition/isDefault never changes the + * key (source/target/index are untouched), so a selection survives edits. + */ +export function edgeKey(edge: FlowEdge, index: number): string { + return edge.id || `${edge.source}->${edge.target}#${index}`; +} + /** Human-readable condition text for an edge's optional guard. */ export function conditionText(c: FlowEdge['condition']): string | undefined { if (!c) return undefined;