Skip to main content

Host Integration

@vef-framework-react/approval-flow-editor is a self-contained business component. Its main integration surface is the plugins prop, not styling or slots — a host injects pickers and field metadata, and reads back a FlowDefinition through onChange.

plugins: EditorPlugins​

import type { EditorPlugins, FlowDefinition } from "@vef-framework-react/approval-flow-editor";

import { ApprovalFlowEditor } from "@vef-framework-react/approval-flow-editor";
import { useState } from "react";

const [definition, setDefinition] = useState<FlowDefinition>(initialValue);

const plugins: EditorPlugins = {
pickers: { user: UserPicker, role: RolePicker, department: DepartmentPicker },
assigneeKinds: servedKindOptions.assignees,
ccKinds: servedKindOptions.ccs,
formFields: [
{ key: "amount", kind: "number", label: "Amount" },
{ key: "reason", kind: "textarea", label: "Reason" }
]
};

<ApprovalFlowEditor plugins={plugins} value={definition} onChange={setDefinition} />;

EditorPlugins has seven optional fields:

FieldTypePurpose
pickersPartial<Record<string, FC<PickerProps>>>Resolves concrete ids, keyed by rule kind. Lookup falls back to the kind's selection mode, so the built-in user / role / department pickers also serve custom kinds that select the same thing.
assigneeKindsReadonlyArray<KindDescriptor<AssigneeKind>>The assignee catalog served by approval/flow.list_kind_options; omitting it uses BUILTIN_ASSIGNEE_KINDS.
ccKindsReadonlyArray<KindDescriptor<CcKind>>The CC catalog served by the same backend action; omitting it uses BUILTIN_CC_KINDS.
formFieldsFormFieldDefinition[]The form's top-level field inventory — feeds the condition editor, the field-permission table, and validateFlowDefinition's fieldPermissions cross-check. Leaving it undefined means "no inventory available" and is distinct from [] — see formFields below.
globalSubjectsFormFieldDefinition[]Host-declared variables the condition editor offers alongside the built-in applicant attributes and formFields.
sharedbooleanThe flow is shared — offered to every tenant (the backend's Flow.IsShared), so its work may be routed to people of other tenants than the one that starts it. Nothing is disabled or flagged for it; with tenants wired, an assignee or CC rule that picks users or departments asks for the tenant first.
tenantsReadonlyArray<TenantOption>The tenants a shared flow's user and department rules can pick from, each { value, label }. The editor knows no tenant of its own, so the host lists them; ignored unless shared is set (v2.30.0).

Pickers​

PickerProps is deliberately minimal — the editor only stores ids; the picker owns its own display (resolving names, rendering chips, search, etc.):

interface PickerProps {
value: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
tenantId?: string;
}

tenantId is set only for a user or department picker of a shared flow whose host wired tenants: a user or department belongs to one tenant, so the designer picks the tenant first (a 机构 select, which stores the choice as tenantId on the rule) and hands it to the picker, which should list that tenant's people. The picker is disabled until a tenant is chosen, and changing the tenant clears the picked ids. Roles and custom catalogs get no tenantId. Omitted, list whatever the host scopes the picker to by default.

import type { PickerProps } from "@vef-framework-react/approval-flow-editor";
import type { FC } from "react";

import { Select } from "@vef-framework-react/components";

function createUserPicker(): FC<PickerProps> {
return function UserPicker({ value, onChange, disabled }) {
const { data: options } = useUserOptionsQuery();

return (
<Select
allowClear
disabled={disabled}
mode="multiple"
options={options}
value={value}
onChange={onChange}
/>
);
};
}

isPrincipalKind and PRINCIPAL_KINDS (["user", "role", "department"]) are exported for hosts that need to build the picker map or a settings UI generically, without re-hardcoding the three kinds.

The built-in applicant condition subjects use the same mechanism: applicantId resolves through the user picker and applicantDepartmentId through the department picker, so hosts do not need a separate id-entry field.

Kind catalogs​

Each KindDescriptor is { kind, label, selection }. selection decides what the designer collects for a rule:

  • "none" — nothing; the backend resolves the kind from runtime context.
  • "user" / "role" / "department" — ids through the matching picker.
  • "form_field" — a field key.
  • "custom" — ids through the picker registered under the rule's own kind.

Pass the application's served catalog whenever it registers custom kinds. The built-in catalogs are only the fallback for applications that use the framework's own resolvers.

formFields​

Typically sourced from @vef-framework-react/approval-form-bridge's projectFormSchema(schema).formFields — see Approval Form Bridge — but any FormFieldDefinition[] works, including a hand-written one for a flow that isn't backed by a designed form.

Since v2.10.0 the inventory is tri-state, and the editor passes it through to validateFlowDefinition as-is (including undefined):

  • undefined (the field omitted) — the inventory is unavailable: a host rendering the flow editor without any form integration. The live validation and the publish gate skip only the key-existence cross-check (field_permission_key_unknown), since there is nothing to check keys against; the permission-value and CC-subset checks still run. A definition with dangling fieldPermissions keys is not flagged in this state, so a form-less host is never falsely blocked.
  • [] (an explicit empty array) — the form is known to have zero fields: every fieldPermissions entry is a dangling reference and errors, mirroring the backend's "flow whose form has zero fields" case.
  • A populated array — every fieldPermissions key must name an entry, and the condition editor / field-permission table render from it.

Pass the projection's real formFields (populated or []) whenever a form exists; omit the plugin field entirely only when there is genuinely no form integration.

A projection-sourced entry may carry hasConditionalVisibility: true (v2.10.0), meaning form linkage can hide that field at runtime. The field-permission table pairs this with a per-row warning: granting required on such a field can deadlock the approval — the backend's required check is linkage-blind, so if linkage hides the field at approval time, its empty value rejects the approve forever. The warning is a non-blocking tooltip (the combination is legitimate when the approver can edit the controlling field) and never enters validateFlowDefinition's publish gate. A related conflict is blocking: pairing a required permission with timeoutAction: "auto_pass" raises field_permission_required_auto_pass, because the auto-pass timeout path finishes the node without the manual-approve required check (see API Reference → Validation).

globalSubjects​

Variables the engine resolves from the instance's server-side globals snapshot (Instance.Globals on the backend, populated by the host's InstanceGlobalsResolver at instance start — never from the client) rather than from form data. kind drives the operator set and value input exactly as it does for formFields:

