Continue workflow folders re-ordering (#9315)

- put actions in steps
- move hooks
- move states
This commit is contained in:
Thomas Trompette
2025-01-02 13:23:07 +01:00
committed by GitHub
parent e3f7a0572e
commit 5da744ebc5
52 changed files with 86 additions and 80 deletions

View File

@ -0,0 +1,16 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { RightDrawerWorkflowEditStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStepContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStep = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowEditStepContent workflow={workflow} />;
};

View File

@ -0,0 +1,34 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStepContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateStep({
workflow,
});
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflow.currentVersion}
onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger}
/>
);
};

View File

@ -0,0 +1,22 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { RightDrawerWorkflowViewStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStepContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStep = () => {
const workflowVersionId = useRecoilValue(workflowVersionIdState);
if (!isDefined(workflowVersionId)) {
throw new Error('Expected a workflow version id');
}
const workflowVersion = useWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return (
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} />
);
};

View File

@ -0,0 +1,26 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStepContent = ({
workflowVersion,
}: {
workflowVersion: WorkflowVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflowVersion}
readonly
/>
);
};

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
const StyledWorkflowStepBody = styled.div`
display: flex;
flex-direction: column;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(6)};
row-gap: ${({ theme }) => theme.spacing(6)};
flex: 1 1 auto;
`;
export { StyledWorkflowStepBody as WorkflowStepBody };

View File

