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
@@ -1,6 +0,0 @@
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type ManualTriggerOutputSchema =
| BaseOutputSchemaV2
| RecordOutputSchemaV2;
@@ -6,9 +6,11 @@ import { type CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeO
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema';
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
import {
type BaseOutputSchemaV2,
type ManualTriggerOutputSchema,
} from 'twenty-shared/workflow';
export type OutputSchemaV2 =
| BaseOutputSchemaV2
@@ -2,8 +2,8 @@ import {
type WorkflowActionType,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { type ManualTriggerOutputSchema } from 'twenty-shared/workflow';
export const isManualTriggerOutputSchema = (
stepType: WorkflowActionType | WorkflowTriggerType,
@@ -159,7 +159,7 @@ describe('computeStepOutputSchema', () => {
expect(result).toEqual({});
});
it('should return empty object for GLOBAL availability', () => {
it('should expose only metadata for GLOBAL availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
@@ -168,10 +168,22 @@ describe('computeStepOutputSchema', () => {
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
expect(result).not.toHaveProperty('payload');
expect((result as any).metadata).toMatchObject({
isLeaf: false,
type: 'object',
label: 'Metadata',
value: {
workspaceMemberId: {
isLeaf: true,
type: 'string',
label: 'Workspace Member Id',
},
},
});
});
it('should return record output schema for SINGLE_RECORD availability', () => {
it('should nest the record under payload and expose metadata for SINGLE_RECORD availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
@@ -185,11 +197,19 @@ describe('computeStepOutputSchema', () => {
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
expect((result as any).payload).toMatchObject({
isLeaf: false,
label: 'Payload',
});
expect((result as any).payload.value).toHaveProperty(
'_outputSchemaType',
'RECORD',
);
expect((result as any).payload.value).toHaveProperty('object');
expect(result).toHaveProperty('metadata');
});
it('should return array indicator for BULK_RECORDS availability', () => {
it('should nest the array indicator under payload and expose metadata for BULK_RECORDS availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
@@ -203,12 +223,13 @@ describe('computeStepOutputSchema', () => {
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('companies');
expect((result as any).companies).toMatchObject({
expect((result as any).payload.value).toHaveProperty('companies');
expect((result as any).payload.value.companies).toMatchObject({
isLeaf: true,
label: 'Companies',
type: 'array',
});
expect(result).toHaveProperty('metadata');
});
it('should return empty object when object metadata is not found for SINGLE_RECORD', () => {
@@ -11,6 +11,12 @@ import { generateRecordEventOutputSchema } from '@/workflow/workflow-variables/u
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
buildManualTriggerMetadataNode,
WORKFLOW_TRIGGER_METADATA_KEY,
WORKFLOW_TRIGGER_PAYLOAD_KEY,
WORKFLOW_TRIGGER_PAYLOAD_LABEL,
} from 'twenty-shared/workflow';
import { DatabaseEventAction } from '~/generated-metadata/graphql';
const PERSISTED_OUTPUT_SCHEMA_TYPES = [
@@ -101,7 +107,9 @@ export const computeStepOutputSchema = ({
}
if (availability.type === 'GLOBAL') {
return {};
return {
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}
if (
@@ -118,17 +126,33 @@ export const computeStepOutputSchema = ({
}
if (availability.type === 'SINGLE_RECORD') {
return generateRecordOutputSchema(objectMetadataItem);
return {
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: {
isLeaf: false,
icon: objectMetadataItem.icon ?? undefined,
label: WORKFLOW_TRIGGER_PAYLOAD_LABEL,
value: generateRecordOutputSchema(objectMetadataItem),
},
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}
// BULK_RECORDS - return array indicator
// BULK_RECORDS - array indicator nested under payload
return {
[objectMetadataItem.namePlural]: {
isLeaf: true,
label: objectMetadataItem.labelPlural,
type: 'array',
value: `Array of ${objectMetadataItem.labelPlural}`,
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: {
isLeaf: false,
type: 'object',
label: WORKFLOW_TRIGGER_PAYLOAD_LABEL,
value: {
[objectMetadataItem.namePlural]: {
isLeaf: true,
label: objectMetadataItem.labelPlural,
type: 'array',
value: `Array of ${objectMetadataItem.labelPlural}`,
},
},
},
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}