Scaffold filters creation and deletion (#12990)
When the feature flag is activated, we can now create filters and delete them. This PR mainly updates how we generate workflow diagrams. https://github.com/user-attachments/assets/1a4aef46-7c3c-45fa-953f-0bd1908b9be7
This commit is contained in:
committed by
GitHub
parent
ba67e0d5f4
commit
e8a2d71844
+2
@@ -50,8 +50,10 @@ export const WorkflowDiagramDefaultEdge = ({
|
||||
<WorkflowDiagramEdgeV2
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
stepId={data.stepId}
|
||||
parentStepId={source}
|
||||
nextStepId={target}
|
||||
filter={data.filter}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowDiagramEdgeV1
|
||||
|
||||
+47
-140
@@ -1,164 +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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { isDropdownOpenComponentStateV2 } from '@/ui/layout/dropdown/states/isDropdownOpenComponentStateV2';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
|
||||
import { WorkflowDiagramEdgeV2Content } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content';
|
||||
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
|
||||
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
|
||||
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconFilter,
|
||||
IconFilterPlus,
|
||||
IconFilterX,
|
||||
IconGitBranchDeleted,
|
||||
IconPlus,
|
||||
} from 'twenty-ui/display';
|
||||
import { IconButtonGroup } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
const StyledIconButtonGroup = styled(IconButtonGroup)`
|
||||
pointer-events: all;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div<{ labelX: number; labelY: number }>`
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
pointer-events: all;
|
||||
${({ labelX, labelY }) => css`
|
||||
transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px);
|
||||
`}
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>`
|
||||
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
|
||||
position: relative;
|
||||
`;
|
||||
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
|
||||
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type WorkflowDiagramEdgeV2Props = {
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
stepId: string | undefined;
|
||||
parentStepId: string;
|
||||
nextStepId: string;
|
||||
filter: Record<string, any> | undefined;
|
||||
};
|
||||
|
||||
export const WorkflowDiagramEdgeV2 = ({
|
||||
labelX,
|
||||
labelY,
|
||||
stepId,
|
||||
parentStepId,
|
||||
nextStepId,
|
||||
filter,
|
||||
}: WorkflowDiagramEdgeV2Props) => {
|
||||
const { openDropdown } = useOpenDropdown();
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
|
||||
workflowVisualizerWorkflowIdComponentState,
|
||||
);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
|
||||
assertWorkflowWithCurrentVersionIsDefined(workflow);
|
||||
|
||||
const { createStep } = useCreateStep({ workflow });
|
||||
const { deleteStep } = useDeleteStep({ workflow });
|
||||
const { startNodeCreation } = useStartNodeCreation();
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2(
|
||||
workflowDiagramPanOnDragComponentState,
|
||||
);
|
||||
|
||||
const workflowInsertStepIds = useRecoilComponentValueV2(
|
||||
workflowInsertStepIdsComponentState,
|
||||
);
|
||||
|
||||
const isSelected =
|
||||
workflowInsertStepIds.parentStepId === parentStepId &&
|
||||
workflowInsertStepIds.nextStepId === nextStepId;
|
||||
|
||||
const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`;
|
||||
|
||||
const isDropdownOpen = useRecoilComponentValueV2(
|
||||
isDropdownOpenComponentStateV2,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
|
||||
<WorkflowDiagramEdgeV2Content
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<StyledOpacityOverlay
|
||||
shouldDisplay={isSelected || hovered || isDropdownOpen}
|
||||
>
|
||||
<StyledIconButtonGroup
|
||||
className="nodrag nopan"
|
||||
iconButtons={[
|
||||
{
|
||||
Icon: IconFilterPlus,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
Icon: IconDotsVertical,
|
||||
onClick: () => {
|
||||
openDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
stepId={stepId}
|
||||
parentStepId={parentStepId}
|
||||
nextStepId={nextStepId}
|
||||
filter={filter}
|
||||
onCreateFilter={() => {
|
||||
return createStep({
|
||||
newStepType: 'FILTER',
|
||||
parentStepId,
|
||||
nextStepId,
|
||||
});
|
||||
}}
|
||||
onDeleteFilter={() => {
|
||||
if (!isDefined(stepId)) {
|
||||
throw new Error(
|
||||
'Step ID must be configured for the edge when rendering a filter',
|
||||
);
|
||||
}
|
||||
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
clickableComponent={<div></div>}
|
||||
data-select-disable
|
||||
dropdownPlacement="bottom-start"
|
||||
dropdownStrategy="absolute"
|
||||
dropdownOffset={{
|
||||
x: 0,
|
||||
y: 4,
|
||||
}}
|
||||
onOpen={() => {
|
||||
setWorkflowDiagramPanOnDrag(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setWorkflowDiagramPanOnDrag(true);
|
||||
}}
|
||||
dropdownComponents={
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
text="Filter"
|
||||
LeftIcon={IconFilter}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Remove Filter"
|
||||
LeftIcon={IconFilterX}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Add Node"
|
||||
LeftIcon={IconPlus}
|
||||
onClick={() => {
|
||||
closeDropdown(dropdownId);
|
||||
setHovered(false);
|
||||
|
||||
startNodeCreation({ parentStepId, nextStepId });
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Delete branch"
|
||||
LeftIcon={IconGitBranchDeleted}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
</StyledOpacityOverlay>
|
||||
</StyledContainer>
|
||||
return deleteStep(stepId);
|
||||
}}
|
||||
onCreateNode={() => {
|
||||
if (isDefined(filter)) {
|
||||
startNodeCreation({ parentStepId: stepId, nextStepId });
|
||||
} else {
|
||||
startNodeCreation({ parentStepId, nextStepId });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { isDropdownOpenComponentStateV2 } from '@/ui/layout/dropdown/states/isDropdownOpenComponentStateV2';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
|
||||
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
|
||||
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconFilter,
|
||||
IconFilterPlus,
|
||||
IconFilterX,
|
||||
IconGitBranchDeleted,
|
||||
IconPlus,
|
||||
} from 'twenty-ui/display';
|
||||
import { IconButtonGroup } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
const StyledIconButtonGroup = styled(IconButtonGroup)`
|
||||
pointer-events: all;
|
||||
`;
|
||||
|
||||
const StyledRoundedIconButtonGroup = styled(IconButtonGroup)`
|
||||
border-radius: 50px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div<{ labelX: number; labelY: number }>`
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
pointer-events: all;
|
||||
${({ labelX, labelY }) => css`
|
||||
transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px);
|
||||
`}
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>`
|
||||
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
type WorkflowDiagramEdgeV2ContentProps = {
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
stepId: string | undefined;
|
||||
parentStepId: string;
|
||||
nextStepId: string;
|
||||
filter: Record<string, any> | undefined;
|
||||
onCreateFilter: () => Promise<void>;
|
||||
onDeleteFilter: () => Promise<void>;
|
||||
onCreateNode: () => void;
|
||||
};
|
||||
|
||||
export const WorkflowDiagramEdgeV2Content = ({
|
||||
labelX,
|
||||
labelY,
|
||||
stepId,
|
||||
parentStepId,
|
||||
nextStepId,
|
||||
filter,
|
||||
onCreateFilter,
|
||||
onDeleteFilter,
|
||||
onCreateNode,
|
||||
}: WorkflowDiagramEdgeV2ContentProps) => {
|
||||
const { openDropdown } = useOpenDropdown();
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2(
|
||||
workflowDiagramPanOnDragComponentState,
|
||||
);
|
||||
|
||||
const workflowInsertStepIds = useRecoilComponentValueV2(
|
||||
workflowInsertStepIdsComponentState,
|
||||
);
|
||||
|
||||
const isSelected =
|
||||
workflowInsertStepIds.nextStepId === nextStepId &&
|
||||
(workflowInsertStepIds.parentStepId === parentStepId ||
|
||||
(isNonEmptyString(stepId) &&
|
||||
workflowInsertStepIds.parentStepId === stepId));
|
||||
|
||||
const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`;
|
||||
|
||||
const isDropdownOpen = useRecoilComponentValueV2(
|
||||
isDropdownOpenComponentStateV2,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const handleCreateFilter = async () => {
|
||||
await onCreateFilter();
|
||||
|
||||
closeDropdown(dropdownId);
|
||||
setHovered(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<StyledOpacityOverlay
|
||||
shouldDisplay={
|
||||
isSelected || hovered || isDropdownOpen || isDefined(filter)
|
||||
}
|
||||
>
|
||||
{isDefined(filter) && !hovered && !isDropdownOpen && !isSelected ? (
|
||||
<StyledRoundedIconButtonGroup
|
||||
className="nodrag nopan"
|
||||
iconButtons={[
|
||||
{
|
||||
Icon: IconFilterPlus,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<StyledIconButtonGroup
|
||||
className="nodrag nopan"
|
||||
iconButtons={[
|
||||
{
|
||||
Icon: IconFilterPlus,
|
||||
onClick: () => {
|
||||
handleCreateFilter();
|
||||
},
|
||||
},
|
||||
{
|
||||
Icon: IconDotsVertical,
|
||||
onClick: () => {
|
||||
openDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
clickableComponent={<div></div>}
|
||||
data-select-disable
|
||||
dropdownPlacement="bottom-start"
|
||||
dropdownStrategy="absolute"
|
||||
dropdownOffset={{
|
||||
x: 0,
|
||||
y: 4,
|
||||
}}
|
||||
onOpen={() => {
|
||||
setWorkflowDiagramPanOnDrag(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setWorkflowDiagramPanOnDrag(true);
|
||||
}}
|
||||
dropdownComponents={
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
text="Filter"
|
||||
LeftIcon={IconFilter}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Remove Filter"
|
||||
LeftIcon={IconFilterX}
|
||||
onClick={() => {
|
||||
closeDropdown(dropdownId);
|
||||
setHovered(false);
|
||||
|
||||
onDeleteFilter();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Add Node"
|
||||
LeftIcon={IconPlus}
|
||||
onClick={() => {
|
||||
closeDropdown(dropdownId);
|
||||
setHovered(false);
|
||||
|
||||
onCreateNode();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
text="Delete branch"
|
||||
LeftIcon={IconGitBranchDeleted}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
</StyledOpacityOverlay>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+18
-9
@@ -1,17 +1,17 @@
|
||||
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
ComponentDecorator,
|
||||
getCanvasElementForDropdownTesting,
|
||||
} from 'twenty-ui/testing';
|
||||
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
|
||||
import { WorkflowDiagramEdgeV2 } from '../WorkflowDiagramEdgeV2';
|
||||
import { WorkflowDiagramEdgeV2Content } from '../WorkflowDiagramEdgeV2Content';
|
||||
|
||||
const meta: Meta<typeof WorkflowDiagramEdgeV2> = {
|
||||
title: 'Modules/Workflow/WorkflowDiagramEdgeV2',
|
||||
component: WorkflowDiagramEdgeV2,
|
||||
const meta: Meta<typeof WorkflowDiagramEdgeV2Content> = {
|
||||
title: 'Modules/Workflow/WorkflowDiagramEdgeV2Content',
|
||||
component: WorkflowDiagramEdgeV2Content,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
ReactflowDecorator,
|
||||
@@ -35,11 +35,14 @@ const meta: Meta<typeof WorkflowDiagramEdgeV2> = {
|
||||
labelY: 0,
|
||||
parentStepId: 'parent-step-id',
|
||||
nextStepId: 'next-step-id',
|
||||
onCreateFilter: fn(),
|
||||
onDeleteFilter: fn(),
|
||||
onCreateNode: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkflowDiagramEdgeV2>;
|
||||
type Story = StoryObj<typeof WorkflowDiagramEdgeV2Content>;
|
||||
|
||||
export const ButtonsAppearOnHover: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -57,7 +60,7 @@ export const ButtonsAppearOnHover: Story = {
|
||||
};
|
||||
|
||||
export const CreateFilter: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttons = await canvas.findAllByRole('button');
|
||||
@@ -71,12 +74,14 @@ export const CreateFilter: Story = {
|
||||
|
||||
userEvent.click(filterButton);
|
||||
|
||||
// TODO: Assert we created a filter
|
||||
await waitFor(() => {
|
||||
expect(args.onCreateFilter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AddNodeAction: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttons = await canvas.findAllByRole('button');
|
||||
@@ -99,6 +104,10 @@ export const AddNodeAction: Story = {
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByText('Add Node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onCreateNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ export type WorkflowRunDiagramNodeData = Exclude<
|
||||
> & { runStatus: WorkflowDiagramRunStatus };
|
||||
|
||||
export type EdgeData = {
|
||||
stepId?: string;
|
||||
filter?: Record<string, any>;
|
||||
shouldDisplayEdgeOptions?: boolean;
|
||||
};
|
||||
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { transformFilterNodesAsEdges } from '../transformFilterNodesAsEdges';
|
||||
|
||||
describe('transformFilterNodesAsEdges', () => {
|
||||
it('should return the original diagram when there are no filter nodes', () => {
|
||||
const diagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: 'SEND_EMAIL',
|
||||
name: 'Step C',
|
||||
},
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'A-C',
|
||||
source: 'A',
|
||||
target: 'C',
|
||||
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformFilterNodesAsEdges(diagram);
|
||||
|
||||
expect(result.nodes).toEqual(diagram.nodes);
|
||||
expect(result.edges).toEqual(diagram.edges);
|
||||
});
|
||||
|
||||
it('should transform A->B->C where B is a FILTER step', () => {
|
||||
const diagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
|
||||
position: { x: 0, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: 'SEND_EMAIL',
|
||||
name: 'Step C',
|
||||
},
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'A-B',
|
||||
source: 'A',
|
||||
target: 'B',
|
||||
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
{
|
||||
id: 'B-C',
|
||||
source: 'B',
|
||||
target: 'C',
|
||||
data: { stepId: 'B', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformFilterNodesAsEdges(diagram);
|
||||
|
||||
// Should only have nodes A and C
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
]);
|
||||
|
||||
// Should have one edge with filter data
|
||||
expect(result.edges).toHaveLength(1);
|
||||
expect(result.edges[0]).toEqual({
|
||||
id: 'A-C-filter-B',
|
||||
source: 'A',
|
||||
target: 'C',
|
||||
data: {
|
||||
shouldDisplayEdgeOptions: true,
|
||||
stepId: 'B',
|
||||
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple filter nodes', () => {
|
||||
const diagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'B1',
|
||||
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' },
|
||||
position: { x: 0, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: 'SEND_EMAIL',
|
||||
name: 'Step C',
|
||||
},
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
{
|
||||
id: 'B2',
|
||||
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' },
|
||||
position: { x: 0, y: 450 },
|
||||
},
|
||||
{
|
||||
id: 'D',
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: 'CREATE_RECORD',
|
||||
name: 'Step D',
|
||||
},
|
||||
position: { x: 0, y: 600 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'A-B1',
|
||||
source: 'A',
|
||||
target: 'B1',
|
||||
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
{
|
||||
id: 'B1-C',
|
||||
source: 'B1',
|
||||
target: 'C',
|
||||
data: { stepId: 'B1', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
{
|
||||
id: 'C-B2',
|
||||
source: 'C',
|
||||
target: 'B2',
|
||||
data: { stepId: 'C', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
{
|
||||
id: 'B2-D',
|
||||
source: 'B2',
|
||||
target: 'D',
|
||||
data: { stepId: 'B2', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformFilterNodesAsEdges(diagram);
|
||||
|
||||
// Should only have nodes A, C, and D
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
expect(result.nodes.map((n) => n.id)).toEqual(
|
||||
expect.arrayContaining(['A', 'C', 'D']),
|
||||
);
|
||||
|
||||
// Should have two edges with filter data
|
||||
expect(result.edges).toHaveLength(2);
|
||||
|
||||
const edgeAC = result.edges.find(
|
||||
(e) => e.source === 'A' && e.target === 'C',
|
||||
);
|
||||
expect(edgeAC).toEqual({
|
||||
id: 'A-C-filter-B1',
|
||||
source: 'A',
|
||||
target: 'C',
|
||||
data: {
|
||||
stepId: 'B1',
|
||||
shouldDisplayEdgeOptions: true,
|
||||
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' },
|
||||
},
|
||||
});
|
||||
|
||||
const edgeCD = result.edges.find(
|
||||
(e) => e.source === 'C' && e.target === 'D',
|
||||
);
|
||||
expect(edgeCD).toEqual({
|
||||
id: 'C-D-filter-B2',
|
||||
source: 'C',
|
||||
target: 'D',
|
||||
data: {
|
||||
stepId: 'B2',
|
||||
shouldDisplayEdgeOptions: true,
|
||||
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filter nodes that are not part of a chain', () => {
|
||||
const diagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
|
||||
position: { x: 0, y: 150 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'A-B',
|
||||
source: 'A',
|
||||
target: 'B',
|
||||
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformFilterNodesAsEdges(diagram);
|
||||
|
||||
// Should only have node A (filter node B is removed)
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
id: 'A',
|
||||
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
// Should have no edges (original edge A-B is removed, no new edges created)
|
||||
expect(result.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve trigger nodes', () => {
|
||||
const diagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger',
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
triggerType: 'DATABASE_EVENT',
|
||||
name: 'Trigger',
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
|
||||
position: { x: 0, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: 'SEND_EMAIL',
|
||||
name: 'Step C',
|
||||
},
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'trigger-B',
|
||||
source: 'trigger',
|
||||
target: 'B',
|
||||
data: { stepId: 'trigger', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
{
|
||||
id: 'B-C',
|
||||
source: 'B',
|
||||
target: 'C',
|
||||
data: { stepId: 'B', shouldDisplayEdgeOptions: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformFilterNodesAsEdges(diagram);
|
||||
|
||||
// Should have trigger and C nodes
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
id: 'trigger',
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
triggerType: 'DATABASE_EVENT',
|
||||
name: 'Trigger',
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
|
||||
position: { x: 0, y: 300 },
|
||||
},
|
||||
]);
|
||||
|
||||
// Should have one edge with filter data
|
||||
expect(result.edges).toEqual([
|
||||
{
|
||||
id: 'trigger-C-filter-B',
|
||||
source: 'trigger',
|
||||
target: 'C',
|
||||
data: {
|
||||
stepId: 'B',
|
||||
shouldDisplayEdgeOptions: true,
|
||||
filter: {
|
||||
nodeType: 'action',
|
||||
actionType: 'FILTER',
|
||||
name: 'Filter B',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,14 @@ export const addEdgeOptions = ({
|
||||
}: WorkflowDiagram): WorkflowDiagram => {
|
||||
return {
|
||||
nodes,
|
||||
edges: edges.map((edge) => ({
|
||||
...edge,
|
||||
data: { shouldDisplayEdgeOptions: true },
|
||||
})),
|
||||
edges: edges.map((edge) => {
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
shouldDisplayEdgeOptions: true,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+5
-2
@@ -10,9 +10,10 @@ import {
|
||||
WorkflowRunDiagramNode,
|
||||
WorkflowRunDiagramStepNodeData,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
|
||||
import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode';
|
||||
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const generateWorkflowRunDiagram = ({
|
||||
trigger,
|
||||
@@ -38,7 +39,9 @@ export const generateWorkflowRunDiagram = ({
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
const workflowDiagram = generateWorkflowDiagram({ trigger, steps });
|
||||
const workflowDiagram = transformFilterNodesAsEdges(
|
||||
generateWorkflowDiagram({ trigger, steps }),
|
||||
);
|
||||
|
||||
let skippedExecution = false;
|
||||
|
||||
|
||||
+7
-4
@@ -1,6 +1,7 @@
|
||||
import { WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
|
||||
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const EMPTY_DIAGRAM: WorkflowDiagram = {
|
||||
@@ -15,8 +16,10 @@ export const getWorkflowVersionDiagram = (
|
||||
return EMPTY_DIAGRAM;
|
||||
}
|
||||
|
||||
return generateWorkflowDiagram({
|
||||
trigger: workflowVersion.trigger ?? undefined,
|
||||
steps: workflowVersion.steps ?? [],
|
||||
});
|
||||
return transformFilterNodesAsEdges(
|
||||
generateWorkflowDiagram({
|
||||
trigger: workflowVersion.trigger ?? undefined,
|
||||
steps: workflowVersion.steps ?? [],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const transformFilterNodesAsEdges = ({
|
||||
nodes,
|
||||
edges,
|
||||
}: WorkflowDiagram): WorkflowDiagram => {
|
||||
const filterNodes = nodes.filter(
|
||||
(node) =>
|
||||
node.data.nodeType === 'action' &&
|
||||
'actionType' in node.data &&
|
||||
node.data.actionType === 'FILTER',
|
||||
);
|
||||
|
||||
if (filterNodes.length === 0) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
const resultNodes = nodes.filter(
|
||||
(node) => !filterNodes.some((filterNode) => filterNode.id === node.id),
|
||||
);
|
||||
|
||||
const resultEdges = [...edges];
|
||||
const edgesToRemove = new Set<string>();
|
||||
const edgesToAdd: typeof edges = [];
|
||||
|
||||
for (const filterNode of filterNodes) {
|
||||
const incomingEdge = edges.find((edge) => edge.target === filterNode.id);
|
||||
const outgoingEdge = edges.find((edge) => edge.source === filterNode.id);
|
||||
|
||||
if (isDefined(incomingEdge) && isDefined(outgoingEdge)) {
|
||||
const newEdge = {
|
||||
...incomingEdge,
|
||||
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
|
||||
target: outgoingEdge.target,
|
||||
data: {
|
||||
...incomingEdge.data,
|
||||
stepId: filterNode.id,
|
||||
filter: filterNode.data,
|
||||
},
|
||||
};
|
||||
|
||||
edgesToAdd.push(newEdge);
|
||||
edgesToRemove.add(incomingEdge.id);
|
||||
edgesToRemove.add(outgoingEdge.id);
|
||||
} else {
|
||||
if (isDefined(incomingEdge)) {
|
||||
edgesToRemove.add(incomingEdge.id);
|
||||
}
|
||||
|
||||
if (isDefined(outgoingEdge)) {
|
||||
edgesToRemove.add(outgoingEdge.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalEdges = [
|
||||
...resultEdges.filter((edge) => !edgesToRemove.has(edge.id)),
|
||||
...edgesToAdd,
|
||||
];
|
||||
|
||||
return {
|
||||
nodes: resultNodes,
|
||||
edges: finalEdges,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user