Continue workflow folders re-ordering (#9315)
- put actions in steps - move hooks - move states
This commit is contained in:
@ -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} />;
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
@ -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)}`,
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { createState } from '@ui/utilities/state/utils/createState';
|
||||
|
||||
export const workflowCreateStepFromParentStepIdState = createState<
|
||||
string | undefined
|
||||
>({
|
||||
key: 'workflowCreateStepFromParentStepId',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,2 @@
|
||||
export const WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID =
|
||||
'workflow-serverless-function-tab-list-component-id';
|
||||
@ -0,0 +1,5 @@
|
||||
export type FunctionInput =
|
||||
| {
|
||||
[name: string]: FunctionInput;
|
||||
}
|
||||
| any;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user