From 92c832d5c9afd63f309daabcfbbb0eb1b95db89f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 5 Jul 2026 02:38:52 +0800 Subject: [PATCH] feat(studio): pick a connector action from the chosen connector (no hand-typed action ids) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow Connector Action node's `actionId` was free text (typo → node fails at run time), left as text because actions have "no flat catalog". But each connector advertises its actions in the runtime descriptors (GET /automation/connectors → {name, actions:[{key,label}]}). Now `actionId` is a picker of the CHOSEN connector's actions, resolved from the sibling connectorId (mirrors object-field → its object's fields): new reference kind `connector-action` + `connectorSource`; `useConnectorActionOptions` fetches the descriptors; `resolveConnectorName` reads the node's connectorConfig. Stays an editable combobox — degrades to text + hint with no connector chosen. Unit-tested (resolveConnectorName, connectorActionsToOptions). Closes the last critical hand-typed-identifier gap in flow-node config. Co-Authored-By: Claude Opus 4.8 --- .changeset/studio-connector-action-picker.md | 24 ++++++ .../FlowReferenceField.connector.test.ts | 58 +++++++++++++ .../inspectors/FlowReferenceField.tsx | 84 ++++++++++++++++++- .../inspectors/flow-node-config.ts | 13 ++- 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 .changeset/studio-connector-action-picker.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.connector.test.ts diff --git a/.changeset/studio-connector-action-picker.md b/.changeset/studio-connector-action-picker.md new file mode 100644 index 000000000..d0571d9e5 --- /dev/null +++ b/.changeset/studio-connector-action-picker.md @@ -0,0 +1,24 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(studio): pick a connector action from the chosen connector (no more hand-typed action ids) + +In a flow's **Connector Action** node, the `actionId` field was a free-text box +(`sendMessage · send` placeholder) — a typo silently produced a node that fails +at run time. It was left as text because a connector's actions have "no flat +catalog"; but each connector already advertises its actions in the runtime +descriptors (`GET /api/v1/automation/connectors` → `{ name, actions:[{key,label}] }`). + +`actionId` is now a **picker of the chosen connector's actions**, resolved from +the sibling `connectorId` (mirroring how `object-field` lists the fields of its +resolved object). New reference kind `connector-action` + `connectorSource` on +`FlowReferenceSpec`; `useConnectorActionOptions` fetches the descriptors and +`resolveConnectorName` reads the connector from the node's `connectorConfig`. Like +every reference in the designer it stays an **editable combobox** — with no +connector chosen (or none installed) it degrades to free text with a hint +("Choose a Connector above to list its actions" / "Actions of ."). + +Closes the last critical hand-typed-identifier gap in flow-node config (the +object / field / flow / role / connector / template references were already +pickers). Unit-tested (`resolveConnectorName`, `connectorActionsToOptions`). diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.connector.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.connector.test.ts new file mode 100644 index 000000000..9508c2fd8 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.connector.test.ts @@ -0,0 +1,58 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Picker-first (#5): a connector_action node's `actionId` is a picker of the +// CHOSEN connector's actions, not free text. These cover the pure resolution + +// option-mapping the picker relies on (the fetch/render is thin glue on top). + +import { describe, it, expect } from 'vitest'; +import { resolveConnectorName, connectorActionsToOptions } from './FlowReferenceField'; + +describe('resolveConnectorName', () => { + const ctx = (connectorConfig: unknown) => ({ draft: {}, node: { connectorConfig } as Record }); + + it('reads the chosen connector from the node connectorConfig block', () => { + expect(resolveConnectorName('connector-action', 'connectorId', ctx({ connectorId: 'slack' }))).toBe('slack'); + }); + + it('honors a custom connectorSource key', () => { + expect(resolveConnectorName('connector-action', 'conn', ctx({ conn: 'salesforce' }))).toBe('salesforce'); + }); + + it('defaults the source key to connectorId', () => { + expect(resolveConnectorName('connector-action', undefined, ctx({ connectorId: 'email' }))).toBe('email'); + }); + + it('is undefined for a non-connector-action kind, or with no connector chosen', () => { + expect(resolveConnectorName('object', 'connectorId', ctx({ connectorId: 'slack' }))).toBeUndefined(); + expect(resolveConnectorName('connector-action', 'connectorId', ctx({}))).toBeUndefined(); + expect(resolveConnectorName('connector-action', 'connectorId', ctx(undefined))).toBeUndefined(); + }); +}); + +describe('connectorActionsToOptions', () => { + it('maps a connector descriptor action list to {value,label} options', () => { + expect( + connectorActionsToOptions([ + { key: 'chat.postMessage', label: 'Post Message' }, + { key: 'send' }, // no label → key is the label + ]), + ).toEqual([ + { value: 'chat.postMessage', label: 'Post Message (chat.postMessage)' }, + { value: 'send', label: 'send' }, + ]); + }); + + it('drops malformed entries and tolerates non-arrays', () => { + expect(connectorActionsToOptions([{ label: 'no key' }, null, { key: '' }, { key: 'ok' }])).toEqual([ + { value: 'ok', label: 'ok' }, + ]); + expect(connectorActionsToOptions(undefined)).toEqual([]); + expect(connectorActionsToOptions('nope')).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index fc9a8193c..b5d1522d2 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -67,6 +67,7 @@ const KIND_TO_META_TYPE: Partial> = { export interface ResolvedRef { kind: ReferenceKind; objectSource?: string; + connectorSource?: string; } /** @@ -80,11 +81,11 @@ export function resolveRefKind( sibling: (key: string) => unknown, ): ResolvedRef | undefined { if (!ref) return undefined; - if (ref.kind) return { kind: ref.kind, objectSource: ref.objectSource }; + if (ref.kind) return { kind: ref.kind, objectSource: ref.objectSource, connectorSource: ref.connectorSource }; 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 }; + if (k) return { kind: k, objectSource: ref.objectSource, connectorSource: ref.connectorSource }; } return undefined; } @@ -110,6 +111,30 @@ function resolveObjectName(kind: ReferenceKind, objectSource: string | undefined return configString(ctx.node, src); } +/** + * Resolve the chosen connector name for a `connector-action` reference — read + * from the sibling key on this node's `connectorConfig` block (default + * `connectorId`), which is where the connector picker writes it. + */ +export function resolveConnectorName(kind: ReferenceKind, connectorSource: string | undefined, ctx: FlowReferenceContext): string | undefined { + if (kind !== 'connector-action') return undefined; + const cc = ctx.node?.connectorConfig; + if (!cc || typeof cc !== 'object' || Array.isArray(cc)) return undefined; + const v = (cc as Record)[connectorSource || 'connectorId']; + return typeof v === 'string' && v ? v : undefined; +} + +/** A connector descriptor's action list → combobox options (exported for test). */ +export function connectorActionsToOptions(actions: unknown): Option[] { + if (!Array.isArray(actions)) return []; + return actions + .filter((a): a is { key: string; label?: string } => !!a && typeof (a as { key?: unknown }).key === 'string' && !!(a as { key: string }).key) + .map((a) => ({ + value: a.key, + label: typeof a.label === 'string' && a.label && a.label !== a.key ? `${a.label} (${a.key})` : a.key, + })); +} + /** * Fetch a metadata type's items as combobox options. `type === undefined` * disables the fetch (returns empty), so the hook can be called @@ -150,6 +175,44 @@ function useMetadataListOptions(type: string | undefined): { options: Option[]; return state; } +/** + * Fetch a connector's actions as combobox options from the runtime connector + * descriptors (`GET /api/v1/automation/connectors`, each `{ name, actions: + * [{key,label}] }`). `connectorName === undefined` disables the fetch (so the + * hook is safe to call unconditionally). Degrades to empty on any failure. + */ +function useConnectorActionOptions(connectorName: string | undefined): { options: Option[]; loading: boolean } { + const [state, setState] = React.useState<{ options: Option[]; loading: boolean }>({ + options: [], + loading: !!connectorName, + }); + React.useEffect(() => { + if (!connectorName) { + setState({ options: [], loading: false }); + return; + } + let cancelled = false; + setState((s) => ({ ...s, loading: true })); + fetch('/api/v1/automation/connectors', { credentials: 'include', headers: { Accept: 'application/json' } }) + .then((r) => (r.ok ? r.json() : null)) + .then((payload) => { + if (cancelled) return; + const connectors = payload?.data?.connectors ?? payload?.connectors ?? []; + const conn = Array.isArray(connectors) + ? connectors.find((c: { name?: unknown }) => c?.name === connectorName) + : undefined; + setState({ options: connectorActionsToOptions(conn?.actions), loading: false }); + }) + .catch(() => { + if (!cancelled) setState({ options: [], loading: false }); + }); + return () => { + cancelled = true; + }; + }, [connectorName]); + return state; +} + export interface ReferenceComboboxProps { /** The resolved concrete reference, or undefined → plain free text. */ resolved: ResolvedRef | undefined; @@ -178,8 +241,12 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled, const objectName = resolved ? resolveObjectName(resolved.kind, resolved.objectSource, ctx) : undefined; const { fields: objectFields } = useObjectFields(kind === 'object-field' ? objectName : undefined); + // connector-action: resolve the chosen connector, then its action catalog. + const connectorName = resolved ? resolveConnectorName(resolved.kind, resolved.connectorSource, ctx) : undefined; + const { options: connectorActionOptions } = useConnectorActionOptions(kind === 'connector-action' ? connectorName : 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 listType = kind && kind !== 'object-field' && kind !== 'node' && kind !== 'connector-action' ? KIND_TO_META_TYPE[kind] : undefined; const { options: listOptions } = useMetadataListOptions(listType); const options = React.useMemo(() => { @@ -189,6 +256,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled, label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name, })); } + if (kind === 'connector-action') return connectorActionOptions; 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; @@ -202,11 +270,13 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled, } if (listType) return listOptions; return []; - }, [kind, listType, objectFields, listOptions, ctx.draft, ctx.node]); + }, [kind, listType, objectFields, connectorActionOptions, 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; + // Same for a connector-action with no connector chosen yet. + const unresolvedConnector = kind === 'connector-action' && !connectorName; return (
@@ -236,6 +306,12 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled, Set the flow’s trigger object (on the Start node) to list fields.

)} + {showHint && kind === 'connector-action' && connectorName && ( +

Actions of {connectorName}.

+ )} + {showHint && unresolvedConnector && ( +

Choose a Connector above to list its actions.

+ )}
); } 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 d13dee45a..53aa8bb03 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 @@ -67,6 +67,7 @@ export type ReferenceKind = | 'queue' | 'department' | 'connector' + | 'connector-action' | 'email-template'; export interface FlowReferenceSpec { @@ -83,6 +84,13 @@ export interface FlowReferenceSpec { * the object name (e.g. CRUD nodes resolve from their own `objectName`). */ objectSource?: string; + /** + * For `connector-action` only: the sibling key (on this node's + * `connectorConfig` block) holding the chosen connector's name. Defaults to + * `'connectorId'`. The picker lists THAT connector's actions (from the runtime + * connector descriptors); with no connector chosen it degrades to free text. + */ + connectorSource?: 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 @@ -518,9 +526,10 @@ const FLOW_NODE_CONFIG: Record = { ], connector_action: [ at('connectorConfig', 'connectorId', 'Connector', 'reference', { ref: { kind: 'connector' }, placeholder: 'slack · email · salesforce' }), - // actionId is polymorphic on the chosen connector and has no flat catalog + // actionId is polymorphic on the chosen connector: the picker lists THAT + // connector's actions (runtime descriptors), degrading to free text if none. // (a deliberate open extension point) — stays free text. - at('connectorConfig', 'actionId', 'Action', 'text', { placeholder: 'sendMessage · send' }), + at('connectorConfig', 'actionId', 'Action', 'reference', { ref: { kind: 'connector-action', connectorSource: 'connectorId' }, 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' }, ],