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:
Thomas Trompette
2025-12-15 16:20:35 +01:00
committed by GitHub
parent 2e104c8e76
commit 95e0793f81
43 changed files with 3152 additions and 209 deletions
@@ -25,7 +25,7 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
}),
}));
jest.mock('@/workflow/hooks/useStepsOutputSchema', () => ({
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
useStepsOutputSchema: () => ({
deleteStepsOutputSchema: mockDeleteStepsOutputSchema,
}),
@@ -1,8 +1,10 @@
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { renderHook } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { type WorkflowAction } from '~/generated/graphql';
const mockUpdateWorkflowVersionStep = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockMarkStepForRecomputation = jest.fn();
jest.mock(
'@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep',
@@ -19,6 +21,12 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
}),
}));
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
useStepsOutputSchema: jest.fn(() => ({
markStepForRecomputation: mockMarkStepForRecomputation,
})),
}));
describe('useUpdateStep', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -52,7 +60,9 @@ describe('useUpdateStep', () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const { result } = renderHook(() => useUpdateStep());
await result.current.updateStep(mockStep);
await act(async () => {
await result.current.updateStep(mockStep);
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
expect(mockUpdateWorkflowVersionStep).toHaveBeenCalledWith({
@@ -60,4 +70,69 @@ describe('useUpdateStep', () => {
step: mockStep,
});
});
it('should mark step for recomputation after update', async () => {
const mockWorkflowVersionId = 'version-123';
const mockStep = {
id: 'step-1',
name: 'Create Record',
valid: true,
type: 'CREATE_RECORD' as const,
settings: {
input: {
objectName: 'company',
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const { result } = renderHook(() => useUpdateStep());
await act(async () => {
await result.current.updateStep(mockStep as WorkflowAction);
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: 'step-1',
workflowVersionId: mockWorkflowVersionId,
});
});
it('should mark step for recomputation for all step types', async () => {
const mockWorkflowVersionId = 'version-123';
const stepTypes = ['CODE', 'HTTP_REQUEST', 'CREATE_RECORD', 'SEND_EMAIL'];
for (const stepType of stepTypes) {
mockMarkStepForRecomputation.mockClear();
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const mockStep = {
id: `step-${stepType}`,
name: `${stepType} Step`,
valid: true,
type: stepType as any,
settings: {
input: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
const { result } = renderHook(() => useUpdateStep());
await act(async () => {
await result.current.updateStep(mockStep as WorkflowAction);
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: `step-${stepType}`,
workflowVersionId: mockWorkflowVersionId,
});
}
});
});
@@ -1,11 +1,11 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep';
import { useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { isDefined } from 'twenty-shared/utils';
export const useDeleteStep = () => {
@@ -1,11 +1,13 @@
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { type WorkflowAction } from '@/workflow/types/Workflow';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
export const useUpdateStep = () => {
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const { markStepForRecomputation } = useStepsOutputSchema();
const updateStep = async (updatedStep: WorkflowAction) => {
const workflowVersionId = await getUpdatableWorkflowVersion();
@@ -15,6 +17,11 @@ export const useUpdateStep = () => {
step: updatedStep,
});
markStepForRecomputation({
stepId: updatedStep.id,
workflowVersionId,
});
return {
updatedStep: result?.data?.updateWorkflowVersionStep,
};
@@ -218,7 +218,7 @@ export const WithObjectStringBody: Story = {
body: `{
"hey": "frontend",
"oh": "backend",
"amazing": "database {{${MOCKED_STEP_ID}.salary}}"
"amazing": "database {{${MOCKED_STEP_ID}.name}}"
}`,
},
outputSchema: {},
@@ -253,7 +253,7 @@ export const WithObjectStringBody: Story = {
expect(textboxes[6]).toHaveTextContent('frontend');
expect(textboxes[8]).toHaveTextContent('backend');
expect(textboxes[10]).toHaveTextContent('database Salary');
expect(textboxes[10]).toHaveTextContent('database Name');
},
};
@@ -273,7 +273,7 @@ export const WithArrayContainingNonStringVariablesBody: Story = {
},
body: `[
"frontend",
{{${MOCKED_STEP_ID}.salary}},
{{${MOCKED_STEP_ID}.name}},
"database"
]`,
},
@@ -300,9 +300,7 @@ export const WithArrayContainingNonStringVariablesBody: Story = {
await waitFor(() => {
const textboxes = canvas.getAllByRole('textbox');
expect(textboxes[5]).toHaveTextContent(
'[ "frontend", Salary, "database"]',
);
expect(textboxes[5]).toHaveTextContent('[ "frontend", Name, "database"]');
});
},
};
@@ -323,7 +321,7 @@ export const WithObjectContainingNonStringVariablesBody: Story = {
},
body: `{
"speciality": "frontend",
"salary": {{${MOCKED_STEP_ID}.salary}}
"name": {{${MOCKED_STEP_ID}.name}}
}`,
},
outputSchema: {},
@@ -350,7 +348,7 @@ export const WithObjectContainingNonStringVariablesBody: Story = {
const textboxes = canvas.getAllByRole('textbox');
expect(textboxes[5]).toHaveTextContent(
'{ "speciality": "frontend", "salary": Salary}',
'{ "speciality": "frontend", "name": Name}',
);
});
},