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
24 changes: 24 additions & 0 deletions .changeset/studio-connector-action-picker.md
Original file line number Diff line number Diff line change
@@ -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 <connector>.").

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`).
Original file line number Diff line number Diff line change
@@ -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<string, unknown> });

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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
export interface ResolvedRef {
kind: ReferenceKind;
objectSource?: string;
connectorSource?: string;
}

/**
Expand All @@ -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;
}
Expand All @@ -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<string, unknown>)[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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Option[]>(() => {
Expand All @@ -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<Record<string, unknown>>) : [];
const currentId = typeof ctx.node?.id === 'string' ? ctx.node.id : undefined;
Expand All @@ -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 (
<div className="w-full space-y-1">
Expand Down Expand Up @@ -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.
</p>
)}
{showHint && kind === 'connector-action' && connectorName && (
<p className="text-[11px] leading-snug text-muted-foreground">Actions of {connectorName}.</p>
)}
{showHint && unresolvedConnector && (
<p className="text-[11px] leading-snug text-muted-foreground">Choose a Connector above to list its actions.</p>
)}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type ReferenceKind =
| 'queue'
| 'department'
| 'connector'
| 'connector-action'
| 'email-template';

export interface FlowReferenceSpec {
Expand All @@ -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
Expand Down Expand Up @@ -518,9 +526,10 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
],
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' },
],
Expand Down
Loading