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:
Thomas Trompette
2026-06-17 17:54:35 +02:00
committed by GitHub
parent 607d9ee6e5
commit 105f9565a5
19 changed files with 629 additions and 50 deletions
@@ -0,0 +1 @@
export const WORKFLOW_TRIGGER_METADATA_LABEL = 'Metadata';
@@ -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
@@ -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 });
});
});
@@ -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: '',
},
},
});
@@ -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,
});