Compute output schema on frontend (#16530)
Fixes https://github.com/twentyhq/core-team-issues/issues/1382 Current issue : all step output schemas are computed and stored on backend side. Which means that, when the database schema is updated - like a field creation - steps needs to be deleted an recreated. Which is invisible to users. Solution : schema generation is moved on frontend side 1. Coming on the page the first time, the schema is populated for all steps except a few ones that are handled differently (Code, Webhook, http node, Agent) 2. A separated state allow to determine if a step needs a recomputation. 3. The user only needs a refresh to see the whole schema re-computed Follow-up: - check if remaining backend steps could be moved to runtime computation. But Code will still require storage. - Clean backend service that is not used anymore
This commit is contained in:
+37
-33
@@ -1,10 +1,11 @@
|
||||
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
|
||||
const mockUpdateOneRecord = jest.fn();
|
||||
const mockGetUpdatableWorkflowVersion = jest.fn();
|
||||
const mockComputeStepOutputSchema = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
|
||||
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
|
||||
useUpdateOneRecord: jest.fn(() => ({
|
||||
@@ -18,9 +19,9 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
|
||||
useComputeStepOutputSchema: jest.fn(() => ({
|
||||
computeStepOutputSchema: mockComputeStepOutputSchema,
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: jest.fn(() => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -39,11 +40,8 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('updates the trigger with computed output schema', async () => {
|
||||
it('updates the trigger and marks it for recomputation for frontend-computed types', async () => {
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
|
||||
mockComputeStepOutputSchema.mockResolvedValue({
|
||||
data: { computeStepOutputSchema: { field1: 'string' } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
|
||||
|
||||
@@ -52,33 +50,10 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
});
|
||||
|
||||
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
|
||||
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({
|
||||
step: trigger,
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId: 'version-id',
|
||||
});
|
||||
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());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTrigger(trigger, {
|
||||
computeOutputSchema: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockComputeStepOutputSchema).not.toHaveBeenCalled();
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
idToUpdate: 'version-id',
|
||||
updateOneRecordInput: {
|
||||
@@ -86,4 +61,33 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks for recomputation for all trigger types', async () => {
|
||||
const triggerTypes = ['DATABASE_EVENT', 'MANUAL', 'CRON', 'WEBHOOK'];
|
||||
|
||||
for (const triggerType of triggerTypes) {
|
||||
mockMarkStepForRecomputation.mockClear();
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
|
||||
|
||||
const testTrigger: WorkflowTrigger = {
|
||||
name: `${triggerType} Trigger`,
|
||||
type: triggerType as any,
|
||||
settings: {
|
||||
outputSchema: {},
|
||||
},
|
||||
nextStepIds: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTrigger(testTrigger);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId: 'version-id',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+10
-20
@@ -1,12 +1,14 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
type WorkflowVersion,
|
||||
} from '@/workflow/types/Workflow';
|
||||
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
|
||||
export const useUpdateWorkflowVersionTrigger = () => {
|
||||
const { updateOneRecord: updateOneWorkflowVersion } =
|
||||
useUpdateOneRecord<WorkflowVersion>({
|
||||
@@ -16,34 +18,22 @@ export const useUpdateWorkflowVersionTrigger = () => {
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
|
||||
const { computeStepOutputSchema } = useComputeStepOutputSchema();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const updateTrigger = async (
|
||||
updatedTrigger: WorkflowTrigger,
|
||||
options: { computeOutputSchema: boolean } = { computeOutputSchema: true },
|
||||
) => {
|
||||
const updateTrigger = async (updatedTrigger: WorkflowTrigger) => {
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
|
||||
if (options.computeOutputSchema) {
|
||||
const outputSchema = (
|
||||
await computeStepOutputSchema({
|
||||
step: updatedTrigger,
|
||||
workflowVersionId,
|
||||
})
|
||||
)?.data?.computeStepOutputSchema;
|
||||
|
||||
updatedTrigger.settings = {
|
||||
...updatedTrigger.settings,
|
||||
outputSchema: outputSchema || {},
|
||||
};
|
||||
}
|
||||
|
||||
await updateOneWorkflowVersion({
|
||||
idToUpdate: workflowVersionId,
|
||||
updateOneRecordInput: {
|
||||
trigger: updatedTrigger,
|
||||
},
|
||||
});
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
|
||||
import { getManualTriggerDefaultSettings } from '../getManualTriggerDefaultSettings';
|
||||
|
||||
const mockObjectMetadataItems: ObjectMetadataItem[] = [
|
||||
{
|
||||
id: 'company-id',
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
icon: 'IconBuilding',
|
||||
fields: [],
|
||||
createdAt: new Date(),
|
||||
} as unknown as ObjectMetadataItem,
|
||||
];
|
||||
|
||||
describe('getManualTriggerDefaultSettings', () => {
|
||||
describe('GLOBAL availability', () => {
|
||||
it('should return correct settings for GLOBAL type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: undefined,
|
||||
availability: {
|
||||
type: 'GLOBAL',
|
||||
locations: undefined,
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom icon when provided', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
icon: 'IconCustom',
|
||||
});
|
||||
|
||||
expect(result.icon).toBe('IconCustom');
|
||||
});
|
||||
|
||||
it('should use isPinned when provided', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
isPinned: true,
|
||||
});
|
||||
|
||||
expect(result.isPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SINGLE_RECORD availability', () => {
|
||||
it('should return correct settings for SINGLE_RECORD type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'SINGLE_RECORD',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: 'company',
|
||||
availability: {
|
||||
type: 'SINGLE_RECORD',
|
||||
objectNameSingular: 'company',
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the first object metadata item', () => {
|
||||
const multipleObjects: ObjectMetadataItem[] = [
|
||||
...mockObjectMetadataItems,
|
||||
{
|
||||
id: 'person-id',
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
labelSingular: 'Person',
|
||||
labelPlural: 'People',
|
||||
icon: 'IconUser',
|
||||
fields: [],
|
||||
} as unknown as ObjectMetadataItem,
|
||||
];
|
||||
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'SINGLE_RECORD',
|
||||
activeNonSystemObjectMetadataItems: multipleObjects,
|
||||
});
|
||||
|
||||
expect(result.objectType).toBe('company');
|
||||
expect(
|
||||
(result.availability as { objectNameSingular: string })
|
||||
.objectNameSingular,
|
||||
).toBe('company');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BULK_RECORDS availability', () => {
|
||||
it('should return correct settings for BULK_RECORDS type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'BULK_RECORDS',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: 'company',
|
||||
availability: {
|
||||
type: 'BULK_RECORDS',
|
||||
objectNameSingular: 'company',
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { type WorkflowAction } from '@/workflow/types/Workflow';
|
||||
import { getRootStepIds } from '../getRootStepIds';
|
||||
|
||||
describe('getRootStepIds', () => {
|
||||
it('should return empty array for empty steps', () => {
|
||||
const result = getRootStepIds([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single step id when only one step exists', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Step 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1']);
|
||||
});
|
||||
|
||||
it('should return root step ids (steps not referenced by other steps)', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Root Step',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-2'],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Child Step',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1']);
|
||||
});
|
||||
|
||||
it('should return multiple root steps when there are parallel branches', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Root 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-3'],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Root 2',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-3'],
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
name: 'Child',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1', 'step-2']);
|
||||
});
|
||||
|
||||
it('should handle steps with undefined nextStepIds', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Step 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: undefined,
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Step 2',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1', 'step-2']);
|
||||
});
|
||||
|
||||
it('should handle complex workflow with multiple levels', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-1-a', 'level-1-b'],
|
||||
},
|
||||
{
|
||||
id: 'level-1-a',
|
||||
name: 'Level 1 A',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-2'],
|
||||
},
|
||||
{
|
||||
id: 'level-1-b',
|
||||
name: 'Level 1 B',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-2'],
|
||||
},
|
||||
{
|
||||
id: 'level-2',
|
||||
name: 'Level 2',
|
||||
type: 'DELETE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['root']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user