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:
+11
-12
@@ -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();
|
||||
|
||||
+3
-3
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-7
@@ -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
-1
@@ -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';
|
||||
|
||||
+3
-3
@@ -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
-1
@@ -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';
|
||||
|
||||
+4
-4
@@ -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
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
+4
-7
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/hooks/useStepsOutputSchema', () => ({
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: () => ({
|
||||
deleteStepsOutputSchema: mockDeleteStepsOutputSchema,
|
||||
}),
|
||||
|
||||
+77
-2
@@ -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,
|
||||
};
|
||||
|
||||
+6
-8
@@ -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}',
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
+37
-33
@@ -1,10 +1,11 @@
|
||||
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
|
||||
const mockUpdateOneRecord = jest.fn();
|
||||
const mockGetUpdatableWorkflowVersion = jest.fn();
|
||||
const mockComputeStepOutputSchema = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
|
||||
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
|
||||
useUpdateOneRecord: jest.fn(() => ({
|
||||
@@ -18,9 +19,9 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
|
||||
useComputeStepOutputSchema: jest.fn(() => ({
|
||||
computeStepOutputSchema: mockComputeStepOutputSchema,
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: jest.fn(() => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -39,11 +40,8 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('updates the trigger with computed output schema', async () => {
|
||||
it('updates the trigger and marks it for recomputation for frontend-computed types', async () => {
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
|
||||
mockComputeStepOutputSchema.mockResolvedValue({
|
||||
data: { computeStepOutputSchema: { field1: 'string' } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
|
||||
|
||||
@@ -52,33 +50,10 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
});
|
||||
|
||||
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
|
||||
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({
|
||||
step: trigger,
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId: 'version-id',
|
||||
});
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
idToUpdate: 'version-id',
|
||||
updateOneRecordInput: {
|
||||
trigger: {
|
||||
...trigger,
|
||||
settings: { ...trigger.settings, outputSchema: { field1: 'string' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips output schema computation when disabled', async () => {
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTrigger(trigger, {
|
||||
computeOutputSchema: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockComputeStepOutputSchema).not.toHaveBeenCalled();
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
idToUpdate: 'version-id',
|
||||
updateOneRecordInput: {
|
||||
@@ -86,4 +61,33 @@ describe('useUpdateWorkflowVersionTrigger', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks for recomputation for all trigger types', async () => {
|
||||
const triggerTypes = ['DATABASE_EVENT', 'MANUAL', 'CRON', 'WEBHOOK'];
|
||||
|
||||
for (const triggerType of triggerTypes) {
|
||||
mockMarkStepForRecomputation.mockClear();
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
|
||||
|
||||
const testTrigger: WorkflowTrigger = {
|
||||
name: `${triggerType} Trigger`,
|
||||
type: triggerType as any,
|
||||
settings: {
|
||||
outputSchema: {},
|
||||
},
|
||||
nextStepIds: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTrigger(testTrigger);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId: 'version-id',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+10
-20
@@ -1,12 +1,14 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
type WorkflowVersion,
|
||||
} from '@/workflow/types/Workflow';
|
||||
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
|
||||
export const useUpdateWorkflowVersionTrigger = () => {
|
||||
const { updateOneRecord: updateOneWorkflowVersion } =
|
||||
useUpdateOneRecord<WorkflowVersion>({
|
||||
@@ -16,34 +18,22 @@ export const useUpdateWorkflowVersionTrigger = () => {
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
|
||||
const { computeStepOutputSchema } = useComputeStepOutputSchema();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const updateTrigger = async (
|
||||
updatedTrigger: WorkflowTrigger,
|
||||
options: { computeOutputSchema: boolean } = { computeOutputSchema: true },
|
||||
) => {
|
||||
const updateTrigger = async (updatedTrigger: WorkflowTrigger) => {
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
|
||||
if (options.computeOutputSchema) {
|
||||
const outputSchema = (
|
||||
await computeStepOutputSchema({
|
||||
step: updatedTrigger,
|
||||
workflowVersionId,
|
||||
})
|
||||
)?.data?.computeStepOutputSchema;
|
||||
|
||||
updatedTrigger.settings = {
|
||||
...updatedTrigger.settings,
|
||||
outputSchema: outputSchema || {},
|
||||
};
|
||||
}
|
||||
|
||||
await updateOneWorkflowVersion({
|
||||
idToUpdate: workflowVersionId,
|
||||
updateOneRecordInput: {
|
||||
trigger: updatedTrigger,
|
||||
},
|
||||
});
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: TRIGGER_STEP_ID,
|
||||
workflowVersionId,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
|
||||
import { getManualTriggerDefaultSettings } from '../getManualTriggerDefaultSettings';
|
||||
|
||||
const mockObjectMetadataItems: ObjectMetadataItem[] = [
|
||||
{
|
||||
id: 'company-id',
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
icon: 'IconBuilding',
|
||||
fields: [],
|
||||
createdAt: new Date(),
|
||||
} as unknown as ObjectMetadataItem,
|
||||
];
|
||||
|
||||
describe('getManualTriggerDefaultSettings', () => {
|
||||
describe('GLOBAL availability', () => {
|
||||
it('should return correct settings for GLOBAL type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: undefined,
|
||||
availability: {
|
||||
type: 'GLOBAL',
|
||||
locations: undefined,
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom icon when provided', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
icon: 'IconCustom',
|
||||
});
|
||||
|
||||
expect(result.icon).toBe('IconCustom');
|
||||
});
|
||||
|
||||
it('should use isPinned when provided', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'GLOBAL',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
isPinned: true,
|
||||
});
|
||||
|
||||
expect(result.isPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SINGLE_RECORD availability', () => {
|
||||
it('should return correct settings for SINGLE_RECORD type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'SINGLE_RECORD',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: 'company',
|
||||
availability: {
|
||||
type: 'SINGLE_RECORD',
|
||||
objectNameSingular: 'company',
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the first object metadata item', () => {
|
||||
const multipleObjects: ObjectMetadataItem[] = [
|
||||
...mockObjectMetadataItems,
|
||||
{
|
||||
id: 'person-id',
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
labelSingular: 'Person',
|
||||
labelPlural: 'People',
|
||||
icon: 'IconUser',
|
||||
fields: [],
|
||||
} as unknown as ObjectMetadataItem,
|
||||
];
|
||||
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'SINGLE_RECORD',
|
||||
activeNonSystemObjectMetadataItems: multipleObjects,
|
||||
});
|
||||
|
||||
expect(result.objectType).toBe('company');
|
||||
expect(
|
||||
(result.availability as { objectNameSingular: string })
|
||||
.objectNameSingular,
|
||||
).toBe('company');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BULK_RECORDS availability', () => {
|
||||
it('should return correct settings for BULK_RECORDS type', () => {
|
||||
const result = getManualTriggerDefaultSettings({
|
||||
availabilityType: 'BULK_RECORDS',
|
||||
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
objectType: 'company',
|
||||
availability: {
|
||||
type: 'BULK_RECORDS',
|
||||
objectNameSingular: 'company',
|
||||
},
|
||||
outputSchema: {},
|
||||
icon: COMMAND_MENU_DEFAULT_ICON,
|
||||
isPinned: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { type WorkflowAction } from '@/workflow/types/Workflow';
|
||||
import { getRootStepIds } from '../getRootStepIds';
|
||||
|
||||
describe('getRootStepIds', () => {
|
||||
it('should return empty array for empty steps', () => {
|
||||
const result = getRootStepIds([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return single step id when only one step exists', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Step 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1']);
|
||||
});
|
||||
|
||||
it('should return root step ids (steps not referenced by other steps)', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Root Step',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-2'],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Child Step',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1']);
|
||||
});
|
||||
|
||||
it('should return multiple root steps when there are parallel branches', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Root 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-3'],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Root 2',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['step-3'],
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
name: 'Child',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1', 'step-2']);
|
||||
});
|
||||
|
||||
it('should handle steps with undefined nextStepIds', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Step 1',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: undefined,
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
name: 'Step 2',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['step-1', 'step-2']);
|
||||
});
|
||||
|
||||
it('should handle complex workflow with multiple levels', () => {
|
||||
const steps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
type: 'CREATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-1-a', 'level-1-b'],
|
||||
},
|
||||
{
|
||||
id: 'level-1-a',
|
||||
name: 'Level 1 A',
|
||||
type: 'UPDATE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-2'],
|
||||
},
|
||||
{
|
||||
id: 'level-1-b',
|
||||
name: 'Level 1 B',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
nextStepIds: ['level-2'],
|
||||
},
|
||||
{
|
||||
id: 'level-2',
|
||||
name: 'Level 2',
|
||||
type: 'DELETE_RECORD',
|
||||
valid: true,
|
||||
settings: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = getRootStepIds(steps);
|
||||
|
||||
expect(result).toEqual(['root']);
|
||||
});
|
||||
});
|
||||
+156
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -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;
|
||||
};
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+482
@@ -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);
|
||||
});
|
||||
});
|
||||
+196
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+119
@@ -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');
|
||||
});
|
||||
});
|
||||
+232
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
+370
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+309
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+221
@@ -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);
|
||||
};
|
||||
+92
@@ -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;
|
||||
}
|
||||
};
|
||||
+32
@@ -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',
|
||||
},
|
||||
};
|
||||
};
|
||||
+49
@@ -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;
|
||||
};
|
||||
+186
@@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
+148
@@ -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(() => {
|
||||
|
||||
+6
-9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user