@ -0,0 +1,143 @@
import {
WorkflowAction,
WorkflowTrigger,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
const WorkflowEditActionFormServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction'
).then((module) => ({
default: module.WorkflowEditActionFormServerlessFunction,
})),
);
type WorkflowStepDetailProps =
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly: true;
onTriggerUpdate?: undefined;
onActionUpdate?: undefined;
}
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onActionUpdate: (action: WorkflowAction) => void;
};
export const WorkflowStepDetail = ({
stepId,
workflowVersion,
...props
}: WorkflowStepDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
workflowVersion,
});
if (!isDefined(stepDefinition)) {
return null;
}
switch (stepDefinition.type) {
case 'trigger': {
if (!isDefined(stepDefinition.definition)) {
throw new Error(
'Expected the trigger to be defined at this point. Ensure the trigger has been set with a default value before trying to edit it.',
);
}
switch (stepDefinition.definition.type) {
case 'DATABASE_EVENT': {
return (
<WorkflowEditTriggerDatabaseEventForm
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
case 'MANUAL': {
return (
<WorkflowEditTriggerManualForm
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
}
return assertUnreachable(
stepDefinition.definition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<Suspense fallback={<RightDrawerSkeletonLoader />}>
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
actionOptions={props}
/>
</Suspense>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'CREATE_RECORD': {
return (
<WorkflowEditActionFormCreateRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'UPDATE_RECORD': {
return (
<WorkflowEditActionFormUpdateRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'DELETE_RECORD': {
return (
<WorkflowEditActionFormDeleteRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
}
return null;
}
}
return assertUnreachable(
stepDefinition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
};

View File

@ -0,0 +1,94 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import styled from '@emotion/styled';
import React, { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { IconComponent } from 'packages/twenty-ui';
import { useTheme } from '@emotion/react';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: row;
padding: ${({ theme }) => theme.spacing(4)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
width: 420px;
overflow: hidden;
`;
const StyledHeaderType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowStepHeader = ({
onTitleChange,
Icon,
iconColor,
initialTitle,
headerType,
}: {
onTitleChange: (newTitle: string) => void;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
}) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
const debouncedOnTitleChange = useDebouncedCallback(onTitleChange, 100);
const handleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedOnTitleChange(newTitle);
};
return (
<StyledHeader>
<StyledHeaderIconContainer>
{
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
}
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
);
};

View File

@ -0,0 +1,61 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { renderHook } from '@testing-library/react';
import { useCreateStep } from '../useCreateStep';
const mockOpenRightDrawer = jest.fn();
const mockCreateNewWorkflowVersion = jest.fn();
const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: { createWorkflowVersionStep: { id: '1' } },
});
jest.mock('recoil', () => ({
useRecoilValue: () => 'parent-step-id',
useSetRecoilState: () => jest.fn(),
atom: (params: any) => params,
}));
jest.mock('@/ui/layout/right-drawer/hooks/useRightDrawer', () => ({
useRightDrawer: () => ({
openRightDrawer: mockOpenRightDrawer,
}),
}));
jest.mock(
'@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep',
() => ({
useCreateWorkflowVersionStep: () => ({
createWorkflowVersionStep: mockCreateWorkflowVersionStep,
}),
}),
);
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useCreateStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
it('should create step in draft version', async () => {
const { result } = renderHook(() =>
useCreateStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
);
await result.current.createStep('CODE');
expect(mockCreateWorkflowVersionStep).toHaveBeenCalled();
expect(mockOpenRightDrawer).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,62 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { renderHook } from '@testing-library/react';
const mockCloseRightDrawer = jest.fn();
const mockCreateNewWorkflowVersion = jest.fn();
const mockDeleteWorkflowVersionStep = jest.fn();
const updateOneRecordMock = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: updateOneRecordMock,
}),
}));
jest.mock('recoil', () => ({
useRecoilValue: () => 'parent-step-id',
useSetRecoilState: () => jest.fn(),
atom: (params: any) => params,
}));
jest.mock('@/ui/layout/right-drawer/hooks/useRightDrawer', () => ({
useRightDrawer: () => ({
closeRightDrawer: mockCloseRightDrawer,
}),
}));
jest.mock('@/workflow/hooks/useDeleteWorkflowVersionStep', () => ({
useDeleteWorkflowVersionStep: () => ({
deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep,
}),
}));
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useDeleteStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
it('should delete step in draft version', async () => {
const { result } = renderHook(() =>
useDeleteStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
);
await result.current.deleteStep('1');
expect(mockDeleteWorkflowVersionStep).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,72 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { renderHook } from '@testing-library/react';
const mockCreateNewWorkflowVersion = jest.fn();
const mockUpdateWorkflowVersionStep = jest.fn();
jest.mock('recoil', () => ({
useRecoilValue: () => 'parent-step-id',
useSetRecoilState: () => jest.fn(),
atom: (params: any) => params,
}));
jest.mock(
'@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep',
() => ({
useUpdateWorkflowVersionStep: () => ({
updateWorkflowVersionStep: mockUpdateWorkflowVersionStep,
}),
}),
);
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useUpdateStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
it('should update step in draft version', async () => {
const { result } = renderHook(() =>
useUpdateStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
);
await result.current.updateStep({
id: '1',
name: 'name',
valid: true,
type: 'CODE',
settings: {
input: {
serverlessFunctionId: 'id',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: true,
},
continueOnFailure: {
value: true,
},
},
},
});
expect(mockUpdateWorkflowVersionStep).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,59 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
import {
WorkflowStepType,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdState';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useCreateStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { openRightDrawer } = useRightDrawer();
const { createWorkflowVersionStep } = useCreateWorkflowVersionStep();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const setWorkflowLastCreatedStepId = useSetRecoilState(
workflowLastCreatedStepIdState,
);
const workflowCreateStepFromParentStepId = useRecoilValue(
workflowCreateStepFromParentStepIdState,
);
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const createStep = async (newStepType: WorkflowStepType) => {
if (!isDefined(workflowCreateStepFromParentStepId)) {
throw new Error('Select a step to create a new step from first.');
}
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
const createdStep = (
await createWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
stepType: newStepType,
})
)?.data?.createWorkflowVersionStep;
if (!createdStep) {
return;
}
setWorkflowSelectedNode(createdStep.id);
setWorkflowLastCreatedStepId(createdStep.id);
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
};
return {
createStep,
};
};

View File

@ -0,0 +1,64 @@
import { useApolloClient, useMutation } from '@apollo/client';
import {
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables,
CreateWorkflowVersionStepInput,
} from '~/generated/graphql';
import { CREATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/createWorkflowVersionStep';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-ui';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useCreateWorkflowVersionStep = () => {
const apolloClient = useApolloClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables
>(CREATE_WORKFLOW_VERSION_STEP, {
client: apolloClient,
});
const createWorkflowVersionStep = async (
input: CreateWorkflowVersionStepInput,
) => {
const result = await mutate({
variables: { input },
});
const createdStep = result?.data?.createWorkflowVersionStep;
if (!isDefined(createdStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!cachedRecord) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: [...(cachedRecord.steps || []), createdStep],
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: newCachedRecord,
});
return result;
};
return { createWorkflowVersionStep };
};

View File

@ -0,0 +1,47 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useDeleteWorkflowVersionStep } from '@/workflow/hooks/useDeleteWorkflowVersionStep';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
export const useDeleteStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep();
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { closeRightDrawer } = useRightDrawer();
const deleteStep = async (stepId: string) => {
closeRightDrawer();
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
if (stepId === TRIGGER_STEP_ID) {
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
trigger: null,
},
});
return;
}
await deleteWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
stepId,
});
};
return {
deleteStep,
};
};

View File

@ -0,0 +1,32 @@
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import {
WorkflowStep,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { isDefined } from 'twenty-ui';
export const useUpdateStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
await updateWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
step: updatedStep,
});
};
return {
updateStep,
};
};

View File

@ -0,0 +1,69 @@
import { useApolloClient, useMutation } from '@apollo/client';
import {
UpdateWorkflowVersionStepInput,
UpdateWorkflowVersionStepMutation,
UpdateWorkflowVersionStepMutationVariables,
WorkflowAction,
} from '~/generated/graphql';
import { UPDATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/updateWorkflowVersionStep';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-ui';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useUpdateWorkflowVersionStep = () => {
const apolloClient = useApolloClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
UpdateWorkflowVersionStepMutation,
UpdateWorkflowVersionStepMutationVariables
>(UPDATE_WORKFLOW_VERSION_STEP, {
client: apolloClient,
});
const updateWorkflowVersionStep = async (
input: UpdateWorkflowVersionStepInput,
) => {
const result = await mutate({ variables: { input } });
const updatedStep = result?.data?.updateWorkflowVersionStep;
if (!isDefined(updatedStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!cachedRecord) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: (cachedRecord.steps || []).map((step: WorkflowAction) => {
if (step.id === updatedStep.id) {
return updatedStep;
}
return step;
}),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: newCachedRecord,
});
return result;
};
return { updateWorkflowVersionStep };
};

View File

@ -0,0 +1,8 @@
import { createState } from '@ui/utilities/state/utils/createState';
export const workflowCreateStepFromParentStepIdState = createState<
string | undefined
>({
key: 'workflowCreateStepFromParentStepId',
defaultValue: undefined,
});

View File

@ -0,0 +1,16 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { RightDrawerWorkflowSelectActionContent } from '@/workflow/workflow-steps/workflow-actions/components/RightDrawerWorkflowSelectActionContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectAction = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowSelectActionContent workflow={workflow} />;
};

View File

@ -0,0 +1,40 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/Actions';
import styled from '@emotion/styled';
import { MenuItem } from 'twenty-ui';
const StyledActionListContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
export const RightDrawerWorkflowSelectActionContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { createStep } = useCreateStep({
workflow,
});
return (
<StyledActionListContainer>
{ACTIONS.map((action) => (
<MenuItem
key={action.type}
LeftIcon={action.icon}
text={action.label}
onClick={() => {
return createStep(action.type);
}}
/>
))}
</StyledActionListContainer>
);
};

View File

@ -0,0 +1,190 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
type WorkflowEditActionFormCreateRecordProps = {
action: WorkflowCreateRecordAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowCreateRecordAction) => void;
};
};
type CreateRecordFormData = {
objectName: string;
[field: string]: unknown;
};
export const WorkflowEditActionFormCreateRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormCreateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<CreateRecordFormData>({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly;
const objectNameSingular = formData.objectName;
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const inlineFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.Relation &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = inlineFieldMetadataItems.map(
(fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
);
const handleFieldChange = (
fieldName: keyof CreateRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: CreateRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const saveAction = useDebouncedCallback(
async (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
{inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.metadata.fieldName] as JsonValue;
return (
<FormFieldInput
key={field.metadata.fieldName}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,166 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormDeleteRecordProps = {
action: WorkflowDeleteRecordAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowDeleteRecordAction) => void;
};
};
type DeleteRecordFormData = {
objectName: string;
objectRecordId: string;
};
export const WorkflowEditActionFormDeleteRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormDeleteRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<DeleteRecordFormData>({
objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId,
});
const isFormDisabled = actionOptions.readonly;
const handleFieldChange = (
fieldName: keyof DeleteRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: DeleteRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const saveAction = useDebouncedCallback(
async (formData: DeleteRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId,
} = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '',
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,244 @@
import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormSendEmailProps = {
action: WorkflowSendEmailAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowSendEmailAction) => void;
};
};
type SendEmailFormData = {
connectedAccountId: string;
email: string;
subject: string;
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
action,
actionOptions,
}: WorkflowEditActionFormSendEmailProps) => {
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth();
const workflowId = useRecoilValue(workflowIdState);
const redirectUrl = `/object/workflow/${workflowId}`;
const [formData, setFormData] = useState<SendEmailFormData>({
connectedAccountId: action.settings.input.connectedAccountId,
email: action.settings.input.email,
subject: action.settings.input.subject ?? '',
body: action.settings.input.body ?? '',
});
const checkConnectedAccountScopes = async (
connectedAccountId: string | null,
) => {
const connectedAccount = accounts.find(
(account) => account.id === connectedAccountId,
);
if (!isDefined(connectedAccount)) {
return;
}
const scopes = connectedAccount.scopes;
if (
!isDefined(scopes) ||
!isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE))
) {
await triggerApisOAuth('google', {
redirectLocation: redirectUrl,
loginHint: connectedAccount.handle,
});
}
};
const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData, checkScopes = false) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
connectedAccountId: formData.connectedAccountId,
email: formData.email,
subject: formData.subject,
body: formData.body,
},
},
});
if (checkScopes === true) {
await checkConnectedAccountScopes(formData.connectedAccountId);
}
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleFieldChange = (
fieldName: keyof SendEmailFormData,
updatedValue: JsonValue,
) => {
const newFormData: SendEmailFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const filter: { or: object[] } = {
or: [
{
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
],
};
if (
isDefined(action.settings.input.connectedAccountId) &&
action.settings.input.connectedAccountId !== ''
) {
filter.or.push({
id: {
eq: action.settings.input.connectedAccountId,
},
});
}
const { records: accounts, loading } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: 'connectedAccount',
filter,
});
let emptyOption: SelectOption<string | null> = { label: 'None', value: null };
const connectedAccountOptions: SelectOption<string | null>[] = [];
accounts.forEach((account) => {
const selectOption = {
label: account.handle,
value: account.id,
};
if (account.accountOwnerId === currentWorkspaceMember?.id) {
connectedAccountOptions.push(selectOption);
} else {
// This handle the case when the current connected account does not belong to the currentWorkspaceMember
// In that case, current connected account email is displayed, but cannot be selected
emptyOption = selectOption;
}
});
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
return (
!loading && (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
/>
<WorkflowStepBody>
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={formData.connectedAccountId}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', {
redirectLocation: redirectUrl,
}),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
handleFieldChange('connectedAccountId', connectedAccountId);
}}
disabled={actionOptions.readonly}
/>
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={formData.email}
onPersist={(email) => {
handleFieldChange('email', email);
}}
VariablePicker={WorkflowVariablePicker}
/>
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={formData.subject}
onPersist={(subject) => {
handleFieldChange('subject', subject);
}}
VariablePicker={WorkflowVariablePicker}
/>
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={formData.body}
onPersist={(body) => {
handleFieldChange('body', body);
}}
VariablePicker={WorkflowVariablePicker}
multiline
/>
</WorkflowStepBody>
</>
)
);
};

View File

@ -0,0 +1,339 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/utils/setNestedValue';
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { useEffect, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { CodeEditor, IconCode, IconPlayerPlay, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTabListContainer = styled.div`
align-items: center;
padding-left: ${({ theme }) => theme.spacing(2)};
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(10)};
`;
type WorkflowEditActionFormServerlessFunctionProps = {
action: WorkflowCodeAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowCodeAction) => void;
};
};
type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
export const WorkflowEditActionFormServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const theme = useTheme();
const { activeTabId, setActiveTabId } = useTabList(
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
);
const { updateOneServerlessFunction, isReady } =
useUpdateOneServerlessFunction(serverlessFunctionId);
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const { availablePackages } = useGetAvailablePackages({
id: serverlessFunctionId,
});
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>(
action.settings.input.serverlessFunctionInput,
);
const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const updateOutputSchemaFromTestResult = async (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getFunctionOutputSchema(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const { testServerlessFunction } = useTestServerlessFunction(
serverlessFunctionId,
updateOutputSchemaFromTestResult,
);
const handleSave = useDebouncedCallback(async () => {
await updateOneServerlessFunction({
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
}, 500);
const onCodeChange = async (newCode: string) => {
if (actionOptions.readonly === true) {
return;
}
setFormValues((prevState) => ({
...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
}));
await handleSave();
await handleUpdateFunctionInputSchema(newCode);
};
const handleUpdateFunctionInputSchema = useDebouncedCallback(
async (sourceCode: string) => {
if (actionOptions.readonly === true) {
return;
}
if (!isDefined(sourceCode)) {
return;
}
const newFunctionInput = getFunctionInputFromSourceCode(sourceCode);
const newMergedInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: action.settings.input.serverlessFunctionInput,
});
const newMergedTestInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: serverlessFunctionTestData.input,
});
setFunctionInput(newMergedInput);
setServerlessFunctionTestData((prev) => ({
...prev,
input: newMergedTestInput,
}));
updateAction({
...action,
settings: {
...action.settings,
outputSchema: {
link: {
isLeaf: true,
icon: 'IconVariable',
tab: 'test',
label: 'Generate Function Input',
},
_outputSchemaType: 'LINK',
},
input: {
...action.settings.input,
serverlessFunctionInput: newMergedInput,
},
},
});
},
500,
);
const handleInputChange = async (value: any, path: string[]) => {
const updatedFunctionInput = setNestedValue(functionInput, path, value);
setFunctionInput(updatedFunctionInput);
updateAction({
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: updatedFunctionInput,
},
},
});
};
const handleTestInputChange = async (value: any, path: string[]) => {
const updatedTestFunctionInput = setNestedValue(
serverlessFunctionTestData.input,
path,
value,
);
setServerlessFunctionTestData((prev) => ({
...prev,
input: updatedTestFunctionInput,
}));
};
const handleRunFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
};
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
await AutoTypings.create(editor, {
monaco,
preloadPackages: true,
onlySpecifiedPackages: true,
versions: availablePackages,
debounceDuration: 0,
});
};
const updateAction = useDebouncedCallback(
(actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
...actionUpdate,
});
},
500,
);
const handleCodeChange = async (value: string) => {
if (actionOptions.readonly === true || !isDefined(workflow)) {
return;
}
await getUpdatableWorkflowVersion(workflow);
await onCodeChange(value);
};
const tabs = [
{ id: 'code', title: 'Code', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconPlayerPlay },
];
useEffect(() => {
setFunctionInput(action.settings.input.serverlessFunctionInput);
}, [action]);
return (
!loading && (
<StyledContainer>
<StyledTabListContainer>
<TabList
tabListInstanceId={
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID
}
tabs={tabs}
behaveAsLinks={false}
/>
</StyledTabListContainer>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
updateAction({ name: newName });
}}
Icon={IconCode}
iconColor={theme.color.orange}
initialTitle={action.name || 'Code - Serverless Function'}
headerType="Code"
/>
<WorkflowStepBody>
{activeTabId === 'code' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Code {!isReady && <span></span>}</InputLabel>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onChange={handleCodeChange}
onMount={handleEditorDidMount}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
}}
/>
</StyledCodeEditorContainer>
</>
)}
{activeTabId === 'test' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={serverlessFunctionTestData.input}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Result</InputLabel>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
/>
</StyledCodeEditorContainer>
</>
)}
</WorkflowStepBody>
{activeTabId === 'test' && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton title="Test" onClick={handleRunFunction} />,
]}
/>
)}
</StyledContainer>
)
);
};

View File

@ -0,0 +1,85 @@
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { FunctionInput } from '@/workflow/workflow-steps/workflow-actions/types/FunctionInput';
import styled from '@emotion/styled';
import { isObject } from '@sniptt/guards';
import { ReactNode } from 'react';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
export const WorkflowEditActionFormServerlessFunctionFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}) => {
const renderFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}): ReactNode[] => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && isObject(inputValue)) {
return (
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
{renderFields({
functionInput: inputValue,
path: currentPath,
VariablePicker,
onInputChange,
})}
</FormNestedFieldInputContainer>
</StyledContainer>
);
} else {
return (
<FormTextFieldInput
key={pathKey}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={readonly}
onPersist={(value) => onInputChange(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
});
};
return (
<>
{renderFields({
functionInput,
path,
VariablePicker,
onInputChange,
readonly,
})}
</>
);
};

View File

@ -0,0 +1,257 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowUpdateRecordAction) => void;
};
};
type UpdateRecordFormData = {
objectName: string;
objectRecordId: string;
fieldsToUpdate: string[];
[field: string]: unknown;
};
const AVAILABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.Text,
FieldMetadataType.Number,
FieldMetadataType.Date,
FieldMetadataType.Boolean,
FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Emails,
FieldMetadataType.Links,
FieldMetadataType.FullName,
FieldMetadataType.Address,
];
export const WorkflowEditActionFormUpdateRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormUpdateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<UpdateRecordFormData>({
objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId,
fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [],
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly;
const handleFieldChange = (
fieldName: keyof UpdateRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: UpdateRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const inlineFieldMetadataItems = selectedObjectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive &&
AVAILABLE_FIELD_METADATA_TYPES.includes(fieldMetadataItem.type),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = inlineFieldMetadataItems.map(
(fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem: selectedObjectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
);
const saveAction = useDebouncedCallback(
async (formData: UpdateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId,
fieldsToUpdate: updatedFieldsToUpdate,
...updatedOtherFields
} = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '',
objectRecord: updatedOtherFields,
fieldsToUpdate: updatedFieldsToUpdate ?? [],
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-update-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName,
objectRecordId: '',
fieldsToUpdate: [],
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
<FormMultiSelectFieldInput
label="Fields to update"
defaultValue={formData.fieldsToUpdate}
options={inlineFieldDefinitions.map((field) => ({
label: field.label,
value: field.metadata.fieldName,
icon: getIcon(field.iconName),
color: 'gray',
}))}
onPersist={(fieldsToUpdate) =>
handleFieldChange('fieldsToUpdate', fieldsToUpdate)
}
/>
<HorizontalSeparator noMargin />
{formData.fieldsToUpdate.map((fieldName) => {
const fieldDefinition = inlineFieldDefinitions.find(
(definition) => definition.metadata.fieldName === fieldName,
);
if (!isDefined(fieldDefinition)) {
return null;
}
const currentValue = formData[
fieldDefinition.metadata.fieldName
] as JsonValue;
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onPersist={(value) => {
handleFieldChange(fieldDefinition.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,68 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import {
RecordId,
Variable,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
import styled from '@emotion/styled';
const StyledRecordChip = styled(RecordChip)`
margin: ${({ theme }) => theme.spacing(2)};
`;
const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
margin: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowSingleRecordFieldChipProps = {
draftValue:
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
};
export const WorkflowSingleRecordFieldChip = ({
draftValue,
selectedRecord,
objectNameSingular,
onRemove,
}: WorkflowSingleRecordFieldChipProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
if (
!!draftValue &&
draftValue.type === 'variable' &&
isStandaloneVariableString(draftValue.value)
) {
return (
<VariableChip
rawVariableName={objectMetadataItem.labelSingular}
onRemove={onRemove}
/>
);
}
if (!!draftValue && draftValue.type === 'static' && !!selectedRecord) {
return (
<StyledRecordChip
record={selectedRecord}
objectNameSingular={objectNameSingular}
/>
);
}
return <StyledPlaceholder>Select a {objectNameSingular}</StyledPlaceholder>;
};

View File

@ -0,0 +1,199 @@
import {
IconChevronDown,
IconForbid,
isDefined,
LightIconButton,
} from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { RecordForSelect } from '@/object-record/relation-picker/types/RecordForSelect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { WorkflowSingleRecordFieldChip } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordFieldChip';
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { isValidUuid } from '~/utils/isValidUuid';
const StyledFormSelectContainer = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
border-bottom-right-radius: none;
border-top-right-radius: none;
box-sizing: border-box;
display: flex;
overflow: 'hidden';
width: 100%;
justify-content: space-between;
align-items: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledSearchVariablesDropdownContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
${({ theme }) => css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme }) => css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export type RecordId = string;
export type Variable = string;
type WorkflowSingleRecordPickerValue =
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
export type WorkflowSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
onChange: (value: RecordId | Variable) => void;
objectNameSingular: string;
};
export const WorkflowSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
onChange,
}: WorkflowSingleRecordPickerProps) => {
const draftValue: WorkflowSingleRecordPickerValue =
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
};
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular,
withSoftDeleted: true,
skip: !isValidUuid(defaultValue),
});
const dropdownId = `workflow-record-picker-${objectNameSingular}`;
const variablesDropdownId = `workflow-record-picker-${objectNameSingular}-variables`;
const { closeDropdown } = useDropdown(dropdownId);
const { setRecordPickerSearchFilter } = useRecordPicker({
recordPickerInstanceId: dropdownId,
});
const handleCloseRelationPickerDropdown = useCallback(() => {
setRecordPickerSearchFilter('');
}, [setRecordPickerSearchFilter]);
const handleRecordSelected = (
selectedEntity: RecordForSelect | null | undefined,
) => {
onChange?.(selectedEntity?.record?.id ?? '');
closeDropdown();
};
const handleVariableTagInsert = (variable: string) => {
onChange?.(variable);
closeDropdown();
};
const handleUnlinkVariable = () => {
closeDropdown();
onChange('');
};
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
/>
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconChevronDown}
accent="tertiary"
/>
}
dropdownComponents={
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<SingleRecordSelect
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
selectedRecordIds={
draftValue?.value &&
!isStandaloneVariableString(draftValue.value)
? [draftValue.value]
: []
}
/>
</RecordPickerComponentInstanceContext.Provider>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledFormSelectContainer>
<StyledSearchVariablesDropdownContainer>
<WorkflowVariablesDropdown
inputId={variablesDropdownId}
onVariableSelect={handleVariableTagInsert}
disabled={false}
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,38 @@
import { WorkflowStepType } from '@/workflow/types/Workflow';
import {
IconAddressBook,
IconComponent,
IconSettingsAutomation,
} from 'twenty-ui';
export const ACTIONS: Array<{
label: string;
type: WorkflowStepType;
icon: IconComponent;
}> = [
{
label: 'Serverless Function',
type: 'CODE',
icon: IconSettingsAutomation,
},
{
label: 'Send Email',
type: 'SEND_EMAIL',
icon: IconSettingsAutomation,
},
{
label: 'Create Record',
type: 'CREATE_RECORD',
icon: IconAddressBook,
},
{
label: 'Update Record',
type: 'UPDATE_RECORD',
icon: IconAddressBook,
},
{
label: 'Delete Record',
type: 'DELETE_RECORD',
icon: IconAddressBook,
},
];

View File

@ -0,0 +1,2 @@
export const WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID =
'workflow-serverless-function-tab-list-component-id';

View File

@ -0,0 +1,5 @@
export type FunctionInput =
| {
[name: string]: FunctionInput;
}
| any;

View File

@ -0,0 +1,26 @@
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/utils/setNestedValue';
describe('setNestedValue', () => {
it('should set nested value properly', () => {
const obj = { a: { b: 'b' } };
const path = ['a', 'b'];
const newValue = 'bb';
const expectedResult = { a: { b: newValue } };
expect(setNestedValue(obj, path, newValue)).toEqual(expectedResult);
});
it('should not mutate the initial object', () => {
const expectedObject = { a: { b: 'b' } };
const initialObject = structuredClone(expectedObject);
const path = ['a', 'b'];
const newValue = 'bb';
const updatedObject = setNestedValue(initialObject, path, newValue);
expect(initialObject).toEqual(expectedObject);
expect(updatedObject).not.toBe(initialObject);
expect(updatedObject.a).not.toBe(initialObject.a);
});
});

View File

@ -0,0 +1,10 @@
export const setNestedValue = (obj: any, path: string[], value: any) => {
const newObj = structuredClone(obj);
path.reduce((o, key, index) => {
if (index === path.length - 1) {
o[key] = value;
}
return o[key] || {};
}, newObj);
return newObj;
};