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:
-6
@@ -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;
|
||||
+4
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+29
-8
@@ -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', () => {
|
||||
|
||||
+32
-8
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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 {}
|
||||
+114
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 };
|
||||
};
|
||||
+2
@@ -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 {}
|
||||
|
||||
+37
-9
@@ -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';
|
||||
+2
@@ -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
|
||||
|
||||
+173
@@ -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 });
|
||||
});
|
||||
});
|
||||
+18
@@ -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: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
+55
-13
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user