feat(workflow): surface manual-trigger payload + metadata in variable picker (#21692)
## Summary Step 2 of the manual-trigger output schema restructuring (expand → display → migrate → contract). Builds on the now-merged #21676 (which expanded the runtime payload to serve `payload` and `metadata` siblings at the trigger root). This PR **surfaces** those in the variable picker as nested, expandable nodes: - `trigger.payload.{record fields}` — the record(s) that triggered the run - `trigger.metadata.workspaceMemberId` — who triggered it The flat root fields (`trigger.id`, etc.) remain available, so existing saved variable references keep working until a later migration phase moves them. ### Changes - **twenty-shared**: metadata/payload label constants + `build-manual-trigger-metadata-node` util + barrel exports. - **twenty-front**: `computeStepOutputSchema` MANUAL branch now nests `payload` (RecordNode for SINGLE_RECORD, array Node for BULK_RECORDS, omitted for GLOBAL) and `metadata`; `ManualTriggerOutputSchema` type updated to `{ payload?; metadata }`. - **twenty-server**: `computeTriggerOutputSchemaFromAvailability` mirrors the same nested shape for server-side validation. The key is `metadata` (not `_metadata`) — custom fields can't start with `_`, so collision risk was deemed acceptable. ## Test plan - [x] `npx nx build twenty-shared` - [x] `computeStepOutputSchema` unit tests pass (55) - [x] Manual: create a manual-trigger workflow (GLOBAL / single-record / bulk), confirm the picker shows `payload` and `metadata` as expandable folders and that selecting a field yields `{{trigger.payload.<field>}}` / `{{trigger.metadata.workspaceMemberId}}` > Note: server typecheck has pre-existing unrelated failures on main (Stripe billing mocks, gmail mocks); none touch workflow files. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21692?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const WORKFLOW_TRIGGER_METADATA_LABEL = 'Metadata';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL =
|
||||
'Workspace Member Id';
|
||||
@@ -0,0 +1 @@
|
||||
export const WORKFLOW_TRIGGER_PAYLOAD_LABEL = 'Payload';
|
||||
@@ -13,8 +13,11 @@ export { IF_ELSE_BRANCH_POSITION_OFFSETS } from './constants/IfElseBranchPositio
|
||||
export { OBJECTS_BLOCKED_FROM_AUTOMATION } from './constants/ObjectsBlockedFromAutomation';
|
||||
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
|
||||
export { WORKFLOW_TRIGGER_METADATA_KEY } from './constants/WorkflowTriggerMetadataKey';
|
||||
export { WORKFLOW_TRIGGER_METADATA_LABEL } from './constants/WorkflowTriggerMetadataLabel';
|
||||
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdKey';
|
||||
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel';
|
||||
export { WORKFLOW_TRIGGER_PAYLOAD_KEY } from './constants/WorkflowTriggerPayloadKey';
|
||||
export { WORKFLOW_TRIGGER_PAYLOAD_LABEL } from './constants/WorkflowTriggerPayloadLabel';
|
||||
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
|
||||
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
|
||||
export { baseTriggerSchema } from './schemas/base-trigger-schema';
|
||||
@@ -164,6 +167,7 @@ export type {
|
||||
OutputSchemaV2,
|
||||
VariableSearchResult,
|
||||
} from './workflow-schema/types/output-schema.type';
|
||||
export { buildManualTriggerMetadataNode } from './workflow-schema/utils/build-manual-trigger-metadata-node';
|
||||
export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths';
|
||||
export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
|
||||
@@ -84,9 +84,10 @@ export type LinkOutputSchema = {
|
||||
|
||||
export type CodeOutputSchema = LinkOutputSchema | BaseOutputSchemaV2;
|
||||
|
||||
export type ManualTriggerOutputSchema =
|
||||
| BaseOutputSchemaV2
|
||||
| RecordOutputSchemaV2;
|
||||
export type ManualTriggerOutputSchema = {
|
||||
payload?: RecordNode | Node;
|
||||
metadata: Node;
|
||||
};
|
||||
|
||||
export type OutputSchemaV2 =
|
||||
| BaseOutputSchemaV2
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import { type RecordOutputSchemaV2 } from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchManualTrigger = ({
|
||||
schema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
schema: unknown;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema,
|
||||
stepType: 'MANUAL',
|
||||
stepName: 'Trigger',
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
const companyRecordSchema: RecordOutputSchemaV2 = {
|
||||
object: {
|
||||
objectMetadataId: 'company-metadata-id',
|
||||
label: 'Company',
|
||||
},
|
||||
fields: {
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.UUID,
|
||||
label: 'Id',
|
||||
value: 'id-value',
|
||||
fieldMetadataId: 'company-id-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Name',
|
||||
value: 'Acme',
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
};
|
||||
|
||||
const metadataNode = {
|
||||
isLeaf: false as const,
|
||||
label: 'Metadata',
|
||||
type: 'object' as const,
|
||||
value: {
|
||||
workspaceMemberId: {
|
||||
isLeaf: true as const,
|
||||
type: 'string' as const,
|
||||
label: 'Workspace Member',
|
||||
value: 'member-id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('searchVariableInOutputSchema - manual trigger output schema', () => {
|
||||
const singleRecordSchema = {
|
||||
payload: {
|
||||
isLeaf: false as const,
|
||||
label: 'Record',
|
||||
value: companyRecordSchema,
|
||||
},
|
||||
metadata: metadataNode,
|
||||
};
|
||||
|
||||
const bulkRecordsSchema = {
|
||||
payload: {
|
||||
isLeaf: false as const,
|
||||
type: 'object' as const,
|
||||
label: 'Record',
|
||||
value: {
|
||||
companies: {
|
||||
isLeaf: true as const,
|
||||
type: 'array' as const,
|
||||
label: 'Companies',
|
||||
value: 'Array of Companies',
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: metadataNode,
|
||||
};
|
||||
|
||||
const globalSchema = { metadata: metadataNode };
|
||||
|
||||
it('resolves a single-record payload field', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: singleRecordSchema,
|
||||
rawVariableName: '{{trigger.payload.name}}',
|
||||
}),
|
||||
).toEqual({
|
||||
variableLabel: 'Name',
|
||||
variablePathLabel: 'Trigger > Record > Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the full record via payload id when isFullRecord', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: singleRecordSchema,
|
||||
rawVariableName: '{{trigger.payload.id}}',
|
||||
isFullRecord: true,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
variableLabel: 'Company',
|
||||
variablePathLabel: 'Trigger > Record > Company',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a bulk-records payload array', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: bulkRecordsSchema,
|
||||
rawVariableName: '{{trigger.payload.companies}}',
|
||||
}),
|
||||
).toEqual({
|
||||
variableLabel: 'Companies',
|
||||
variablePathLabel: 'Trigger > Record > Companies',
|
||||
variableType: 'array',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a metadata field for any availability', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: globalSchema,
|
||||
rawVariableName: '{{trigger.metadata.workspaceMemberId}}',
|
||||
}),
|
||||
).toEqual({
|
||||
variableLabel: 'Workspace Member',
|
||||
variablePathLabel: 'Trigger > Metadata > Workspace Member',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Not Found for an unknown payload field', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: singleRecordSchema,
|
||||
rawVariableName: '{{trigger.payload.unknownField}}',
|
||||
}),
|
||||
).toEqual({ variableLabel: undefined, variablePathLabel: undefined });
|
||||
});
|
||||
|
||||
it('returns Not Found for an unknown top-level node', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: singleRecordSchema,
|
||||
rawVariableName: '{{trigger.notANode.x}}',
|
||||
}),
|
||||
).toEqual({ variableLabel: undefined, variablePathLabel: undefined });
|
||||
});
|
||||
|
||||
it('returns Not Found for a bare node reference without a field', () => {
|
||||
expect(
|
||||
searchManualTrigger({
|
||||
schema: singleRecordSchema,
|
||||
rawVariableName: '{{trigger.payload}}',
|
||||
}),
|
||||
).toEqual({ variableLabel: undefined, variablePathLabel: undefined });
|
||||
});
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { WORKFLOW_TRIGGER_METADATA_LABEL } from '@/workflow/constants/WorkflowTriggerMetadataLabel';
|
||||
import { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from '@/workflow/constants/WorkflowTriggerMetadataWorkspaceMemberIdKey';
|
||||
import { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from '@/workflow/constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel';
|
||||
import { type Node } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
export const buildManualTriggerMetadataNode = (): Node => ({
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: WORKFLOW_TRIGGER_METADATA_LABEL,
|
||||
value: {
|
||||
[WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY]: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
+55
-13
@@ -10,6 +10,7 @@ import {
|
||||
type FindRecordsOutputSchema,
|
||||
type FormOutputSchema,
|
||||
type IteratorOutputSchema,
|
||||
type ManualTriggerOutputSchema,
|
||||
type RecordFieldLeaf,
|
||||
type RecordFieldNodeValue,
|
||||
type RecordOutputSchemaV2,
|
||||
@@ -586,24 +587,65 @@ const searchThroughManualTriggerOutputSchema = ({
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
manualTriggerOutputSchema: unknown;
|
||||
manualTriggerOutputSchema: ManualTriggerOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) {
|
||||
return searchThroughRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
if (!isDefined(manualTriggerOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const nodeKey = parts[1];
|
||||
const remainingParts = parts.slice(2);
|
||||
const fieldName = remainingParts[remainingParts.length - 1];
|
||||
const pathSegments = remainingParts.slice(0, -1);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(nodeKey) || !isDefined(fieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
if (nodeKey === 'payload') {
|
||||
const { payload } = manualTriggerOutputSchema;
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const payloadStepName = `${stepName} > ${payload.label}`;
|
||||
|
||||
// Single-record triggers nest a record schema, bulk triggers a plain map.
|
||||
if (isRecordOutputSchemaV2(payload.value)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: payloadStepName,
|
||||
recordOutputSchema: payload.value,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
stepName: payloadStepName,
|
||||
baseOutputSchema: payload.value,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
return searchThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: manualTriggerOutputSchema as BaseOutputSchemaV2,
|
||||
rawVariableName,
|
||||
});
|
||||
if (nodeKey === 'metadata') {
|
||||
const { metadata } = manualTriggerOutputSchema;
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
stepName: `${stepName} > ${metadata.label}`,
|
||||
baseOutputSchema: metadata.value,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
return EMPTY_RESULT;
|
||||
};
|
||||
|
||||
// Main dispatcher
|
||||
@@ -635,7 +677,7 @@ export const searchVariableInOutputSchema = ({
|
||||
if (stepType === 'MANUAL') {
|
||||
return searchThroughManualTriggerOutputSchema({
|
||||
stepName,
|
||||
manualTriggerOutputSchema: schema,
|
||||
manualTriggerOutputSchema: schema as ManualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user