Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,31 @@ 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;
value: unknown;
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 (
<FlowReferenceField
field={field}
value={value}
onCommit={(v) => onCommit(v)}
disabled={disabled}
context={context}
/>
);
case 'keyValue':
return (
<FlowKeyValueField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
configKeyOf,
FLOW_NODE_TYPE_OPTIONS,
} from './flow-node-config';
import { jsonSchemaToFlowFields } from './json-schema-to-fields';
import { useActionConfigSchemas } from '../previews/useFlowNodePalette';
import { FlowNodeConfigField } from './FlowNodeConfigField';

interface FlowNode {
Expand Down Expand Up @@ -73,7 +75,17 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
const index = nodes.findIndex((n) => 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
Expand Down Expand Up @@ -202,6 +214,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
onCommit={(v) => setField(field.path, v)}
disabled={readOnly}
locale={locale}
context={{ draft, node }}
/>
))}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<datalist>` 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<string, unknown>;
/** The node currently being edited — used to resolve sibling config keys. */
node: Record<string, unknown> | null;
}

interface Option {
value: string;
label: string;
}

/** Read `node.config[key]` as a non-empty string, else undefined. */
function configString(node: Record<string, unknown> | 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<string, unknown>)[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<Record<string, unknown>>) : [];
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<Option[]>(() => {
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<Record<string, unknown>>) : [];
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 (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{field.label}</Label>
<Input
list={options.length ? listId : undefined}
value={value != null ? String(value) : ''}
onChange={(e) => onCommit(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
{options.length > 0 && (
<datalist id={listId}>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</datalist>
)}
{kind === 'object-field' && objectName && (
<p className="text-[11px] leading-snug text-muted-foreground">Fields of {objectName}.</p>
)}
{unresolvedObject && (
<p className="text-[11px] leading-snug text-muted-foreground">
Set the flow’s trigger object (on the Start node) to list fields.
</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -129,7 +159,8 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
{ 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.',
}),
Expand Down Expand Up @@ -183,21 +214,21 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
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' }),
Expand Down Expand Up @@ -299,7 +330,8 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
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.',
}),
Expand Down Expand Up @@ -350,7 +382,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
}),
],
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' },
Expand All @@ -364,7 +396,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
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' },
Expand Down Expand Up @@ -397,7 +429,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
*/
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' ),
Expand Down
Loading
Loading