const plugins: EditorPlugins = {
globalSubjects: [
{ key: "applicantLevel", kind: "number", label: "Applicant level" },
{
key: "applicantDepartmentName",
kind: "select",
label: "Applicant department",
options: [
{ label: "Engineering", value: "engineering" },
{ label: "Sales", value: "sales" }
]
}
]
};

Resolution order matters when keys collide: a globalSubjects key colliding with a built-in applicant subject is dropped, and a formFields key colliding with either a built-in subject or a global subject is shadowed.

Condition operators​

ConditionDefinition.operator draws from a closed vocabulary the backend's approval.ConditionOperator mirrors literal-for-literal:

const CONDITION_OPERATORS = [
"eq", "ne", "gt", "gte", "lt", "lte",
"contains", "not_contains", "starts_with", "ends_with",
"in", "not_in", "is_empty", "is_not_empty"
] as const;

Import CONDITION_OPERATORS (the runtime array) and ConditionOperator (the derived type) when building a custom condition UI on top of the same vocabulary — one definition site backs both the type and the allow-list validateFlowDefinition checks against.

Serialization: fromFlowDefinition / toFlowDefinition​

The editor uses these internally to convert between the wire FlowDefinition and its live canvas graph on every controlled value / onChange round-trip, so most hosts never call them directly. They're exported for cases where a host needs the same conversion outside a mounted editor — e.g. constructing/inspecting a FlowDefinition in a test, or normalizing a hand-authored definition before first passing it in as value.

import { fromFlowDefinition, toFlowDefinition } from "@vef-framework-react/approval-flow-editor";

const { nodes, edges } = fromFlowDefinition(savedDefinition); // wire → live graph
const definition = toFlowDefinition(nodes, edges); // live graph → wire (detached deep copy)

fromFlowDefinition normalizes omitted fields to designer defaults, so a definition saved by an older editor version or authored externally still hydrates to fully-explicit configuration.

Validation: validateFlowDefinition​

import { validateFlowDefinition } from "@vef-framework-react/approval-flow-editor";

const errors = validateFlowDefinition(definition, { formFields, assigneeKinds, ccKinds });
// errors: FlowValidationError[] — empty means the flow is deploy-ready

Mirrors the backend's deploy-time ValidateFlowDefinition rule-for-rule — including, since v2.10.0, the field-permission rules (permission values must be in-vocabulary, keys must name real form fields, CC nodes are restricted to visible/hidden, and a required permission must not pair with an auto-pass timeout) — but, unlike the backend, which returns the first error, collects every violation so a host can surface them all at once. A definition that passes this validator passes backend deploy validation.

The second parameter is a FlowValidationContext. Its formFields entry is the tri-state inventory: pass the same list handed to plugins.formFields so fieldPermissions keys are cross-checked against the real field inventory. [] means "the form has zero fields" (every permission key errors); omitting the entry means "no inventory available" and skips only the key-existence check. Pass the same assignee and CC catalogs handed to the editor so custom kinds validate instead of being reported as unknown.

Use it to gate a save/deploy action outside the editor's own publish button — for example, disabling a wizard's "next step" control:

const errors = useMemo(
() => validateFlowDefinition(flowDefinition, { formFields, assigneeKinds, ccKinds }),
[flowDefinition, formFields, assigneeKinds, ccKinds]
);

<Button disabled={errors.length > 0} onClick={handleNext}>Next</Button>;

Each FlowValidationError carries a stable code (FlowValidationCode), a human-readable message, and — depending on the rule — nodeId, edgeId, and/or branchId for surfacing the error next to the offending element. See API Reference for the full code list.

Putting it together​

A host building an end-to-end designer typically combines all three pieces — pickers, formFields sourced from approval-form-bridge, and validateFlowDefinition as a step gate:

import type { EditorPlugins, FlowDefinition } from "@vef-framework-react/approval-flow-editor";

import { ApprovalFlowEditor, validateFlowDefinition } from "@vef-framework-react/approval-flow-editor";
import { projectFormSchema } from "@vef-framework-react/approval-form-bridge";
import { useMemo, useState } from "react";

function FlowStep({ formSchema, pickers }: { formSchema: FormSchema; pickers: EditorPlugins["pickers"] }) {
const [definition, setDefinition] = useState<FlowDefinition>({ nodes: [], edges: [] });
const projection = useMemo(() => projectFormSchema(formSchema), [formSchema]);
const plugins = useMemo<EditorPlugins>(
() => ({ pickers, formFields: projection.formFields }),
[pickers, projection]
);
const errors = useMemo(
() => validateFlowDefinition(definition, projection.formFields),
[definition, projection]
);

return (
<>
<ApprovalFlowEditor plugins={plugins} value={definition} onChange={setDefinition} />
<Button disabled={errors.length > 0} onClick={() => saveFlow(definition)}>Save</Button>
</>
);
}