Implement branch front end (#13489)

First PR to support workflow branches on frontend
This commit is contained in:
martmull
2025-08-04 16:11:46 +02:00
committed by GitHub
parent 2c87c6fc1b
commit 9a05673093
124 changed files with 3735 additions and 938 deletions
@@ -9,7 +9,7 @@ import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/const
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
@@ -17,7 +17,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
@@ -10,7 +10,7 @@ import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/workflow-trigger
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -14,7 +14,7 @@ import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trig
import { WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
@@ -0,0 +1,98 @@
import { act, renderHook } from '@testing-library/react';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import {
WorkflowTrigger,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
const mockUpdateOneRecord = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockComputeStepOutputSchema = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: jest.fn(() => ({
updateOneRecord: mockUpdateOneRecord,
})),
}));
jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersion', () => ({
useGetUpdatableWorkflowVersion: jest.fn(() => ({
getUpdatableWorkflowVersion: mockGetUpdatableWorkflowVersion,
})),
}));
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
useComputeStepOutputSchema: jest.fn(() => ({
computeStepOutputSchema: mockComputeStepOutputSchema,
})),
}));
describe('useUpdateWorkflowVersionTrigger', () => {
const mockWorkflow = {
id: 'workflow-id',
currentVersion: { id: 'version-id' },
} as WorkflowWithCurrentVersion;
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
beforeEach(() => {
jest.clearAllMocks();
});
it('updates the trigger with computed output schema', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
mockComputeStepOutputSchema.mockResolvedValue({
data: { computeStepOutputSchema: { field1: 'string' } },
});
const { result } = renderHook(() =>
useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }),
);
await act(async () => {
await result.current.updateTrigger(trigger);
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalledWith(mockWorkflow);
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({ step: trigger });
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
trigger: {
...trigger,
settings: { ...trigger.settings, outputSchema: { field1: 'string' } },
},
},
});
});
it('skips output schema computation when disabled', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
const { result } = renderHook(() =>
useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }),
);
await act(async () => {
await result.current.updateTrigger(trigger, {
computeOutputSchema: false,
});
});
expect(mockComputeStepOutputSchema).not.toHaveBeenCalled();
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
trigger,
},
});
});
});
@@ -28,6 +28,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -45,6 +50,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.updated`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -62,6 +72,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.deleted`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -79,6 +94,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -97,6 +117,11 @@ describe('getTriggerDefaultDefinition', () => {
outputSchema: {},
icon: COMMAND_MENU_DEFAULT_ICON,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -115,6 +140,11 @@ describe('getTriggerDefaultDefinition', () => {
schedule: { day: 1, hour: 0, minute: 0 },
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -133,6 +163,11 @@ describe('getTriggerDefaultDefinition', () => {
httpMethod: 'GET',
authentication: null,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -1,6 +1,6 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { DatabaseTriggerDefaultLabel } from '@/workflow/workflow-trigger/constants/DatabaseTriggerDefaultLabel';
import { getTriggerDefaultLabel } from '../getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
describe('getTriggerDefaultLabel', () => {
describe('DATABASE_EVENT triggers', () => {
@@ -0,0 +1,39 @@
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
describe('getTriggerIcon', () => {
it('should return proper Icon for DATABASE_EVENT', () => {
expect(
getTriggerIcon({
type: 'DATABASE_EVENT',
settings: { eventName: 'company.created', outputSchema: {} },
}),
).toBe('IconPlaylistAdd');
});
it('should return proper Icon for MANUAL', () => {
expect(
getTriggerIcon({
type: 'MANUAL',
settings: { outputSchema: {} },
}),
).toBe('IconHandMove');
});
it('should return proper Icon for CRON', () => {
expect(
getTriggerIcon({
type: 'CRON',
settings: { outputSchema: {}, type: 'CUSTOM', pattern: '' },
}),
).toBe('IconClock');
});
it('should return proper Icon for CRON', () => {
expect(
getTriggerIcon({
type: 'WEBHOOK',
settings: { outputSchema: {}, httpMethod: 'GET', authentication: null },
}),
).toBe('IconWebhook');
});
});
@@ -0,0 +1,11 @@
import { WorkflowAction } from '@/workflow/types/Workflow';
export const getRootStepIds = (steps: WorkflowAction[]): string[] => {
const childIds = new Set<string>();
for (const step of steps) {
step.nextStepIds?.forEach((id) => childIds.add(id));
}
return steps.filter((step) => !childIds.has(step.id)).map((step) => step.id);
};
@@ -1,20 +1,26 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
WorkflowAction,
WorkflowTrigger,
WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { isDefined } from 'twenty-shared/utils';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
// TODO: This needs to be migrated to the server
export const getTriggerDefaultDefinition = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
steps,
}: {
defaultLabel: string;
type: WorkflowTriggerType;
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
steps?: WorkflowAction[] | null;
}): WorkflowTrigger => {
if (activeNonSystemObjectMetadataItems.length === 0) {
throw new Error(
@@ -22,11 +28,19 @@ export const getTriggerDefaultDefinition = ({
);
}
const nextStepIds = isDefined(steps) ? getRootStepIds(steps) : [];
const baseTriggerDefinition = {
name: defaultLabel,
position: { x: 0, y: 0 },
nextStepIds,
};
switch (type) {
case 'DATABASE_EVENT': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
eventName: `${activeNonSystemObjectMetadataItems[0].nameSingular}.${
DATABASE_TRIGGER_TYPES.find(
@@ -39,8 +53,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'MANUAL': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: getManualTriggerDefaultSettings({
availability: 'WHEN_RECORD_SELECTED',
activeNonSystemObjectMetadataItems,
@@ -49,8 +63,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'CRON': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
type: 'DAYS',
schedule: { day: 1, hour: 0, minute: 0 },
@@ -60,8 +74,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'WEBHOOK': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
outputSchema: {},
httpMethod: 'GET',
@@ -1,5 +1,5 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { assertUnreachable } from 'twenty-shared/utils';
export const getTriggerHeaderType = (trigger: WorkflowTrigger) => {