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:
+110
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user