# Implement "Tidy Up" Action for Workflow Diagram **Task Link:** https://twill.ai/twentyhq/ENG/tasks/6 ## Summary This PR adds a "Tidy Up" action to the workflow diagram interface, allowing users to automatically organize and clean up the layout of workflow nodes and connections. ## Changes Made - **Added new workflow action**: Created `TidyUpWorkflowSingleRecordAction` component to provide a UI action for tidying up workflow diagrams - **Refactored tidy-up logic**: Extracted core tidy-up functionality into a reusable `useTidyUp` hook, separating layout logic from workflow version persistence - **Updated action menu configuration**: Integrated the new tidy-up action into `WorkflowActionsConfig` with proper permissions and keyboard shortcuts - **Enhanced right-click menu**: Updated `WorkflowDiagramRightClickCommandMenu` to use the refactored tidy-up hook - **Improved hook architecture**: Simplified `useTidyUpWorkflowVersion` to delegate layout calculations to the new `useTidyUp` hook ## Key Features - Users can now trigger workflow diagram tidy-up from the action menu - Consistent tidy-up behavior across both right-click menu and action menu interfaces - Better separation of concerns between layout calculation and data persistence <details> <summary>📸 Screenshots</summary> Playwright test screenshots captured during development: ### command-menu-with-tidy-up-workflow.png  ### tidy-up-workflow-command-menu.png  ### tidy-up-workflow-error-toast.png  </details> --------- Co-authored-by: Twill <agent@twill.ai> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
This commit is contained in:
co-authored by
Twill
Claude
Thomas Trompette
parent
0d0cce7bb1
commit
ce045db15a
+40
-23
@@ -11,6 +11,7 @@ import { SeeActiveVersionWorkflowSingleRecordAction } from '@/action-menu/action
|
||||
import { SeeRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeRunsWorkflowSingleRecordAction';
|
||||
import { SeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeVersionsWorkflowSingleRecordAction';
|
||||
import { TestWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/TestWorkflowSingleRecordAction';
|
||||
import { TidyUpWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/TidyUpWorkflowSingleRecordAction';
|
||||
import { WorkflowSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys';
|
||||
import { inheritActionsFromDefaultConfig } from '@/action-menu/actions/record-actions/utils/inheritActionsFromDefaultConfig';
|
||||
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
IconPower,
|
||||
IconReorder,
|
||||
IconVersions,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
@@ -209,6 +211,21 @@ export const WORKFLOW_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
|
||||
availableOn: [ActionViewType.SHOW_PAGE],
|
||||
component: <AddNodeWorkflowSingleRecordAction />,
|
||||
},
|
||||
[WorkflowSingleRecordActionKeys.TIDY_UP]: {
|
||||
key: WorkflowSingleRecordActionKeys.TIDY_UP,
|
||||
label: msg`Tidy up workflow`,
|
||||
shortLabel: msg`Tidy up`,
|
||||
isPinned: false,
|
||||
position: 9,
|
||||
Icon: IconReorder,
|
||||
type: ActionType.Standard,
|
||||
scope: ActionScope.RecordSelection,
|
||||
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
|
||||
areWorkflowTriggerAndStepsDefined(workflowWithCurrentVersion) &&
|
||||
!isDefined(selectedRecord?.deletedAt),
|
||||
availableOn: [ActionViewType.SHOW_PAGE],
|
||||
component: <TidyUpWorkflowSingleRecordAction />,
|
||||
},
|
||||
[NoSelectionWorkflowRecordActionKeys.GO_TO_RUNS]: {
|
||||
type: ActionType.Navigation,
|
||||
scope: ActionScope.Global,
|
||||
@@ -260,82 +277,82 @@ export const WORKFLOW_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
|
||||
label: msg`Create new workflow`,
|
||||
},
|
||||
[SingleRecordActionKeys.ADD_TO_FAVORITES]: {
|
||||
position: 9,
|
||||
},
|
||||
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
|
||||
position: 10,
|
||||
},
|
||||
[SingleRecordActionKeys.DELETE]: {
|
||||
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
|
||||
position: 11,
|
||||
},
|
||||
[SingleRecordActionKeys.DELETE]: {
|
||||
position: 12,
|
||||
label: msg`Delete workflow`,
|
||||
},
|
||||
[MultipleRecordsActionKeys.DELETE]: {
|
||||
position: 12,
|
||||
position: 13,
|
||||
label: msg`Delete workflows`,
|
||||
},
|
||||
[SingleRecordActionKeys.DESTROY]: {
|
||||
position: 13,
|
||||
position: 14,
|
||||
label: msg`Permanently destroy workflow`,
|
||||
},
|
||||
[SingleRecordActionKeys.EXPORT_FROM_RECORD_INDEX]: {
|
||||
position: 14,
|
||||
position: 15,
|
||||
label: msg`Export workflow`,
|
||||
shouldBeRegistered: ({ selectedRecord }) =>
|
||||
!isDefined(selectedRecord?.deletedAt),
|
||||
},
|
||||
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
|
||||
position: 15,
|
||||
position: 16,
|
||||
label: msg`Export workflow`,
|
||||
},
|
||||
[MultipleRecordsActionKeys.EXPORT]: {
|
||||
position: 16,
|
||||
position: 17,
|
||||
label: msg`Export workflows`,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.EXPORT_VIEW]: {
|
||||
position: 16,
|
||||
position: 17,
|
||||
label: msg`Export view`,
|
||||
},
|
||||
[MultipleRecordsActionKeys.DESTROY]: {
|
||||
position: 17,
|
||||
position: 18,
|
||||
label: msg`Permanently destroy workflows`,
|
||||
},
|
||||
[SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
|
||||
position: 18,
|
||||
position: 19,
|
||||
label: msg`Navigate to previous workflow`,
|
||||
},
|
||||
[SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD]: {
|
||||
position: 19,
|
||||
position: 20,
|
||||
label: msg`Navigate to next workflow`,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.SEE_DELETED_RECORDS]: {
|
||||
position: 20,
|
||||
position: 21,
|
||||
label: msg`See deleted workflows`,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.HIDE_DELETED_RECORDS]: {
|
||||
position: 21,
|
||||
position: 22,
|
||||
label: msg`Hide deleted workflows`,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.IMPORT_RECORDS]: {
|
||||
position: 22,
|
||||
position: 23,
|
||||
label: msg`Import workflows`,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_PEOPLE]: {
|
||||
position: 23,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_COMPANIES]: {
|
||||
position: 24,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES]: {
|
||||
[NoSelectionRecordActionKeys.GO_TO_COMPANIES]: {
|
||||
position: 25,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_SETTINGS]: {
|
||||
[NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES]: {
|
||||
position: 26,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_TASKS]: {
|
||||
[NoSelectionRecordActionKeys.GO_TO_SETTINGS]: {
|
||||
position: 27,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_NOTES]: {
|
||||
[NoSelectionRecordActionKeys.GO_TO_TASKS]: {
|
||||
position: 28,
|
||||
},
|
||||
[NoSelectionRecordActionKeys.GO_TO_NOTES]: {
|
||||
position: 29,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Action } from '@/action-menu/actions/components/Action';
|
||||
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
|
||||
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
|
||||
import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const TidyUpWorkflowSingleRecordAction = () => {
|
||||
const recordId = useSelectedRecordIdOrThrow();
|
||||
const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion();
|
||||
const instanceId = getWorkflowVisualizerComponentInstanceId({
|
||||
recordId,
|
||||
});
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow(instanceId);
|
||||
|
||||
const onClick = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const workflowDiagramState = workflowDiagramComponentState.atomFamily({
|
||||
instanceId,
|
||||
});
|
||||
const workflowDiagram = snapshot
|
||||
.getLoadable(workflowDiagramState)
|
||||
.getValue();
|
||||
|
||||
if (!isDefined(workflowDiagram)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
|
||||
await tidyUpWorkflowVersion(workflowVersionId, workflowDiagram);
|
||||
},
|
||||
[getUpdatableWorkflowVersion, tidyUpWorkflowVersion, instanceId],
|
||||
);
|
||||
|
||||
return <Action onClick={onClick} />;
|
||||
};
|
||||
+1
@@ -7,4 +7,5 @@ export enum WorkflowSingleRecordActionKeys {
|
||||
SEE_VERSIONS = 'see-versions-workflow-single-record',
|
||||
TEST = 'test-workflow-single-record',
|
||||
ADD_NODE = 'add-node-workflow-single-record',
|
||||
TIDY_UP = 'tidy-up-workflow-single-record',
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,11 +4,12 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useGetUpdatableWorkflowVersionOrThrow = () => {
|
||||
export const useGetUpdatableWorkflowVersionOrThrow = (instanceId?: string) => {
|
||||
const { createDraftFromWorkflowVersion } =
|
||||
useCreateDraftFromWorkflowVersion();
|
||||
const workflowVisualizerWorkflowId = useRecoilComponentValue(
|
||||
workflowVisualizerWorkflowIdComponentState,
|
||||
instanceId,
|
||||
);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
|
||||
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useClo
|
||||
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
|
||||
import { useWorkflowDiagramScreenToFlowPosition } from '@/workflow/workflow-diagram/hooks/useWorkflowDiagramScreenToFlowPosition';
|
||||
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
|
||||
import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion';
|
||||
import { useTidyUp } from '@/workflow/workflow-version/hooks/useTidyUp';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRef } from 'react';
|
||||
@@ -41,10 +41,10 @@ export const WorkflowDiagramRightClickCommandMenu = () => {
|
||||
workflowDiagramRightClickMenuPositionState,
|
||||
);
|
||||
|
||||
const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion();
|
||||
const { tidyUp } = useTidyUp();
|
||||
|
||||
const handleReorderWorkflowDiagram = async () => {
|
||||
await tidyUpWorkflowVersion();
|
||||
await tidyUp();
|
||||
closeRightClickMenu();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
|
||||
import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useTidyUp = () => {
|
||||
const [workflowDiagram, setWorkflowDiagram] = useRecoilComponentState(
|
||||
workflowDiagramComponentState,
|
||||
);
|
||||
|
||||
const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion();
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
|
||||
const tidyUp = async () => {
|
||||
if (!isDefined(workflowDiagram)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
|
||||
const tidiedUpDiagram = await tidyUpWorkflowVersion(
|
||||
workflowVersionId,
|
||||
workflowDiagram,
|
||||
);
|
||||
|
||||
if (isDefined(tidiedUpDiagram)) {
|
||||
setWorkflowDiagram(tidiedUpDiagram);
|
||||
}
|
||||
};
|
||||
|
||||
return { tidyUp };
|
||||
};
|
||||
+8
-15
@@ -1,5 +1,5 @@
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { type WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
@@ -15,16 +15,10 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
|
||||
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
|
||||
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
|
||||
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
|
||||
import { UPDATE_WORKFLOW_VERSION_POSITIONS } from '@/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions';
|
||||
|
||||
export const useTidyUpWorkflowVersion = () => {
|
||||
const [workflowDiagram, setWorkflowDiagram] = useRecoilComponentState(
|
||||
workflowDiagramComponentState,
|
||||
);
|
||||
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
@@ -42,14 +36,10 @@ export const useTidyUpWorkflowVersion = () => {
|
||||
UpdateWorkflowVersionPositionsMutationVariables
|
||||
>(UPDATE_WORKFLOW_VERSION_POSITIONS, { client: apolloCoreClient });
|
||||
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
|
||||
const updateWorkflowVersionPosition = async (
|
||||
workflowVersionId: string,
|
||||
positions: { id: string; position: { x: number; y: number } }[],
|
||||
) => {
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
|
||||
await mutate({ variables: { input: { workflowVersionId, positions } } });
|
||||
|
||||
const cachedRecord = getRecordFromCache<WorkflowVersion>(workflowVersionId);
|
||||
@@ -100,7 +90,10 @@ export const useTidyUpWorkflowVersion = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const tidyUpWorkflowVersion = async () => {
|
||||
const tidyUpWorkflowVersion = async (
|
||||
workflowVersionId: string,
|
||||
workflowDiagram: WorkflowDiagram,
|
||||
) => {
|
||||
if (!isDefined(workflowDiagram)) {
|
||||
return;
|
||||
}
|
||||
@@ -112,9 +105,9 @@ export const useTidyUpWorkflowVersion = () => {
|
||||
position: node.position,
|
||||
}));
|
||||
|
||||
await updateWorkflowVersionPosition(positions);
|
||||
await updateWorkflowVersionPosition(workflowVersionId, positions);
|
||||
|
||||
setWorkflowDiagram(tidiedUpDiagram);
|
||||
return tidiedUpDiagram;
|
||||
};
|
||||
|
||||
return { tidyUpWorkflowVersion };
|
||||
|
||||
Reference in New Issue
Block a user