Thomas Trompette
2025-09-10 17:25:40 +02:00
committed by GitHub
parent bcf1bba302
commit 3bfb5bbd86
34 changed files with 642 additions and 118 deletions
@@ -961,6 +961,11 @@ export enum DistantTableUpdate {
TABLE_DELETED = 'TABLE_DELETED'
}
export type DuplicateWorkflowVersionStepInput = {
stepId: Scalars['String'];
workflowVersionId: Scalars['String'];
};
export type EditSsoInput = {
id: Scalars['UUID'];
status: SsoIdentityProviderStatus;
@@ -1474,6 +1479,7 @@ export type Mutation = {
destroyPageLayoutTab: Scalars['Boolean'];
destroyPageLayoutWidget: Scalars['Boolean'];
disablePostgresProxy: PostgresCredentials;
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
editSSOIdentityProvider: EditSsoOutput;
emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials;
@@ -1918,6 +1924,11 @@ export type MutationDestroyPageLayoutWidgetArgs = {
};
export type MutationDuplicateWorkflowVersionStepArgs = {
input: DuplicateWorkflowVersionStepInput;
};
export type MutationEditSsoIdentityProviderArgs = {
input: EditSsoInput;
};
@@ -5289,6 +5300,13 @@ export type DeleteWorkflowVersionStepMutationVariables = Exact<{
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, deletedStepIds?: Array<string> | null } };
export type DuplicateWorkflowVersionStepMutationVariables = Exact<{
input: DuplicateWorkflowVersionStepInput;
}>;
export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<string> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } };
export type RunWorkflowVersionMutationVariables = Exact<{
input: RunWorkflowVersionInput;
}>;
@@ -12063,6 +12081,52 @@ export function useDeleteWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
export type DeleteWorkflowVersionStepMutationHookResult = ReturnType<typeof useDeleteWorkflowVersionStepMutation>;
export type DeleteWorkflowVersionStepMutationResult = Apollo.MutationResult<DeleteWorkflowVersionStepMutation>;
export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables>;
export const DuplicateWorkflowVersionStepDocument = gql`
mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) {
duplicateWorkflowVersionStep(input: $input) {
triggerNextStepIds
stepsNextStepIds
createdStep {
id
name
type
settings
valid
nextStepIds
position {
x
y
}
}
}
}
`;
export type DuplicateWorkflowVersionStepMutationFn = Apollo.MutationFunction<DuplicateWorkflowVersionStepMutation, DuplicateWorkflowVersionStepMutationVariables>;
/**
* __useDuplicateWorkflowVersionStepMutation__
*
* To run a mutation, you first call `useDuplicateWorkflowVersionStepMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDuplicateWorkflowVersionStepMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [duplicateWorkflowVersionStepMutation, { data, loading, error }] = useDuplicateWorkflowVersionStepMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useDuplicateWorkflowVersionStepMutation(baseOptions?: Apollo.MutationHookOptions<DuplicateWorkflowVersionStepMutation, DuplicateWorkflowVersionStepMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DuplicateWorkflowVersionStepMutation, DuplicateWorkflowVersionStepMutationVariables>(DuplicateWorkflowVersionStepDocument, options);
}
export type DuplicateWorkflowVersionStepMutationHookResult = ReturnType<typeof useDuplicateWorkflowVersionStepMutation>;
export type DuplicateWorkflowVersionStepMutationResult = Apollo.MutationResult<DuplicateWorkflowVersionStepMutation>;
export type DuplicateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<DuplicateWorkflowVersionStepMutation, DuplicateWorkflowVersionStepMutationVariables>;
export const RunWorkflowVersionDocument = gql`
mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) {
runWorkflowVersion(input: $input) {
@@ -925,6 +925,11 @@ export enum DistantTableUpdate {
TABLE_DELETED = 'TABLE_DELETED'
}
export type DuplicateWorkflowVersionStepInput = {
stepId: Scalars['String'];
workflowVersionId: Scalars['String'];
};
export type EditSsoInput = {
id: Scalars['UUID'];
status: SsoIdentityProviderStatus;
@@ -1429,6 +1434,7 @@ export type Mutation = {
destroyPageLayoutTab: Scalars['Boolean'];
destroyPageLayoutWidget: Scalars['Boolean'];
disablePostgresProxy: PostgresCredentials;
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
editSSOIdentityProvider: EditSsoOutput;
emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials;
@@ -1849,6 +1855,11 @@ export type MutationDestroyPageLayoutWidgetArgs = {
};
export type MutationDuplicateWorkflowVersionStepArgs = {
input: DuplicateWorkflowVersionStepInput;
};
export type MutationEditSsoIdentityProviderArgs = {
input: EditSsoInput;
};
@@ -1,17 +1,10 @@
import { CommandMenuWorkflowSelectActionContent } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent';
import { useCommandMenuWorkflowIdOrThrow } from '@/command-menu/pages/workflow/hooks/useCommandMenuWorkflowIdOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuWorkflowSelectAction = () => {
const workflowId = useCommandMenuWorkflowIdOrThrow();
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return (
<WorkflowVisualizerComponentInstanceContext.Provider
@@ -21,7 +14,7 @@ export const CommandMenuWorkflowSelectAction = () => {
}),
}}
>
<CommandMenuWorkflowSelectActionContent workflow={workflow} />
<CommandMenuWorkflowSelectActionContent />
</WorkflowVisualizerComponentInstanceContext.Provider>
);
};
@@ -4,10 +4,7 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import {
type WorkflowActionType,
type WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { type WorkflowActionType } from '@/workflow/types/Workflow';
import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu';
import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer';
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
@@ -26,16 +23,10 @@ import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated/graphql';
export const CommandMenuWorkflowSelectActionContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
export const CommandMenuWorkflowSelectActionContent = () => {
const { getIcon } = useIcons();
const { createStep } = useCreateStep({
workflow,
});
const { createStep } = useCreateStep();
const { closeRightClickMenu } = useCloseRightClickMenu();
@@ -0,0 +1,24 @@
import { gql } from '@apollo/client';
export const DUPLICATE_WORKFLOW_VERSION_STEP = gql`
mutation DuplicateWorkflowVersionStep(
$input: DuplicateWorkflowVersionStepInput!
) {
duplicateWorkflowVersionStep(input: $input) {
triggerNextStepIds
stepsNextStepIds
createdStep {
id
name
type
settings
valid
nextStepIds
position {
x
y
}
}
}
}
`;
@@ -1,8 +1,5 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
@@ -53,12 +50,7 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
targetY,
});
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
const { createStep } = useCreateStep({ workflow });
const { createStep } = useCreateStep();
const { deleteEdge } = useDeleteEdge();
@@ -30,7 +30,6 @@ export const WorkflowDiagramStepNodeEditable = ({
);
const { deleteStep } = useDeleteStep();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
@@ -0,0 +1,71 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep';
import { useTheme } from '@emotion/react';
import { useId } from 'react';
import { Button } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { getOsControlSymbol } from 'twenty-ui/utilities';
export const WorkflowActionFooter = ({
stepId,
additionalActions,
}: {
stepId: string;
additionalActions?: React.ReactNode[];
}) => {
const dropdownId = useId();
const theme = useTheme();
const { duplicateStep } = useDuplicateStep();
const { closeDropdown } = useCloseDropdown();
const OptionsDropdown = () => {
return (
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title="Options" hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={['duplicate']}
>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
duplicateStep({ stepId });
}}
text="Duplicate"
/>
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
return (
<RightDrawerFooter
actions={[
<OptionsDropdown key="options" />,
...(additionalActions ?? []),
]}
/>
);
};
@@ -1,4 +1,3 @@
import { type WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
@@ -44,17 +43,6 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
};
describe('useCreateStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
beforeEach(() => {
jest.clearAllMocks();
});
@@ -63,15 +51,9 @@ describe('useCreateStep', () => {
const mockWorkflowVersionId = 'version-123';
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const { result } = renderHook(
() =>
useCreateStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
{
wrapper,
},
);
const { result } = renderHook(() => useCreateStep(), {
wrapper,
});
await act(async () => {
await result.current.createStep({
@@ -6,11 +6,14 @@ const mockGetUpdatableWorkflowVersion = jest.fn();
const mockDeleteStepsOutputSchema = jest.fn();
const mockCloseCommandMenu = jest.fn();
jest.mock('@/workflow/hooks/useDeleteWorkflowVersionStep', () => ({
useDeleteWorkflowVersionStep: () => ({
deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep,
jest.mock(
'@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep',
() => ({
useDeleteWorkflowVersionStep: () => ({
deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep,
}),
}),
}));
);
jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
useGetUpdatableWorkflowVersionOrThrow: () => ({
@@ -0,0 +1,70 @@
import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockDuplicateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: {
duplicateWorkflowVersionStep: { createdStep: { id: '2', type: 'CODE' } },
},
});
jest.mock(
'@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep',
() => ({
useDuplicateWorkflowVersionStep: () => ({
duplicateWorkflowVersionStep: mockDuplicateWorkflowVersionStep,
}),
}),
);
jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
useGetUpdatableWorkflowVersionOrThrow: () => ({
getUpdatableWorkflowVersion: mockGetUpdatableWorkflowVersion,
}),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => {
const workflowVisualizerComponentInstanceId =
'workflow-visualizer-instance-id';
return (
<RecoilRoot>
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: workflowVisualizerComponentInstanceId,
}}
>
{children}
</WorkflowVisualizerComponentInstanceContext.Provider>
</RecoilRoot>
);
};
describe('useDuplicateStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create step in workflow version', async () => {
const mockWorkflowVersionId = 'version-123';
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const { result } = renderHook(() => useDuplicateStep(), {
wrapper,
});
await act(async () => {
await result.current.duplicateStep({
stepId: 'step-1',
});
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
expect(mockDuplicateWorkflowVersionStep).toHaveBeenCalledWith({
stepId: 'step-1',
workflowVersionId: mockWorkflowVersionId,
});
});
});
@@ -1,20 +1,13 @@
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState';
import {
type WorkflowStepType,
type WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { type WorkflowStepType } from '@/workflow/types/Workflow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useCreateStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion | undefined;
}) => {
export const useCreateStep = () => {
const [isLoading, setIsLoading] = useState(false);
const { createWorkflowVersionStep } = useCreateWorkflowVersionStep();
const setWorkflowSelectedNode = useSetRecoilComponentState(
@@ -27,12 +20,6 @@ export const useCreateStep = ({
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
if (!isDefined(workflow)) {
return {
createStep: async () => undefined,
};
}
const createStep = async ({
newStepType,
parentStepId,
@@ -1,7 +1,7 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useDeleteWorkflowVersionStep } from '@/workflow/hooks/useDeleteWorkflowVersionStep';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep';
export const useDeleteStep = () => {
const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep();
@@ -0,0 +1,57 @@
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useDuplicateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useDuplicateStep = () => {
const [isLoading, setIsLoading] = useState(false);
const { duplicateWorkflowVersionStep } = useDuplicateWorkflowVersionStep();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setWorkflowLastCreatedStepId = useSetRecoilComponentState(
workflowLastCreatedStepIdComponentState,
);
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const duplicateStep = async ({ stepId }: { stepId: string }) => {
if (isLoading === true) {
return;
}
setIsLoading(true);
try {
const workflowVersionId = await getUpdatableWorkflowVersion();
const workflowVersionStepChanges = (
await duplicateWorkflowVersionStep({
workflowVersionId,
stepId,
})
)?.data?.duplicateWorkflowVersionStep;
const createdStep = workflowVersionStepChanges?.createdStep;
if (!isDefined(createdStep)) {
throw new Error("Couldn't duplicate step");
}
setWorkflowSelectedNode(createdStep.id);
setWorkflowLastCreatedStepId(createdStep.id);
return createdStep;
} finally {
setIsLoading(false);
}
};
return {
duplicateStep,
};
};
@@ -0,0 +1,55 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { DUPLICATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/duplicateWorkflowVersionStep';
import { flowComponentState } from '@/workflow/states/flowComponentState';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
import { useMutation } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import {
type DuplicateWorkflowVersionStepInput,
type DuplicateWorkflowVersionStepMutation,
type DuplicateWorkflowVersionStepMutationVariables,
} from '~/generated-metadata/graphql';
export const useDuplicateWorkflowVersionStep = () => {
const apolloCoreClient = useApolloCoreClient();
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const setFlow = useSetRecoilComponentState(flowComponentState);
const [mutate] = useMutation<
DuplicateWorkflowVersionStepMutation,
DuplicateWorkflowVersionStepMutationVariables
>(DUPLICATE_WORKFLOW_VERSION_STEP, {
client: apolloCoreClient,
});
const duplicateWorkflowVersionStep = async (
input: DuplicateWorkflowVersionStepInput,
) => {
const result = await mutate({
variables: { input },
});
const workflowVersionStepChanges =
result?.data?.duplicateWorkflowVersionStep;
const updatedWorkflowVersion = updateWorkflowVersionCache({
workflowVersionStepChanges,
workflowVersionId: input.workflowVersionId,
});
if (isDefined(updatedWorkflowVersion)) {
setFlow({
workflowVersionId: updatedWorkflowVersion.id,
trigger: updatedWorkflowVersion.trigger,
steps: updatedWorkflowVersion.steps,
});
}
return result;
};
return { duplicateWorkflowVersionStep };
};
@@ -3,6 +3,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -145,6 +146,7 @@ export const WorkflowEditActionAiAgent = ({
readonly={actionOptions.readonly}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -24,7 +24,6 @@ import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctio
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextArea } from '@/ui/input/components/TextArea';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
@@ -44,6 +43,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { type Monaco } from '@monaco-editor/react';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
@@ -515,15 +515,20 @@ export const WorkflowEditActionServerlessFunction = ({
</>
)}
</WorkflowStepBody>
{activeTabId === WorkflowServerlessFunctionTabId.TEST && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton
title="Test"
onClick={handleRunFunction}
disabled={isTesting || actionOptions.readonly}
/>,
]}
{!actionOptions.readonly && (
<WorkflowActionFooter
stepId={action.id}
additionalActions={
activeTabId === WorkflowServerlessFunctionTabId.TEST
? [
<CmdEnterActionButton
title="Test"
onClick={handleRunFunction}
disabled={isTesting}
/>,
]
: []
}
/>
)}
{fullScreenOverlay}
@@ -7,6 +7,7 @@ import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews';
import { type WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -271,6 +272,7 @@ export const WorkflowEditActionCreateRecord = ({
);
})}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -6,6 +6,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo
import { useEffect, useState } from 'react';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
@@ -179,6 +180,7 @@ export const WorkflowEditActionDeleteRecord = ({
/>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -11,6 +11,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -294,6 +295,7 @@ export const WorkflowEditActionSendEmail = ({
multiline
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
)
);
@@ -9,6 +9,7 @@ import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-typ
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -265,6 +266,7 @@ export const WorkflowEditActionUpdateRecord = ({
);
})}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -13,6 +13,7 @@ import { type RecordFilter } from '@/object-record/record-filter/types/RecordFil
import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters';
import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
@@ -232,6 +233,7 @@ export const WorkflowEditActionFindRecords = ({
readonly
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -6,6 +6,7 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { type WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
@@ -398,6 +399,7 @@ export const WorkflowEditActionFormBuilder = ({
</StyledAddFieldButtonContainer>
)}
</StyledWorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -5,7 +5,9 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
@@ -56,9 +58,11 @@ const meta: Meta<typeof WorkflowEditActionFormBuilder> = {
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
RouterDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
],
};
@@ -8,10 +8,10 @@ import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-acti
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
@@ -217,15 +217,20 @@ export const WorkflowEditActionHttpRequest = ({
</StyledTestTabContent>
)}
</WorkflowStepBody>
{activeTabId === WorkflowHttpRequestTabId.TEST && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton
title="Test"
onClick={handleTestRequest}
disabled={isTesting || actionOptions.readonly}
/>,
]}
{!actionOptions.readonly && (
<WorkflowActionFooter
stepId={action.id}
additionalActions={
activeTabId === WorkflowHttpRequestTabId.TEST
? [
<CmdEnterActionButton
title="Test"
onClick={handleTestRequest}
disabled={isTesting}
/>,
]
: []
}
/>
)}
</>
@@ -3,6 +3,7 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, waitFor, within } from '@storybook/test';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
@@ -84,6 +85,7 @@ const meta: Meta<typeof WorkflowEditActionHttpRequest> = {
SnackBarDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
],
};
@@ -0,0 +1,10 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class DuplicateWorkflowVersionStepInput {
@Field(() => String)
stepId: string;
@Field(() => String)
workflowVersionId: string;
}
@@ -7,6 +7,7 @@ import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto';
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto';
import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto';
@@ -124,4 +125,19 @@ export class WorkflowVersionStepResolver {
return step;
}
@Mutation(() => WorkflowVersionStepChangesDTO)
async duplicateWorkflowVersionStep(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args('input')
{ stepId, workflowVersionId }: DuplicateWorkflowVersionStepInput,
): Promise<WorkflowVersionStepChangesDTO> {
return this.workflowVersionStepWorkspaceService.duplicateWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
stepId,
},
);
}
}
@@ -10,4 +10,5 @@ export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_BUILDING = 'SERVERLESS_FUNCTION_BUILDING',
SERVERLESS_FUNCTION_CODE_UNCHANGED = 'SERVERLESS_FUNCTION_CODE_UNCHANGED',
SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED = 'SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED',
SERVERLESS_FUNCTION_CREATE_FAILED = 'SERVERLESS_FUNCTION_CREATE_FAILED',
}
@@ -363,7 +363,7 @@ export class ServerlessFunctionService {
});
}
async usePublishedVersionAsDraft({
async createDraftFromPublishedVersion({
id,
version,
workspaceId,
@@ -400,6 +400,57 @@ export class ServerlessFunctionService {
});
}
async duplicateServerlessFunction({
id,
version,
workspaceId,
}: {
id: string;
version: string;
workspaceId: string;
}) {
const serverlessFunctionToDuplicate =
await this.serverlessFunctionRepository.findOneOrFail({
where: {
id,
workspaceId,
},
});
const newServerlessFunction = await this.createOneServerlessFunction(
{
name: serverlessFunctionToDuplicate.name,
description: serverlessFunctionToDuplicate.description,
timeoutSeconds: serverlessFunctionToDuplicate.timeoutSeconds,
},
workspaceId,
);
if (!isDefined(newServerlessFunction)) {
throw new ServerlessFunctionException(
'Failed to create new serverless function',
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CREATE_FAILED,
);
}
await this.fileStorageService.copy({
from: {
folderPath: getServerlessFolder({
serverlessFunction: serverlessFunctionToDuplicate,
version,
}),
},
to: {
folderPath: getServerlessFolder({
serverlessFunction: newServerlessFunction,
version: 'draft',
}),
},
});
return newServerlessFunction;
}
private async throttleExecution(workspaceId: string) {
try {
await this.throttlerService.throttle(
@@ -24,6 +24,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED:
throw new ForbiddenError(error);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CODE_UNCHANGED:
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CREATE_FAILED:
throw error;
default: {
return assertUnreachable(error.code);
@@ -48,6 +48,8 @@ const BASE_STEP_DEFINITION: BaseWorkflowActionSettings = {
},
};
const DUPLICATED_STEP_POSITION_OFFSET = 50;
@Injectable()
export class WorkflowVersionStepWorkspaceService {
constructor(
@@ -279,36 +281,69 @@ export class WorkflowVersionStepWorkspaceService {
});
}
async duplicateStep({
step,
async duplicateWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
switch (step.type) {
case WorkflowActionType.CODE: {
await this.serverlessFunctionService.usePublishedVersionAsDraft({
id: step.settings.input.serverlessFunctionId,
version: step.settings.input.serverlessFunctionVersion,
workspaceId,
});
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
return {
...step,
settings: {
...step.settings,
input: {
...step.settings.input,
serverlessFunctionVersion: 'draft',
},
},
};
}
default: {
return step;
}
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
assertWorkflowVersionIsDraft(workflowVersion);
const stepToDuplicate = workflowVersion.steps?.find(
(step) => step.id === stepId,
);
if (!isDefined(stepToDuplicate)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const duplicatedStep = await this.createStepForDuplicate({
step: stepToDuplicate,
workspaceId,
});
const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
});
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
trigger: updatedTrigger,
});
return computeWorkflowVersionStepChanges({
createdStep: updatedInsertedStep,
trigger: updatedTrigger,
steps: updatedSteps,
});
}
async submitFormStep({
@@ -372,6 +407,38 @@ export class WorkflowVersionStepWorkspaceService {
});
}
async createDraftStep({
step,
workspaceId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
switch (step.type) {
case WorkflowActionType.CODE: {
await this.serverlessFunctionService.createDraftFromPublishedVersion({
id: step.settings.input.serverlessFunctionId,
version: step.settings.input.serverlessFunctionVersion,
workspaceId,
});
return {
...step,
settings: {
...step.settings,
input: {
...step.settings.input,
serverlessFunctionVersion: 'draft',
},
},
};
}
default: {
return step;
}
}
}
private async enrichOutputSchema({
step,
workspaceId,
@@ -745,4 +812,53 @@ export class WorkflowVersionStepWorkspaceService {
return acc;
}, {});
}
private async createStepForDuplicate({
step,
workspaceId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
const duplicatedStepPosition = {
x: (step.position?.x ?? 0) + DUPLICATED_STEP_POSITION_OFFSET,
y: (step.position?.y ?? 0) + DUPLICATED_STEP_POSITION_OFFSET,
};
switch (step.type) {
case WorkflowActionType.CODE: {
const newServerlessFunction =
await this.serverlessFunctionService.duplicateServerlessFunction({
id: step.settings.input.serverlessFunctionId,
version: step.settings.input.serverlessFunctionVersion,
workspaceId,
});
return {
...step,
id: v4(),
name: `${step.name} (Duplicate)`,
nextStepIds: [],
position: duplicatedStepPosition,
settings: {
...step.settings,
input: {
...step.settings.input,
serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft',
},
},
};
}
default: {
return {
...step,
id: v4(),
name: `${step.name} (Duplicate)`,
nextStepIds: [],
position: duplicatedStepPosition,
};
}
}
}
}
@@ -102,7 +102,7 @@ export class WorkflowVersionWorkspaceService {
for (const step of workflowVersionToCopy.steps) {
const duplicatedStep =
await this.workflowVersionStepWorkspaceService.duplicateStep({
await this.workflowVersionStepWorkspaceService.createDraftStep({
step,
workspaceId,
});