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
@@ -2,7 +2,6 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormAddressFieldInput } from '../FormAddressFieldInput';
const meta: Meta<typeof FormAddressFieldInput> = {
@@ -44,12 +43,12 @@ export const WithVariables: Story = {
args: {
label: 'Address',
defaultValue: {
addressStreet1: `{{${MOCKED_STEP_ID}.address.street1}}`,
addressStreet2: `{{${MOCKED_STEP_ID}.address.street2}}`,
addressCity: `{{${MOCKED_STEP_ID}.address.city}}`,
addressState: `{{${MOCKED_STEP_ID}.address.state}}`,
addressCountry: `{{${MOCKED_STEP_ID}.address.country}}`,
addressPostcode: `{{${MOCKED_STEP_ID}.address.postcode}}`,
addressStreet1: `{{trigger.properties.after.address.addressStreet1}}`,
addressStreet2: `{{trigger.properties.after.address.addressStreet2}}`,
addressCity: `{{trigger.properties.after.address.addressCity}}`,
addressState: `{{trigger.properties.after.address.addressState}}`,
addressCountry: `{{trigger.properties.after.address.addressCountry}}`,
addressPostcode: `{{trigger.properties.after.address.addressPostcode}}`,
addressLat: 39.781721,
addressLng: -89.650148,
},
@@ -58,11 +57,11 @@ export const WithVariables: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const street1Variable = await canvas.findByText('Street 1');
const street2Variable = await canvas.findByText('Street 2');
const cityVariable = await canvas.findByText('My City');
const stateVariable = await canvas.findByText('My State');
const postcodeVariable = await canvas.findByText('My Postcode');
const street1Variable = await canvas.findByText('Address Street1');
const street2Variable = await canvas.findByText('Address Street2');
const cityVariable = await canvas.findByText('Address City');
const stateVariable = await canvas.findByText('Address State');
const postcodeVariable = await canvas.findByText('Address Postcode');
expect(street1Variable).toBeVisible();
expect(street2Variable).toBeVisible();
@@ -47,11 +47,11 @@ export const WithVariable: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const amountMicros = await canvas.findByText('My Amount Micros');
const currencyCode = await canvas.findByText('My Currency Code');
const amountMicros = await canvas.findByText('Amount Micros');
const currencyCode = await canvas.findAllByText('Currency Code');
expect(amountMicros).toBeVisible();
expect(currencyCode).toBeVisible();
expect(currencyCode).toHaveLength(2);
},
};
@@ -38,22 +38,20 @@ export const WithVariable: Story = {
args: {
label: 'Name',
defaultValue: {
firstName: `{{${MOCKED_STEP_ID}.fullName.firstName}}`,
lastName: `{{${MOCKED_STEP_ID}.fullName.lastName}}`,
firstName: `{{${MOCKED_STEP_ID}.name}}`,
lastName: `{{${MOCKED_STEP_ID}.amount}}`,
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstNameVariable = await canvas.findByText('Full Name First Name');
expect(firstNameVariable).toBeVisible();
await canvas.findAllByText('Name');
const lastNameVariable = await canvas.findByText('Full Name Last Name');
const lastNameVariable = await canvas.findByText('Amount');
expect(lastNameVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(2);
await canvas.findAllByText('VariablePicker');
},
};
@@ -1,5 +1,5 @@
import { expect, fn, userEvent, within } from '@storybook/test';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
@@ -95,7 +95,7 @@ export const SelectingVariables: Story = {
return (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.phone.number}}`);
onVariableSelect(`{{${MOCKED_STEP_ID}.amount.amountMicros}}`);
}}
>
Add variable
@@ -127,12 +127,12 @@ export const SelectingVariables: Story = {
await userEvent.click(phoneNumberVariablePicker);
const phoneNumberVariable = await canvas.findByText('My Number');
const phoneNumberVariable = await canvas.findByText('Amount Micros');
expect(phoneNumberVariable).toBeVisible();
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith({
primaryPhoneNumber: `{{${MOCKED_STEP_ID}.phone.number}}`,
primaryPhoneNumber: `{{${MOCKED_STEP_ID}.amount.amountMicros}}`,
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
});
@@ -1,5 +1,5 @@
import { expect, fn, userEvent, within } from '@storybook/test';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
@@ -17,12 +17,12 @@ type Story = StoryObj<typeof VariableChip>;
export const Default: Story = {
args: {
rawVariableName: `{{${MOCKED_STEP_ID}.address.street1}}`,
rawVariableName: `{{trigger.properties.after.address.addressStreet1}}`,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Street 1')).toBeVisible();
expect(await canvas.findByText('Address Street1')).toBeVisible();
},
};
@@ -33,12 +33,12 @@ export const DefaultDeleteHovered: Story = {
},
},
args: {
rawVariableName: `{{${MOCKED_STEP_ID}.address.street1}}`,
rawVariableName: `{{trigger.properties.after.address.addressStreet1}}`,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Street 1')).toBeVisible();
expect(await canvas.findByText('Address Street1')).toBeVisible();
},
};
@@ -1,90 +0,0 @@
import { stepsOutputSchemaFamilyState } from '@/workflow/states/stepsOutputSchemaFamilyState';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import {
type OutputSchemaV2,
type StepOutputSchemaV2,
} from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const useStepsOutputSchema = () => {
const populateStepsOutputSchema = useRecoilCallback(
({ set }) =>
(workflowVersion: WorkflowVersion) => {
workflowVersion.steps?.forEach((step) => {
const stepOutputSchema: StepOutputSchemaV2 = {
id: step.id,
name: step.name,
type: step.type,
icon: getActionIcon(step.type),
outputSchema: step.settings?.outputSchema as OutputSchemaV2,
};
set(
stepsOutputSchemaFamilyState(
getStepOutputSchemaFamilyStateKey(workflowVersion.id, step.id),
),
stepOutputSchema,
);
});
const trigger = workflowVersion.trigger;
if (isDefined(trigger)) {
const triggerIconKey = getTriggerIcon(trigger);
const triggerOutputSchema: StepOutputSchemaV2 = {
id: TRIGGER_STEP_ID,
name: isDefined(trigger.name)
? trigger.name
: getTriggerDefaultLabel(trigger),
type: trigger.type,
icon: triggerIconKey,
outputSchema: trigger.settings?.outputSchema as OutputSchemaV2,
};
set(
stepsOutputSchemaFamilyState(
getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
TRIGGER_STEP_ID,
),
),
triggerOutputSchema,
);
}
},
[],
);
const deleteStepsOutputSchema = useRecoilCallback(
({ set }) =>
({
stepIds,
workflowVersionId,
}: {
stepIds: string[];
workflowVersionId: string;
}) => {
stepIds.forEach((stepId) => {
set(
stepsOutputSchemaFamilyState(
getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId),
),
null,
);
});
},
[],
);
return {
populateStepsOutputSchema,
deleteStepsOutputSchema,
};
};
@@ -1,5 +1,5 @@
import { stepsOutputSchemaFamilyState } from '@/workflow/states/stepsOutputSchemaFamilyState';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { stepsOutputSchemaFamilyState } from '@/workflow/workflow-variables/states/stepsOutputSchemaFamilyState';
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { selectorFamily } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -0,0 +1,87 @@
import { getAgentIdFromStep } from '../getAgentIdFromStep';
describe('getAgentIdFromStep', () => {
it('should return undefined when stepDefinition is undefined', () => {
const result = getAgentIdFromStep(undefined);
expect(result).toBeUndefined();
});
it('should return undefined when stepDefinition type is trigger', () => {
const result = getAgentIdFromStep({
type: 'trigger',
definition: { type: 'DATABASE_EVENT' },
});
expect(result).toBeUndefined();
});
it('should return undefined when definition is undefined', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: undefined,
});
expect(result).toBeUndefined();
});
it('should return undefined when definition type is not AI_AGENT', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: { type: 'CREATE_RECORD' },
});
expect(result).toBeUndefined();
});
it('should return undefined when settings is missing', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: { type: 'AI_AGENT' },
});
expect(result).toBeUndefined();
});
it('should return undefined when settings.input is missing', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: {
type: 'AI_AGENT',
settings: {},
},
});
expect(result).toBeUndefined();
});
it('should return undefined when settings.input.agentId is missing', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: {
type: 'AI_AGENT',
settings: {
input: {},
},
},
});
expect(result).toBeUndefined();
});
it('should return agentId when all conditions are met', () => {
const result = getAgentIdFromStep({
type: 'action',
definition: {
type: 'AI_AGENT',
settings: {
input: {
agentId: 'agent-123',
},
},
},
});
expect(result).toBe('agent-123');
});
});
@@ -132,13 +132,10 @@ export const WorkflowDiagramCanvasEditable = () => {
const triggerToUpdate = workflowWithCurrentVersion?.currentVersion?.trigger;
if (isDefined(triggerToUpdate)) {
await updateTrigger(
{
...triggerToUpdate,
position: node.position,
},
{ computeOutputSchema: false },
);
await updateTrigger({
...triggerToUpdate,
position: node.position,
});
return;
}
@@ -2,13 +2,13 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { flowComponentState } from '@/workflow/states/flowComponentState';
import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams';
@@ -3,7 +3,6 @@ import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandM
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { flowComponentState } from '@/workflow/states/flowComponentState';
@@ -18,6 +17,7 @@ import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { selectWorkflowDiagramNode } from '@/workflow/workflow-diagram/utils/selectWorkflowDiagramNode';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { useContext, useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -1,11 +1,11 @@
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { flowComponentState } from '@/workflow/states/flowComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -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}',
);
});
},
@@ -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',
});
}
});
});
@@ -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 {
@@ -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,
});
});
});
});
@@ -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']);
});
});
@@ -0,0 +1,156 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { shouldRecomputeOutputSchemaFamilyState } from '@/workflow/workflow-variables/states/shouldRecomputeOutputSchemaFamilyState';
import { stepsOutputSchemaFamilyState } from '@/workflow/workflow-variables/states/stepsOutputSchemaFamilyState';
import {
type OutputSchemaV2,
type StepOutputSchemaV2,
} from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import {
computeStepOutputSchema,
shouldComputeOutputSchemaOnFrontend,
} from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const useStepsOutputSchema = () => {
const populateStepsOutputSchema = useRecoilCallback(
({ set, snapshot }) =>
(workflowVersion: WorkflowVersion) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
workflowVersion.steps?.forEach((step) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
step.id,
);
const shouldRecompute = snapshot
.getLoadable(shouldRecomputeOutputSchemaFamilyState(stepKey))
.getValue();
const shouldComputeOnFrontend = shouldComputeOutputSchemaOnFrontend(
step.type,
);
if (!shouldRecompute) {
return;
}
const outputSchema = shouldComputeOnFrontend
? computeStepOutputSchema({
step,
objectMetadataItems,
})
: step.settings?.outputSchema;
const stepOutputSchema: StepOutputSchemaV2 = {
id: step.id,
name: step.name,
type: step.type,
icon: getActionIcon(step.type),
outputSchema: (outputSchema ?? {}) as OutputSchemaV2,
};
set(stepsOutputSchemaFamilyState(stepKey), stepOutputSchema);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), false);
});
const trigger = workflowVersion.trigger;
if (isDefined(trigger)) {
const triggerKey = getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
TRIGGER_STEP_ID,
);
const shouldRecompute = snapshot
.getLoadable(shouldRecomputeOutputSchemaFamilyState(triggerKey))
.getValue();
const shouldComputeOnFrontend = shouldComputeOutputSchemaOnFrontend(
trigger.type,
);
if (!shouldRecompute) {
return;
}
const triggerIconKey = getTriggerIcon(trigger);
const outputSchema = shouldComputeOnFrontend
? computeStepOutputSchema({
step: trigger,
objectMetadataItems,
})
: trigger.settings?.outputSchema;
const triggerOutputSchema: StepOutputSchemaV2 = {
id: TRIGGER_STEP_ID,
name: isDefined(trigger.name)
? trigger.name
: getTriggerDefaultLabel(trigger),
type: trigger.type,
icon: triggerIconKey,
outputSchema: (outputSchema ?? {}) as OutputSchemaV2,
};
set(stepsOutputSchemaFamilyState(triggerKey), triggerOutputSchema);
set(shouldRecomputeOutputSchemaFamilyState(triggerKey), false);
}
},
[],
);
const markStepForRecomputation = useRecoilCallback(
({ set }) =>
({
stepId,
workflowVersionId,
}: {
stepId: string;
workflowVersionId: string;
}) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersionId,
stepId,
);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), true);
},
[],
);
const deleteStepsOutputSchema = useRecoilCallback(
({ set }) =>
({
stepIds,
workflowVersionId,
}: {
stepIds: string[];
workflowVersionId: string;
}) => {
stepIds.forEach((stepId) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersionId,
stepId,
);
set(stepsOutputSchemaFamilyState(stepKey), null);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), true);
});
},
[],
);
return {
populateStepsOutputSchema,
markStepForRecomputation,
deleteStepsOutputSchema,
};
};
@@ -0,0 +1,9 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const shouldRecomputeOutputSchemaFamilyState = createFamilyState<
boolean,
string | undefined
>({
key: 'shouldRecomputeOutputSchemaFamilyState',
defaultValue: true,
});
@@ -2,6 +2,7 @@ import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/R
export type RecordNode = {
isLeaf: false;
icon?: string;
label: string;
value: RecordOutputSchemaV2;
};
@@ -2,6 +2,7 @@ import { type FieldMetadataType } from 'twenty-shared/types';
export type RecordFieldLeaf = {
isLeaf: true;
icon?: string;
type: FieldMetadataType;
label: string;
value: any;
@@ -11,6 +12,7 @@ export type RecordFieldLeaf = {
export type RecordFieldNode = {
isLeaf: false;
icon?: string;
type: FieldMetadataType;
label: string;
value: RecordFieldNodeValue;
@@ -25,6 +27,7 @@ export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode;
export type RecordOutputSchemaV2 = {
object: {
icon?: string;
label: string;
objectMetadataId: string;
isRelationField?: boolean;
@@ -0,0 +1,482 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
computeStepOutputSchema,
shouldComputeOutputSchemaOnFrontend,
} from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
const mockCompanyObjectMetadataItem: ObjectMetadataItem = {
id: 'company-metadata-id',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
icon: 'IconBuildingSkyscraper',
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
},
],
} as ObjectMetadataItem;
describe('computeStepOutputSchema', () => {
describe('PERSISTED_OUTPUT_SCHEMA_TYPES', () => {
it('should return undefined for CODE step type', () => {
const result = computeStepOutputSchema({
step: { type: 'CODE', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for HTTP_REQUEST step type', () => {
const result = computeStepOutputSchema({
step: { type: 'HTTP_REQUEST', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for AI_AGENT step type', () => {
const result = computeStepOutputSchema({
step: { type: 'AI_AGENT', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for WEBHOOK step type', () => {
const result = computeStepOutputSchema({
step: { type: 'WEBHOOK', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for ITERATOR step type', () => {
const result = computeStepOutputSchema({
step: { type: 'ITERATOR', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
});
describe('DATABASE_EVENT trigger', () => {
it('should return empty object when eventName is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'DATABASE_EVENT', settings: {} } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when eventName cannot be parsed', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'invalid' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'unknownObject.created' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return record event output schema for valid eventName', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'company.created' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
expect(result).toHaveProperty('fields');
});
it('should return empty object for invalid action string', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'company.invalidAction' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it.each(['updated', 'deleted', 'upserted'])(
'should return record event output schema for %s action',
(action) => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: `company.${action}` },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
},
);
});
describe('MANUAL trigger', () => {
it('should return empty object when availability is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'MANUAL', settings: {} } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object for GLOBAL availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: { availability: { type: 'GLOBAL' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return record output schema for SINGLE_RECORD availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'SINGLE_RECORD',
objectNameSingular: 'company',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
});
it('should return array indicator for BULK_RECORDS availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'BULK_RECORDS',
objectNameSingular: 'company',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('companies');
expect((result as any).companies).toMatchObject({
isLeaf: true,
label: 'Companies',
type: 'array',
});
});
it('should return empty object when object metadata is not found for SINGLE_RECORD', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'SINGLE_RECORD',
objectNameSingular: 'unknownObject',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found for BULK_RECORDS', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'BULK_RECORDS',
objectNameSingular: 'unknownObject',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
});
describe('CRON trigger', () => {
it('should return empty object', () => {
const result = computeStepOutputSchema({
step: { type: 'CRON', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
});
describe('Record action steps', () => {
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return empty object for %s when objectName is not defined',
(stepType) => {
const result = computeStepOutputSchema({
step: { type: stepType, settings: { input: {} } } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
},
);
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return empty object for %s when object metadata is not found',
(stepType) => {
const result = computeStepOutputSchema({
step: {
type: stepType,
settings: { input: { objectName: 'unknownObject' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
},
);
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return record output schema for %s with valid objectName',
(stepType) => {
const result = computeStepOutputSchema({
step: {
type: stepType,
settings: { input: { objectName: 'company' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
},
);
});
describe('FIND_RECORDS step', () => {
it('should return empty object when objectName is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'FIND_RECORDS', settings: { input: {} } } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found', () => {
const result = computeStepOutputSchema({
step: {
type: 'FIND_RECORDS',
settings: { input: { objectName: 'unknownObject' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return find records output schema with valid objectName', () => {
const result = computeStepOutputSchema({
step: {
type: 'FIND_RECORDS',
settings: { input: { objectName: 'company' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('first');
expect(result).toHaveProperty('all');
expect(result).toHaveProperty('totalCount');
});
});
describe('FORM step', () => {
it('should return empty object when form fields are not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'FORM', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
it('should return empty object when form fields are empty', () => {
const result = computeStepOutputSchema({
step: { type: 'FORM', settings: { input: [] } } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
it('should return form output schema with valid form fields', () => {
const result = computeStepOutputSchema({
step: {
type: 'FORM',
settings: {
input: [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: 'TEXT',
},
],
},
} as any,
objectMetadataItems: [],
});
expect(result).toHaveProperty('firstName');
expect((result as any).firstName).toMatchObject({
isLeaf: true,
label: 'First Name',
});
});
});
describe('SEND_EMAIL step', () => {
it('should return success boolean schema', () => {
const result = computeStepOutputSchema({
step: { type: 'SEND_EMAIL', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
});
});
});
describe('Empty output schema steps', () => {
it.each(['FILTER', 'DELAY', 'EMPTY'])(
'should return empty object for %s step type',
(stepType) => {
const result = computeStepOutputSchema({
step: { type: stepType, settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
},
);
});
describe('Unknown step type', () => {
it('should return empty object for unknown step type', () => {
const result = computeStepOutputSchema({
step: { type: 'UNKNOWN_TYPE', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
});
});
describe('shouldComputeOutputSchemaOnFrontend', () => {
it('should return false for CODE', () => {
expect(shouldComputeOutputSchemaOnFrontend('CODE')).toBe(false);
});
it('should return false for HTTP_REQUEST', () => {
expect(shouldComputeOutputSchemaOnFrontend('HTTP_REQUEST')).toBe(false);
});
it('should return false for AI_AGENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('AI_AGENT')).toBe(false);
});
it('should return false for WEBHOOK', () => {
expect(shouldComputeOutputSchemaOnFrontend('WEBHOOK')).toBe(false);
});
it('should return false for ITERATOR', () => {
expect(shouldComputeOutputSchemaOnFrontend('ITERATOR')).toBe(false);
});
it('should return true for DATABASE_EVENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('DATABASE_EVENT')).toBe(true);
});
it('should return true for CREATE_RECORD', () => {
expect(shouldComputeOutputSchemaOnFrontend('CREATE_RECORD')).toBe(true);
});
it('should return true for FIND_RECORDS', () => {
expect(shouldComputeOutputSchemaOnFrontend('FIND_RECORDS')).toBe(true);
});
it('should return true for SEND_EMAIL', () => {
expect(shouldComputeOutputSchemaOnFrontend('SEND_EMAIL')).toBe(true);
});
});
@@ -0,0 +1,196 @@
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import { FieldMetadataType } from 'twenty-shared/types';
describe('generateFakeValue', () => {
describe('Primitive classification', () => {
it('should generate string value', () => {
const result = generateFakeValue('string', 'Primitive');
expect(result).toBe('My text');
});
it('should generate number value', () => {
const result = generateFakeValue('number', 'Primitive');
expect(result).toBe(20);
});
it('should generate boolean value', () => {
const result = generateFakeValue('boolean', 'Primitive');
expect(result).toBe(true);
});
it('should generate Date value', () => {
const result = generateFakeValue('Date', 'Primitive');
expect(result).toBeInstanceOf(Date);
});
it('should generate array of strings', () => {
const result = generateFakeValue('string[]', 'Primitive');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect((result as string[])[0]).toBe('My text');
});
it('should generate array of numbers', () => {
const result = generateFakeValue('number[]', 'Primitive');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect((result as number[])[0]).toBe(20);
});
it('should generate object with properties', () => {
const result = generateFakeValue(
'{name: string; age: number}',
'Primitive',
) as Record<string, any>;
expect(result).toEqual({
name: 'My text',
age: 20,
});
});
it('should return null for unknown primitive type', () => {
const result = generateFakeValue('unknownType', 'Primitive');
expect(result).toBeNull();
});
it('should use Primitive as default classification', () => {
const result = generateFakeValue('string');
expect(result).toBe('My text');
});
});
describe('FieldMetadataType classification', () => {
it('should generate TEXT value', () => {
const result = generateFakeValue(
FieldMetadataType.TEXT,
'FieldMetadataType',
);
expect(result).toBe('My text');
});
it('should generate NUMBER value', () => {
const result = generateFakeValue(
FieldMetadataType.NUMBER,
'FieldMetadataType',
);
expect(result).toBe(20);
});
it('should generate BOOLEAN value', () => {
const result = generateFakeValue(
FieldMetadataType.BOOLEAN,
'FieldMetadataType',
);
expect(result).toBe(true);
});
it('should generate DATE value', () => {
const result = generateFakeValue(
FieldMetadataType.DATE,
'FieldMetadataType',
);
expect(result).toBe('01/23/2025');
});
it('should generate DATE_TIME value', () => {
const result = generateFakeValue(
FieldMetadataType.DATE_TIME,
'FieldMetadataType',
);
expect(result).toBe('01/23/2025 15:16');
});
it('should generate ADDRESS value', () => {
const result = generateFakeValue(
FieldMetadataType.ADDRESS,
'FieldMetadataType',
);
expect(result).toBe('123 Main St, Anytown, CA 12345');
});
it('should generate FULL_NAME value', () => {
const result = generateFakeValue(
FieldMetadataType.FULL_NAME,
'FieldMetadataType',
);
expect(result).toBe('Tim Cook');
});
it('should generate RAW_JSON value as null', () => {
const result = generateFakeValue(
FieldMetadataType.RAW_JSON,
'FieldMetadataType',
);
expect(result).toBeNull();
});
it('should generate RICH_TEXT value', () => {
const result = generateFakeValue(
FieldMetadataType.RICH_TEXT,
'FieldMetadataType',
);
expect(result).toBe('My rich text');
});
it('should generate UUID value', () => {
const result = generateFakeValue(
FieldMetadataType.UUID,
'FieldMetadataType',
);
expect(result).toBe('123e4567-e89b-12d3-a456-426614174000');
});
it('should return null for unknown FieldMetadataType', () => {
const result = generateFakeValue('UNKNOWN_TYPE', 'FieldMetadataType');
expect(result).toBeNull();
});
it.each([
FieldMetadataType.CURRENCY,
FieldMetadataType.LINKS,
FieldMetadataType.PHONES,
FieldMetadataType.EMAILS,
FieldMetadataType.RATING,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.ARRAY,
FieldMetadataType.RELATION,
FieldMetadataType.ACTOR,
])(
'should return null for unsupported FieldMetadataType %s',
(fieldType) => {
const result = generateFakeValue(fieldType, 'FieldMetadataType');
expect(result).toBeNull();
},
);
});
describe('Unknown classification', () => {
it('should return null for unknown classification', () => {
const result = generateFakeValue('string', 'Unknown' as any);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,119 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFindRecordsOutputSchema';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateFindRecordsOutputSchema', () => {
it('should generate schema with first, all, and totalCount properties', () => {
const objectMetadataItem = createMockObjectMetadataItem();
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result).toHaveProperty('first');
expect(result).toHaveProperty('all');
expect(result).toHaveProperty('totalCount');
});
describe('first property', () => {
it('should be a non-leaf node with record schema as value', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelSingular: 'Company',
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first).toMatchObject({
isLeaf: false,
icon: 'IconAlpha',
label: 'First Company',
});
expect(result.first.value).toHaveProperty('_outputSchemaType', 'RECORD');
});
it('should use default label when labelSingular is undefined', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelSingular: undefined as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first.label).toBe('First Record');
});
});
describe('all property', () => {
it('should be a leaf node with array type', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelPlural: 'Companies',
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.all).toMatchObject({
isLeaf: true,
icon: 'IconListDetails',
label: 'All Companies',
type: 'array',
value: 'Returns an array of records',
});
});
it('should use default label when labelPlural is undefined', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelPlural: undefined as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.all?.label).toBe('All Records');
});
});
describe('totalCount property', () => {
it('should be a leaf node with number type', () => {
const objectMetadataItem = createMockObjectMetadataItem();
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.totalCount).toMatchObject({
isLeaf: true,
icon: 'IconSum',
label: 'Total Count',
type: 'number',
value: 'Count of matching records',
});
});
});
it('should include record fields in first value', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: 'TEXT',
isActive: true,
isSystem: false,
},
] as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first.value).toHaveProperty('fields');
expect((result.first.value as any).fields).toHaveProperty('name');
});
});
@@ -0,0 +1,232 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { generateFormOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFormOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateFormOutputSchema', () => {
describe('Non-RECORD fields', () => {
it('should generate leaf node for TEXT field', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.TEXT,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).toHaveProperty('firstName');
expect(result.firstName).toMatchObject({
isLeaf: true,
type: 'TEXT',
label: 'First Name',
});
});
it('should use placeholder as value when defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'email',
label: 'Email',
type: FieldMetadataType.TEXT,
placeholder: 'Enter your email',
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.email).toMatchObject({
value: 'Enter your email',
});
});
it('should generate fake value when placeholder is not defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.name.value).toBe('My text');
});
it('should handle NUMBER field type', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'quantity',
label: 'Quantity',
type: FieldMetadataType.NUMBER,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.quantity).toMatchObject({
isLeaf: true,
type: FieldMetadataType.NUMBER,
label: 'Quantity',
value: 20,
});
});
});
describe('RECORD fields', () => {
it('should generate non-leaf node for RECORD field with valid objectName', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {
objectName: 'company',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
labelSingular: 'Company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(result).toHaveProperty('selectedCompany');
expect(result.selectedCompany).toMatchObject({
isLeaf: false,
label: 'Selected Company',
});
expect((result.selectedCompany as any).value).toHaveProperty(
'_outputSchemaType',
'RECORD',
);
});
it('should skip RECORD field when objectName is not defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {},
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).not.toHaveProperty('selectedCompany');
});
it('should skip RECORD field when settings is undefined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).not.toHaveProperty('selectedCompany');
});
it('should skip RECORD field when objectMetadataItem is not found', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {
objectName: 'unknownObject',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(result).not.toHaveProperty('selectedCompany');
});
});
describe('Multiple fields', () => {
it('should handle multiple fields of different types', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'age',
label: 'Age',
type: FieldMetadataType.NUMBER,
},
{
id: 'field-3',
name: 'company',
label: 'Company',
type: 'RECORD',
settings: {
objectName: 'company',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
labelSingular: 'Company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(Object.keys(result)).toHaveLength(3);
expect(result).toHaveProperty('firstName');
expect(result).toHaveProperty('age');
expect(result).toHaveProperty('company');
});
});
describe('Empty input', () => {
it('should return empty object for empty form fields', () => {
const result = generateFormOutputSchema([], []);
expect(result).toEqual({});
});
});
});
@@ -0,0 +1,370 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { DatabaseEventAction } from '~/generated/graphql';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateRecordEventOutputSchema', () => {
describe('CREATED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
icon: 'IconBuilding',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(result).toEqual({
object: {
icon: 'IconBuilding',
label: 'Company',
objectMetadataId: 'company-id',
fieldIdName: 'properties.after.id',
},
fields: {},
_outputSchemaType: 'RECORD',
});
});
it('should prefix field names with properties.after', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: 'IconText',
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.name');
});
});
describe('UPDATED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.UPDATED,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
describe('DELETED action', () => {
it('should generate schema with properties.before prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DELETED,
);
expect(result.object.fieldIdName).toBe('properties.before.id');
});
it('should prefix field names with properties.before', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DELETED,
);
expect(Object.keys(result.fields)).toContain('properties.before.name');
});
});
describe('DESTROYED action', () => {
it('should generate schema with properties.before prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DESTROYED,
);
expect(result.object.fieldIdName).toBe('properties.before.id');
});
});
describe('UPSERTED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.UPSERTED,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
describe('Field handling', () => {
it('should exclude inactive fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'inactive-field-id',
name: 'inactiveField',
label: 'Inactive Field',
type: FieldMetadataType.TEXT,
isActive: false,
isSystem: false,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toHaveLength(0);
});
it('should exclude searchVector system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'search-vector-id',
name: 'searchVector',
label: 'Search Vector',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.searchVector',
);
});
it('should convert relation fields to prefixed UUID id fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'company-relation-id',
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
icon: 'IconBuilding',
relation: {
type: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain(
'properties.after.companyId',
);
expect(result.fields['properties.after.companyId']).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
});
});
it('should generate composite fields with prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'address-field-id',
name: 'address',
label: 'Address',
type: FieldMetadataType.ADDRESS,
isActive: true,
isSystem: false,
icon: 'IconMap',
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.address');
const addressField = result.fields['properties.after.address'];
expect(addressField).toMatchObject({
isLeaf: false,
type: FieldMetadataType.ADDRESS,
});
});
it('should convert MORPH_RELATION fields to prefixed UUID id fields when MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'target',
label: 'Target',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
icon: 'IconLink',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.targetId');
expect(result.fields['properties.after.targetId']).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
});
});
it('should exclude MORPH_RELATION fields when not MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'targets',
label: 'Targets',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.targets',
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.targetsId',
);
});
it('should exclude one-to-many relations', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'people-relation-id',
name: 'people',
label: 'People',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
relation: {
type: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.people',
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.peopleId',
);
});
});
describe('Default action handling', () => {
it('should default to properties.after for unknown action', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
'UNKNOWN_ACTION' as DatabaseEventAction,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
});
@@ -0,0 +1,309 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateRecordOutputSchema', () => {
it('should generate schema with correct object metadata', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
icon: 'IconBuilding',
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result).toEqual({
object: {
icon: 'IconBuilding',
label: 'Company',
objectMetadataId: 'company-id',
fieldIdName: 'id',
},
fields: {},
_outputSchemaType: 'RECORD',
});
});
it('should generate fields for active non-system fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: 'IconText',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('name');
expect(result.fields.name).toMatchObject({
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Name',
fieldMetadataId: 'name-field-id',
});
});
it('should exclude inactive fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'inactive-field-id',
name: 'inactiveField',
label: 'Inactive Field',
type: FieldMetadataType.TEXT,
isActive: false,
isSystem: false,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('inactiveField');
});
it('should exclude searchVector system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'search-vector-id',
name: 'searchVector',
label: 'Search Vector',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('searchVector');
});
it('should exclude position system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'position-id',
name: 'position',
label: 'Position',
type: FieldMetadataType.NUMBER,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('position');
});
it('should include non-excluded system fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'created-at-id',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
isActive: true,
isSystem: true,
icon: 'IconCalendar',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('createdAt');
});
it('should generate composite field with subfields for ADDRESS type', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'address-field-id',
name: 'address',
label: 'Address',
type: FieldMetadataType.ADDRESS,
isActive: true,
isSystem: false,
icon: 'IconMap',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('address');
expect(result.fields.address).toMatchObject({
isLeaf: false,
type: FieldMetadataType.ADDRESS,
label: 'Address',
fieldMetadataId: 'address-field-id',
});
expect((result.fields.address as any).value).toHaveProperty(
'addressStreet1',
);
expect((result.fields.address as any).value).toHaveProperty('addressCity');
});
it('should convert relation fields to UUID id fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'company-relation-id',
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
icon: 'IconBuilding',
relation: {
type: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('company');
expect(result.fields).toHaveProperty('companyId');
expect(result.fields.companyId).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
label: 'Company Id',
});
});
it('should exclude one-to-many relations', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'people-relation-id',
name: 'people',
label: 'People',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
relation: {
type: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('people');
expect(result.fields).not.toHaveProperty('peopleId');
});
it('should handle object without icon', () => {
const objectMetadataItem = createMockObjectMetadataItem({
icon: null as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.object.icon).toBeUndefined();
});
it('should convert MORPH_RELATION fields to UUID id fields when MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'target',
label: 'Target',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
icon: 'IconLink',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('target');
expect(result.fields).toHaveProperty('targetId');
expect(result.fields.targetId).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
label: 'Target Id',
});
});
it('should exclude MORPH_RELATION fields when not MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'targets',
label: 'Targets',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('targets');
expect(result.fields).not.toHaveProperty('targetsId');
});
it('should handle field without icon', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'field-no-icon',
name: 'noIcon',
label: 'No Icon',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: null,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields.noIcon).toMatchObject({
icon: undefined,
});
});
});
@@ -0,0 +1,221 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type WorkflowAction,
type WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { generateFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFindRecordsOutputSchema';
import { generateFormOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFormOutputSchema';
import { generateRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated/graphql';
const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'CODE',
'HTTP_REQUEST',
'AI_AGENT',
'WEBHOOK',
'ITERATOR',
];
const findObjectMetadataItemByName = (
objectMetadataItems: ObjectMetadataItem[],
objectName: string,
): ObjectMetadataItem | undefined => {
return objectMetadataItems.find((item) => item.nameSingular === objectName);
};
const parseEventName = (
eventName: string,
): { objectName: string; action: DatabaseEventAction } | undefined => {
const [objectName, actionString] = eventName.split('.');
if (!objectName || !actionString) {
return undefined;
}
const actionMap: Record<string, DatabaseEventAction> = {
created: DatabaseEventAction.CREATED,
updated: DatabaseEventAction.UPDATED,
deleted: DatabaseEventAction.DELETED,
upserted: DatabaseEventAction.UPSERTED,
};
const action = actionMap[actionString.toLowerCase()];
if (!action) {
return undefined;
}
return { objectName, action };
};
export const computeStepOutputSchema = ({
step,
objectMetadataItems,
}: {
step: WorkflowTrigger | WorkflowAction;
objectMetadataItems: ObjectMetadataItem[];
}): OutputSchemaV2 | undefined => {
const stepType = step.type;
if (PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType)) {
return undefined;
}
switch (stepType) {
case 'DATABASE_EVENT': {
const eventName = step.settings?.eventName;
if (!isDefined(eventName)) {
return {};
}
const parsed = parseEventName(eventName);
if (!parsed) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
parsed.objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateRecordEventOutputSchema(objectMetadataItem, parsed.action);
}
case 'MANUAL': {
const availability = step.settings?.availability;
if (!isDefined(availability)) {
return {};
}
if (availability.type === 'GLOBAL') {
return {};
}
if (
availability.type === 'SINGLE_RECORD' ||
availability.type === 'BULK_RECORDS'
) {
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
availability.objectNameSingular,
);
if (!objectMetadataItem) {
return {};
}
if (availability.type === 'SINGLE_RECORD') {
return generateRecordOutputSchema(objectMetadataItem);
}
// BULK_RECORDS - return array indicator
return {
[objectMetadataItem.namePlural]: {
isLeaf: true,
label: objectMetadataItem.labelPlural,
type: 'array',
value: `Array of ${objectMetadataItem.labelPlural}`,
},
};
}
return {};
}
case 'CRON': {
return {};
}
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD':
case 'UPSERT_RECORD': {
const objectName = step.settings?.input?.objectName;
if (!isDefined(objectName)) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateRecordOutputSchema(objectMetadataItem);
}
case 'FIND_RECORDS': {
const objectName = step.settings?.input?.objectName;
if (!isDefined(objectName)) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateFindRecordsOutputSchema(objectMetadataItem);
}
case 'FORM': {
const formFields = step.settings?.input as
| WorkflowFormActionField[]
| undefined;
if (!isDefined(formFields) || formFields.length === 0) {
return {};
}
return generateFormOutputSchema(formFields, objectMetadataItems);
}
case 'SEND_EMAIL': {
return {
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
};
}
case 'FILTER':
case 'DELAY':
case 'EMPTY': {
return {};
}
default: {
return {};
}
}
};
export const shouldComputeOutputSchemaOnFrontend = (
stepType: string,
): boolean => {
return !PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType);
};
@@ -0,0 +1,92 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type FakeValueTypes =
| string
| number
| boolean
| Date
| FakeValueTypes[]
| FieldMetadataType
| { [key: string]: FakeValueTypes }
| null;
type TypeClassification = 'Primitive' | 'FieldMetadataType';
const generatePrimitiveValue = (valueType: string): FakeValueTypes => {
if (valueType === 'string') {
return 'My text';
} else if (valueType === 'number') {
return 20;
} else if (valueType === 'boolean') {
return true;
} else if (valueType === 'Date') {
return new Date();
} else if (valueType.endsWith('[]')) {
const elementType = valueType.replace('[]', '');
return Array.from({ length: 3 }, () => generateFakeValue(elementType));
} else if (valueType.startsWith('{') && valueType.endsWith('}')) {
const objData: Record<string, FakeValueTypes> = {};
const properties = valueType
.slice(1, -1)
.split(';')
.map((property) => property.trim())
.filter((property) => property);
properties.forEach((property) => {
const [key, propertyValueType] = property
.split(':')
.map((segment) => segment.trim());
objData[key] = generateFakeValue(propertyValueType);
});
return objData;
} else {
return null;
}
};
const generateFieldMetadataTypeValue = (
valueType: string,
): FakeValueTypes | null => {
switch (valueType) {
case FieldMetadataType.TEXT:
return 'My text';
case FieldMetadataType.NUMBER:
return 20;
case FieldMetadataType.BOOLEAN:
return true;
case FieldMetadataType.DATE:
return '01/23/2025';
case FieldMetadataType.DATE_TIME:
return '01/23/2025 15:16';
case FieldMetadataType.ADDRESS:
return '123 Main St, Anytown, CA 12345';
case FieldMetadataType.FULL_NAME:
return 'Tim Cook';
case FieldMetadataType.RAW_JSON:
return null;
case FieldMetadataType.RICH_TEXT:
return 'My rich text';
case FieldMetadataType.UUID:
return '123e4567-e89b-12d3-a456-426614174000';
default:
return null;
}
};
export const generateFakeValue = (
valueType: string,
classification: TypeClassification = 'Primitive',
): FakeValueTypes => {
switch (classification) {
case 'Primitive':
return generatePrimitiveValue(valueType);
case 'FieldMetadataType':
return generateFieldMetadataTypeValue(valueType);
default:
return null;
}
};
@@ -0,0 +1,32 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
export const generateFindRecordsOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
): FindRecordsOutputSchema => {
const recordOutputSchema = generateRecordOutputSchema(objectMetadataItem);
return {
first: {
isLeaf: false,
icon: 'IconAlpha',
label: `First ${objectMetadataItem.labelSingular ?? 'Record'}`,
value: recordOutputSchema,
},
all: {
isLeaf: true,
icon: 'IconListDetails',
label: `All ${objectMetadataItem.labelPlural ?? 'Records'}`,
type: 'array',
value: 'Returns an array of records',
},
totalCount: {
isLeaf: true,
icon: 'IconSum',
label: 'Total Count',
type: 'number',
value: 'Count of matching records',
},
};
};
@@ -0,0 +1,49 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const generateFormOutputSchema = (
formFields: WorkflowFormActionField[],
objectMetadataItems: ObjectMetadataItem[],
): FormOutputSchema => {
const result: FormOutputSchema = {};
for (const formField of formFields) {
if (formField.type === 'RECORD') {
const objectName = formField.settings?.objectName;
if (!isDefined(objectName)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectName,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
result[formField.name] = {
isLeaf: false,
label: formField.label,
value: generateRecordOutputSchema(objectMetadataItem),
};
} else {
result[formField.name] = {
isLeaf: true,
type: formField.type as FieldMetadataType,
label: formField.label,
value:
formField.placeholder ??
generateFakeValue(formField.type, 'FieldMetadataType'),
};
}
}
return result;
};
@@ -0,0 +1,186 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type DatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/DatabaseEventTriggerOutputSchema';
import {
type FieldOutputSchemaV2,
type RecordFieldLeaf,
type RecordOutputSchemaV2,
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import {
compositeTypeDefinitions,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated/graphql';
const camelToTitleCase = (camelCaseText: string): string =>
capitalize(
camelCaseText
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase()),
);
const EXCLUDED_SYSTEM_FIELDS = ['searchVector', 'position'];
const shouldGenerateFieldOutput = (
fieldMetadataItem: ObjectMetadataItem['fields'][number],
): boolean => {
if (!fieldMetadataItem.isActive) {
return false;
}
const isExcludedSystemField =
(fieldMetadataItem.isSystem &&
EXCLUDED_SYSTEM_FIELDS.includes(fieldMetadataItem.name)) ??
false;
if (isExcludedSystemField) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type !== RelationType.MANY_TO_ONE
) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.settings?.relationType !== RelationType.MANY_TO_ONE
) {
return false;
}
return true;
};
const generatePrefixedRecordField = (
fieldMetadataItem: ObjectMetadataItem['fields'][number],
prefix: string,
): Record<string, FieldOutputSchemaV2> => {
const compositeType = compositeTypeDefinitions.get(fieldMetadataItem.type);
const icon = fieldMetadataItem.icon ?? undefined;
if (isDefined(compositeType)) {
const prefixedValue = compositeType.properties.reduce(
(acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: true,
};
return acc;
},
{} as Record<string, RecordFieldLeaf>,
);
return {
[`${prefix}.${fieldMetadataItem.name}`]: {
isLeaf: false,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
fieldMetadataId: fieldMetadataItem.id,
value: prefixedValue,
},
};
}
return {
[`${prefix}.${fieldMetadataItem.name}`]: {
isLeaf: true,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
value: generateFakeValue(fieldMetadataItem.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
},
};
};
const generatePrefixedRecordFields = (
objectMetadataItem: ObjectMetadataItem,
prefix: string,
): Record<string, FieldOutputSchemaV2> => {
const result: Record<string, FieldOutputSchemaV2> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (!shouldGenerateFieldOutput(fieldMetadataItem)) {
continue;
}
const isRelationField =
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION;
if (isRelationField) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldLabel = camelToTitleCase(relationIdFieldName);
result[`${prefix}.${relationIdFieldName}`] = {
isLeaf: true,
icon: fieldMetadataItem.icon ?? undefined,
type: FieldMetadataType.UUID,
label: relationIdFieldLabel,
value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
} else {
Object.assign(
result,
generatePrefixedRecordField(fieldMetadataItem, prefix),
);
}
}
return result;
};
const generateRecordEventWithPrefix = (
objectMetadataItem: ObjectMetadataItem,
prefix: string,
): RecordOutputSchemaV2 => {
return {
object: {
icon: objectMetadataItem.icon ?? undefined,
label: objectMetadataItem.labelSingular,
objectMetadataId: objectMetadataItem.id,
fieldIdName: `${prefix}.id`,
},
fields: generatePrefixedRecordFields(objectMetadataItem, prefix),
_outputSchemaType: 'RECORD',
};
};
export const generateRecordEventOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
action: DatabaseEventAction,
): DatabaseEventTriggerOutputSchema => {
switch (action) {
case DatabaseEventAction.CREATED:
case DatabaseEventAction.UPDATED:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.after',
);
case DatabaseEventAction.DELETED:
case DatabaseEventAction.DESTROYED:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.before',
);
default:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.after',
);
}
};
@@ -0,0 +1,148 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type FieldOutputSchemaV2,
type RecordFieldLeaf,
type RecordOutputSchemaV2,
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import {
compositeTypeDefinitions,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
const camelToTitleCase = (camelCaseText: string): string =>
capitalize(
camelCaseText
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase()),
);
const EXCLUDED_SYSTEM_FIELDS = ['searchVector', 'position'];
const shouldGenerateFieldOutput = (
fieldMetadataItem: FieldMetadataItem,
): boolean => {
if (!fieldMetadataItem.isActive) {
return false;
}
const isExcludedSystemField =
(fieldMetadataItem.isSystem &&
EXCLUDED_SYSTEM_FIELDS.includes(fieldMetadataItem.name)) ??
false;
if (isExcludedSystemField) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type !== RelationType.MANY_TO_ONE
) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.settings?.relationType !== RelationType.MANY_TO_ONE
) {
return false;
}
return true;
};
const generateRecordField = (
fieldMetadataItem: FieldMetadataItem,
): FieldOutputSchemaV2 => {
const compositeType = compositeTypeDefinitions.get(fieldMetadataItem.type);
const icon = fieldMetadataItem.icon ?? undefined;
if (isDefined(compositeType)) {
return {
isLeaf: false,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
fieldMetadataId: fieldMetadataItem.id,
value: compositeType.properties.reduce(
(acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: true,
};
return acc;
},
{} as Record<string, RecordFieldLeaf>,
),
};
}
return {
isLeaf: true,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
value: generateFakeValue(fieldMetadataItem.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
};
const generateRecordFields = (
objectMetadataItem: ObjectMetadataItem,
): Record<string, FieldOutputSchemaV2> => {
const result: Record<string, FieldOutputSchemaV2> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (!shouldGenerateFieldOutput(fieldMetadataItem)) {
continue;
}
const isRelationField =
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION;
if (isRelationField) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldLabel = camelToTitleCase(relationIdFieldName);
result[relationIdFieldName] = {
isLeaf: true,
icon: fieldMetadataItem.icon ?? undefined,
type: FieldMetadataType.UUID,
label: relationIdFieldLabel,
value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
} else {
result[fieldMetadataItem.name] = generateRecordField(fieldMetadataItem);
}
}
return result;
};
export const generateRecordOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
): RecordOutputSchemaV2 => {
return {
object: {
icon: objectMetadataItem.icon ?? undefined,
label: objectMetadataItem.labelSingular,
objectMetadataId: objectMetadataItem.id,
fieldIdName: 'id',
},
fields: generateRecordFields(objectMetadataItem),
_outputSchemaType: 'RECORD',
};
};
@@ -1,6 +1,6 @@
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { flowComponentState } from '@/workflow/states/flowComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowRunIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowRunIdComponentState';
@@ -8,6 +8,7 @@ import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/st
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { type Decorator } from '@storybook/react';
import { useEffect, useState } from 'react';
import { useRecoilCallback } from 'recoil';
@@ -22,11 +23,14 @@ export const WorkflowStepDecorator: Decorator = (Story) => {
const workflowVersion = getWorkflowMock().versions.edges[0]
.node as WorkflowVersion;
const { populateStepsOutputSchema } = useStepsOutputSchema();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
const [ready, setReady] = useState(false);
const handleMount = useRecoilCallback(
({ set }) =>
() => {
async () => {
await loadMockedObjectMetadataItems();
set(
workflowVisualizerWorkflowIdComponentState.atomFamily({
instanceId: workflowVisualizerComponentInstanceId,
@@ -70,7 +74,7 @@ export const WorkflowStepDecorator: Decorator = (Story) => {
populateStepsOutputSchema(workflowVersion);
setReady(true);
},
[populateStepsOutputSchema, workflowVersion],
[loadMockedObjectMetadataItems, populateStepsOutputSchema, workflowVersion],
);
useEffect(() => {
@@ -175,15 +175,12 @@ export class WorkflowSchemaWorkspaceService {
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code and HTTP request workflow actions.
// For code actions, OutputSchema is computed and updated when testing the serverless function.
// For HTTP requests, OutputSchema is determined by the example response input
// AI agent OutputSchema is enriched from agent's responseFormat
if (
[WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes(
step.type,
)
) {
const BACKEND_ENRICHED_TYPES = [
WorkflowActionType.AI_AGENT,
WorkflowActionType.ITERATOR,
];
if (!BACKEND_ENRICHED_TYPES.includes(step.type)) {
return step;
}
@@ -4,6 +4,7 @@ export type NodeType = 'object' | 'unknown';
export type Leaf = {
isLeaf: true;
icon?: string;
type: LeafType;
label: string;
value: any;
@@ -11,6 +12,7 @@ export type Leaf = {
export type Node = {
isLeaf: false;
icon?: string;
type: NodeType;
label: string;
value: BaseOutputSchemaV2;