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(),
};
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { MigrateManualTriggerVariablesToPayloadCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-workspace-command-1800000001000-migrate-manual-trigger-variables-to-payload.command';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [WorkspaceIteratorModule, WorkspaceCacheModule],
providers: [MigrateManualTriggerVariablesToPayloadCommand],
})
export class V2_15_UpgradeVersionCommandModule {}
@@ -0,0 +1,114 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { rewriteTriggerVariablesToPayload } from 'src/database/commands/upgrade-version-command/2-15/utils/rewrite-trigger-variables-to-payload.util';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@RegisteredWorkspaceCommand('2.15.0', 1800000001000)
@Command({
name: 'upgrade:2-15:migrate-manual-trigger-variables-to-payload',
description:
'Rewrite saved {{trigger.<field>}} references to {{trigger.payload.<field>}} for manual record triggers',
})
export class MigrateManualTriggerVariablesToPayloadCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
// Empty/partially-provisioned workspaces have no workflowVersion object;
// fetching the repository for a missing entity throws, so skip them.
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const workflowVersionObject =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.workflowVersion.universalIdentifier,
});
if (!isDefined(workflowVersionObject)) {
this.logger.log(
`workflowVersion object not found for workspace ${workspaceId}, skipping`,
);
return;
}
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const allVersions = await workflowVersionRepository.find();
let updatedVersionCount = 0;
for (const version of allVersions) {
if (!this.isManualRecordTrigger(version.trigger)) {
continue;
}
// Trigger references only appear in downstream steps, never in the
// trigger object itself, so only the steps need rewriting.
const migratedSteps = rewriteTriggerVariablesToPayload(version.steps);
if (!migratedSteps.changed) {
continue;
}
updatedVersionCount++;
if (isDryRun) {
continue;
}
await workflowVersionRepository.update(version.id, {
steps: migratedSteps.value,
});
}
if (updatedVersionCount > 0) {
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Migrated trigger variables in ${updatedVersionCount} workflow version(s) for workspace ${workspaceId}`,
);
}
}
private isManualRecordTrigger(
trigger: WorkflowVersionWorkspaceEntity['trigger'],
): boolean {
if (!isDefined(trigger) || trigger.type !== WorkflowTriggerType.MANUAL) {
return false;
}
const availabilityType = trigger.settings?.availability?.type;
return (
availabilityType === 'SINGLE_RECORD' || availabilityType === 'BULK_RECORDS'
);
}
}
@@ -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 };
};
@@ -15,6 +15,7 @@ import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-13/2-13-upgrade-version-command.module';
import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-14/2-14-upgrade-version-command.module';
import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-15/2-15-upgrade-version-command.module';
@Module({
imports: [
@@ -33,6 +34,7 @@ import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_10_UpgradeVersionCommandModule,
V2_13_UpgradeVersionCommandModule,
V2_14_UpgradeVersionCommandModule,
V2_15_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -4,12 +4,16 @@ import { isString } from '@sniptt/guards';
import { isDefined, isValidVariable } from 'twenty-shared/utils';
import {
BaseOutputSchemaV2,
buildManualTriggerMetadataNode,
BulkRecordsAvailability,
extractRawVariableNamePart,
GlobalAvailability,
navigateOutputSchemaProperty,
SingleRecordAvailability,
TRIGGER_STEP_ID,
WORKFLOW_TRIGGER_METADATA_KEY,
WORKFLOW_TRIGGER_PAYLOAD_KEY,
WORKFLOW_TRIGGER_PAYLOAD_LABEL,
WorkflowActionType,
} from 'twenty-shared/workflow';
@@ -291,14 +295,28 @@ export class WorkflowSchemaWorkspaceService {
workspaceId: string;
}): Promise<OutputSchema> {
if (availability.type === 'GLOBAL') {
return {};
return {
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}
if (availability.type === 'SINGLE_RECORD') {
return this.computeRecordOutputSchema({
const recordOutputSchema = await this.computeRecordOutputSchema({
objectType: availability.objectNameSingular,
workspaceId,
});
const payload: Node = {
isLeaf: false,
type: 'object',
label: WORKFLOW_TRIGGER_PAYLOAD_LABEL,
value: recordOutputSchema,
};
return {
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: payload,
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}
if (availability.type === 'BULK_RECORDS') {
@@ -308,15 +326,25 @@ export class WorkflowSchemaWorkspaceService {
workspaceId,
);
return {
[objectMetadataInfo.flatObjectMetadata.namePlural]: {
label: objectMetadataInfo.flatObjectMetadata.labelPlural,
isLeaf: true,
type: 'array',
value:
'Array of ' + objectMetadataInfo.flatObjectMetadata.labelPlural,
const payload: Node = {
isLeaf: false,
type: 'object',
label: WORKFLOW_TRIGGER_PAYLOAD_LABEL,
value: {
[objectMetadataInfo.flatObjectMetadata.namePlural]: {
label: objectMetadataInfo.flatObjectMetadata.labelPlural,
isLeaf: true,
type: 'array',
value:
'Array of ' + objectMetadataInfo.flatObjectMetadata.labelPlural,
},
},
};
return {
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: payload,
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}
return {};
@@ -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,
});