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
28 changes: 28 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,20 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -646,6 +660,20 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '关闭动作',
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <edgeKey> }
* 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 (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<div className="flex h-8 items-center rounded border bg-muted/30 px-2 font-mono text-sm text-muted-foreground">
{value}
</div>
</div>
);
}

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 (
<InspectorShell
kindLabel={t('engine.inspector.flowEdge.kind', locale)}
title={selection.label ?? selection.id}
onClose={onClearSelection}
closeLabel={t('engine.inspector.flowEdge.close', locale)}
>
<InspectorEmptyState message={t('engine.inspector.flowEdge.missing', locale)} />
</InspectorShell>
);
}

// 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<FlowEdge>) => {
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 (
<InspectorShell
kindLabel={t('engine.inspector.flowEdge.kind', locale)}
title={selection.label ?? `${edge.source} → ${edge.target}`}
onClose={onClearSelection}
closeLabel={t('engine.inspector.flowEdge.close', locale)}
footer={
<InspectorRemoveButton
label={t('engine.inspector.flowEdge.remove', locale)}
onClick={() => {
onPatch({ edges: spliceArray(edges, index, null) });
onClearSelection();
}}
disabled={readOnly}
/>
}
>
<EndpointRow label={t('engine.inspector.flowEdge.source', locale)} value={edge.source} />
<EndpointRow label={t('engine.inspector.flowEdge.target', locale)} value={edge.target} />

<div className="flex items-center gap-2 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('engine.inspector.flowEdge.routing', locale)}
</span>
<span className="h-px flex-1 bg-border" aria-hidden />
</div>

<InspectorTextField
label={t('engine.inspector.flowEdge.label', locale)}
value={edge.label ?? ''}
onCommit={(v) => patchEdge({ label: v })}
placeholder={t('engine.inspector.flowEdge.labelHint', locale)}
disabled={readOnly || isDefault}
/>
<InspectorTextField
label={t('engine.inspector.flowEdge.condition', locale)}
value={conditionText(edge.condition) ?? ''}
onCommit={(v) => patchEdge({ condition: v || undefined })}
placeholder={t('engine.inspector.flowEdge.conditionHint', locale)}
disabled={readOnly || isDefault}
mono
/>
<InspectorCheckboxField
label={t('engine.inspector.flowEdge.isDefault', locale)}
value={isDefault}
// The default ("else") branch is taken when no other guard matches, so
// it carries neither a condition nor a branch label — clear both.
onCommit={(v) => patchEdge(v ? { isDefault: true, condition: undefined, label: undefined } : { isDefault: false })}
disabled={readOnly}
/>
<p className="text-[11px] leading-snug text-muted-foreground">
{t('engine.inspector.flowEdge.hint', locale)}
</p>
</InspectorShell>
);
}
Original file line number Diff line number Diff line change
@@ -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 <FlowEdgeInspector {...props} />;
}
return <FlowNodeInspector {...props} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -81,6 +84,7 @@ export function FlowObjectListField({
addLabel,
removeLabel,
emptyLabel,
context,
}: FlowObjectListFieldProps) {
const external = React.useMemo(
() =>
Expand Down Expand Up @@ -169,6 +173,19 @@ export function FlowObjectListField({
}}
disabled={disabled}
/>
) : col.kind === 'reference' ? (
<div className="flex-1">
<ReferenceCombobox
resolved={resolveRefKind(col.ref, (k) => 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}
/>
</div>
) : (
<Input
value={typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : ''}
Expand Down
Loading
Loading