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,110 @@
import { rewriteTriggerVariablesToPayload } from 'src/database/commands/upgrade-version-command/2-15/utils/rewrite-trigger-variables-to-payload.util';
describe('rewriteTriggerVariablesToPayload', () => {
it('rewrites a flat trigger field reference into the payload node', () => {
const { value, changed } = rewriteTriggerVariablesToPayload({
message: 'Hello {{trigger.name}}',
});
expect(changed).toBe(true);
expect(value).toEqual({ message: 'Hello {{trigger.payload.name}}' });
});
it('rewrites composite and nested field paths', () => {
const { value, changed } = rewriteTriggerVariablesToPayload({
to: '{{trigger.email.primaryEmail}}',
city: '{{trigger.address.addressCity}}',
});
expect(changed).toBe(true);
expect(value).toEqual({
to: '{{trigger.payload.email.primaryEmail}}',
city: '{{trigger.payload.address.addressCity}}',
});
});
it('rewrites bracket-escaped segments', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger.[My Custom Field]}}',
);
expect(changed).toBe(true);
expect(value).toBe('{{trigger.payload.[My Custom Field]}}');
});
it('rewrites every occurrence within a value', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger.firstName}} {{trigger.lastName}}',
);
expect(changed).toBe(true);
expect(value).toBe(
'{{trigger.payload.firstName}} {{trigger.payload.lastName}}',
);
});
it('migrates a field whose name merely starts with "payload"', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger.payloadStatus}}',
);
expect(changed).toBe(true);
expect(value).toBe('{{trigger.payload.payloadStatus}}');
});
it('is idempotent for already-migrated references', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger.payload.name}}',
);
expect(changed).toBe(false);
expect(value).toBe('{{trigger.payload.name}}');
});
it('leaves metadata references untouched', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger.metadata.workspaceMemberId}}',
);
expect(changed).toBe(false);
expect(value).toBe('{{trigger.metadata.workspaceMemberId}}');
});
it('leaves the legacy _metadata key untouched', () => {
const { value, changed } = rewriteTriggerVariablesToPayload(
'{{trigger._metadata.workspaceMemberId}}',
);
expect(changed).toBe(false);
expect(value).toBe('{{trigger._metadata.workspaceMemberId}}');
});
it('does not touch references to other steps', () => {
const { value, changed } = rewriteTriggerVariablesToPayload({
body: '{{a1b2c3.trigger.name}} {{step-2.email}}',
});
expect(changed).toBe(false);
expect(value).toEqual({
body: '{{a1b2c3.trigger.name}} {{step-2.email}}',
});
});
it('does not touch a bare full-trigger reference', () => {
const { value, changed } = rewriteTriggerVariablesToPayload('{{trigger}}');
expect(changed).toBe(false);
expect(value).toBe('{{trigger}}');
});
it('returns null/undefined values unchanged', () => {
expect(rewriteTriggerVariablesToPayload(null)).toEqual({
value: null,
changed: false,
});
expect(rewriteTriggerVariablesToPayload(undefined)).toEqual({
value: undefined,
changed: false,
});
});
});
@@ -0,0 +1,31 @@
import { isDefined } from 'twenty-shared/utils';
// Manual-trigger record fields moved from the flat trigger root
// ({{trigger.name}}) to a nested payload node ({{trigger.payload.name}}).
// The negative lookaheads keep this idempotent: references already pointing at
// `payload`/`metadata` (or the legacy `_metadata` key) are left untouched, so
// re-running is a no-op and metadata refs are never prefixed with `payload`.
// A field named exactly `payload` or `metadata` collides and is skipped — that
// risk was accepted when the nested keys were named.
const TRIGGER_VARIABLE_PREFIX_REGEX =
/\{\{trigger\.(?!payload[.}])(?!metadata[.}])(?!_metadata[.}])/g;
export const rewriteTriggerVariablesToPayload = <TValue>(
value: TValue,
): { value: TValue; changed: boolean } => {
if (!isDefined(value)) {
return { value, changed: false };
}
const serialized = JSON.stringify(value);
const rewritten = serialized.replace(
TRIGGER_VARIABLE_PREFIX_REGEX,
'{{trigger.payload.',
);
if (rewritten === serialized) {
return { value, changed: false };
}
return { value: JSON.parse(rewritten) as TValue, changed: true };
};