Update filter design (#13243)

In this PR:

- Adjust the edges to match the new Figma design
- Properly display the filters for workflows and workflow versions
(replaced shouldDisplayEdgeOptions with isEdgeEditable as we want to
display configured filters on workflow versions, but want to disallow
editing them)
- Wrote a few tests to make coverage pass


https://github.com/user-attachments/assets/d303d338-1938-4efe-b489-5a530d65fb30
This commit is contained in:
Baptiste Devessier
2025-07-17 15:36:46 +02:00
committed by GitHub
parent fca39d317f
commit 0752f24638
20 changed files with 852 additions and 209 deletions

View File

@ -1,5 +1,6 @@
import { WorkflowDiagramEdgeV1 } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1'; import { WorkflowDiagramEdgeV1 } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1';
import { WorkflowDiagramEdgeV2 } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2'; import { WorkflowDiagramEdgeV2Empty } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Empty';
import { WorkflowDiagramEdgeV2Filter } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Filter';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@ -10,6 +11,7 @@ import {
EdgeProps, EdgeProps,
getStraightPath, getStraightPath,
} from '@xyflow/react'; } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql'; import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramDefaultEdgeProps = EdgeProps<WorkflowDiagramEdge>; type WorkflowDiagramDefaultEdgeProps = EdgeProps<WorkflowDiagramEdge>;
@ -36,6 +38,18 @@ export const WorkflowDiagramDefaultEdge = ({
targetY, targetY,
}); });
if (!isDefined(data)) {
throw new Error('Edge data is not defined');
}
const displayEdgeV1 = !isWorkflowFilteringEnabled && data.isEdgeEditable;
const displayEmptyFilters =
isWorkflowFilteringEnabled &&
data.edgeType === 'default' &&
data.isEdgeEditable;
const displayFilters =
isWorkflowFilteringEnabled && data.edgeType === 'filter';
return ( return (
<> <>
<BaseEdge <BaseEdge
@ -44,26 +58,35 @@ export const WorkflowDiagramDefaultEdge = ({
path={edgePath} path={edgePath}
style={{ stroke: theme.border.color.strong }} style={{ stroke: theme.border.color.strong }}
/> />
{data?.shouldDisplayEdgeOptions && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
{isWorkflowFilteringEnabled ? ( {displayEdgeV1 && (
<WorkflowDiagramEdgeV2 <WorkflowDiagramEdgeV1
labelX={labelX} labelY={labelY}
labelY={labelY} parentStepId={source}
stepId={data.stepId} nextStepId={target}
parentStepId={source} />
nextStepId={target} )}
filter={data.filter} {displayEmptyFilters && (
/> <WorkflowDiagramEdgeV2Empty
) : ( labelX={labelX}
<WorkflowDiagramEdgeV1 labelY={labelY}
labelY={labelY} parentStepId={source}
parentStepId={source} nextStepId={target}
nextStepId={target} />
/> )}
)} {displayFilters && (
</EdgeLabelRenderer> <WorkflowDiagramEdgeV2Filter
)} labelX={labelX}
labelY={labelY}
stepId={data.stepId}
parentStepId={source}
nextStepId={target}
filterSettings={data.filterSettings}
isEdgeEditable={data.isEdgeEditable}
/>
)}
</EdgeLabelRenderer>
</> </>
); );
}; };

View File

@ -0,0 +1,13 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
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;
`;
export { StyledContainer as WorkflowDiagramEdgeV2Container };

View File

@ -2,29 +2,23 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined'; import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { WorkflowDiagramEdgeV2Content } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content'; import { WorkflowDiagramEdgeV2EmptyContent } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2EmptyContent';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep'; import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { isDefined } from 'twenty-shared/utils';
type WorkflowDiagramEdgeV2Props = { type WorkflowDiagramEdgeV2EmptyProps = {
labelX: number; labelX: number;
labelY: number; labelY: number;
stepId: string | undefined;
parentStepId: string; parentStepId: string;
nextStepId: string; nextStepId: string;
filter: Record<string, any> | undefined;
}; };
export const WorkflowDiagramEdgeV2 = ({ export const WorkflowDiagramEdgeV2Empty = ({
labelX, labelX,
labelY, labelY,
stepId,
parentStepId, parentStepId,
nextStepId, nextStepId,
filter, }: WorkflowDiagramEdgeV2EmptyProps) => {
}: WorkflowDiagramEdgeV2Props) => {
const workflowVisualizerWorkflowId = useRecoilComponentValueV2( const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
workflowVisualizerWorkflowIdComponentState, workflowVisualizerWorkflowIdComponentState,
); );
@ -32,17 +26,14 @@ export const WorkflowDiagramEdgeV2 = ({
assertWorkflowWithCurrentVersionIsDefined(workflow); assertWorkflowWithCurrentVersionIsDefined(workflow);
const { createStep } = useCreateStep({ workflow }); const { createStep } = useCreateStep({ workflow });
const { deleteStep } = useDeleteStep({ workflow });
const { startNodeCreation } = useStartNodeCreation(); const { startNodeCreation } = useStartNodeCreation();
return ( return (
<WorkflowDiagramEdgeV2Content <WorkflowDiagramEdgeV2EmptyContent
labelX={labelX} labelX={labelX}
labelY={labelY} labelY={labelY}
stepId={stepId}
parentStepId={parentStepId} parentStepId={parentStepId}
nextStepId={nextStepId} nextStepId={nextStepId}
filter={filter}
onCreateFilter={() => { onCreateFilter={() => {
return createStep({ return createStep({
newStepType: 'FILTER', newStepType: 'FILTER',
@ -50,21 +41,8 @@ export const WorkflowDiagramEdgeV2 = ({
nextStepId, nextStepId,
}); });
}} }}
onDeleteFilter={() => {
if (!isDefined(stepId)) {
throw new Error(
'Step ID must be configured for the edge when rendering a filter',
);
}
return deleteStep(stepId);
}}
onCreateNode={() => { onCreateNode={() => {
if (isDefined(filter)) { startNodeCreation({ parentStepId, nextStepId });
startNodeCreation({ parentStepId: stepId, nextStepId });
} else {
startNodeCreation({ parentStepId, nextStepId });
}
}} }}
/> />
); );

View File

@ -0,0 +1,87 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconFilter, IconPlus } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
`;
type WorkflowDiagramEdgeV2EmptyContentProps = {
labelX: number;
labelY: number;
parentStepId: string;
nextStepId: string;
onCreateFilter: () => Promise<void>;
onCreateNode: () => void;
};
export const WorkflowDiagramEdgeV2EmptyContent = ({
labelX,
labelY,
parentStepId,
nextStepId,
onCreateFilter,
onCreateNode,
}: WorkflowDiagramEdgeV2EmptyContentProps) => {
const [hovered, setHovered] = useState(false);
const workflowInsertStepIds = useRecoilComponentValueV2(
workflowInsertStepIdsComponentState,
);
const isSelected =
workflowInsertStepIds.nextStepId === nextStepId &&
workflowInsertStepIds.parentStepId === parentStepId;
const handleCreateFilter = async () => {
await onCreateFilter();
setHovered(false);
};
const setWorkflowSelectedNode = useSetRecoilComponentStateV2(
workflowSelectedNodeComponentState,
);
const handleFilterButtonClick = () => {
setWorkflowSelectedNode(parentStepId);
handleCreateFilter();
};
return (
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<WorkflowDiagramEdgeV2VisibilityContainer
shouldDisplay={isSelected || hovered}
>
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
{
Icon: IconPlus,
onClick: onCreateNode,
},
]}
/>
</WorkflowDiagramEdgeV2VisibilityContainer>
</WorkflowDiagramEdgeV2Container>
);
};

View File

@ -0,0 +1,60 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramEdgeV2FilterContent } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2FilterContent';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { isDefined } from 'twenty-shared/utils';
type WorkflowDiagramEdgeV2FilterProps = {
labelX: number;
labelY: number;
stepId: string;
parentStepId: string;
nextStepId: string;
filterSettings: FilterSettings;
isEdgeEditable: boolean;
};
export const WorkflowDiagramEdgeV2Filter = ({
labelX,
labelY,
stepId,
parentStepId,
nextStepId,
filterSettings,
isEdgeEditable,
}: WorkflowDiagramEdgeV2FilterProps) => {
const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
workflowVisualizerWorkflowIdComponentState,
);
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
const { deleteStep } = useDeleteStep({ workflow });
const { startNodeCreation } = useStartNodeCreation();
return (
<WorkflowDiagramEdgeV2FilterContent
labelX={labelX}
labelY={labelY}
stepId={stepId}
parentStepId={parentStepId}
nextStepId={nextStepId}
filterSettings={filterSettings}
isEdgeEditable={isEdgeEditable}
onDeleteFilter={() => {
if (!isDefined(stepId)) {
throw new Error(
'Step ID must be configured for the edge when rendering a filter',
);
}
return deleteStep(stepId);
}}
onCreateNode={() => {
startNodeCreation({ parentStepId: stepId, nextStepId });
}}
/>
);
};

View File

@ -9,11 +9,13 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState'; import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { css } from '@emotion/react'; import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react'; import { useState } from 'react';
@ -21,9 +23,7 @@ import { isDefined } from 'twenty-shared/utils';
import { import {
IconDotsVertical, IconDotsVertical,
IconFilter, IconFilter,
IconFilterPlus,
IconFilterX, IconFilterX,
IconGitBranchDeleted,
IconPlus, IconPlus,
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input'; import { IconButtonGroup } from 'twenty-ui/input';
@ -33,49 +33,33 @@ const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all; pointer-events: all;
`; `;
const StyledRoundedIconButtonGroup = styled(IconButtonGroup)` const StyledConfiguredFilterContainer = styled.div`
border-radius: 50px; height: 26px;
overflow: hidden; width: 26px;
pointer-events: all;
`; `;
const StyledContainer = styled.div<{ labelX: number; labelY: number }>` type WorkflowDiagramEdgeV2FilterContentProps = {
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; labelX: number;
labelY: number; labelY: number;
stepId: string | undefined; stepId: string;
parentStepId: string; parentStepId: string;
nextStepId: string; nextStepId: string;
filter: Record<string, any> | undefined; filterSettings: FilterSettings;
onCreateFilter: () => Promise<void>;
onDeleteFilter: () => Promise<void>; onDeleteFilter: () => Promise<void>;
onCreateNode: () => void; onCreateNode: () => void;
isEdgeEditable: boolean;
}; };
export const WorkflowDiagramEdgeV2Content = ({ export const WorkflowDiagramEdgeV2FilterContent = ({
labelX, labelX,
labelY, labelY,
stepId, stepId,
parentStepId, parentStepId,
nextStepId, nextStepId,
filter,
onCreateFilter,
onDeleteFilter, onDeleteFilter,
onCreateNode, onCreateNode,
}: WorkflowDiagramEdgeV2ContentProps) => { isEdgeEditable,
}: WorkflowDiagramEdgeV2FilterContentProps) => {
const { openDropdown } = useOpenDropdown(); const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown(); const { closeDropdown } = useCloseDropdown();
@ -108,71 +92,74 @@ export const WorkflowDiagramEdgeV2Content = ({
const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu(); const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu();
const handleCreateFilter = async () => {
await onCreateFilter();
closeDropdown(dropdownId);
setHovered(false);
};
const setWorkflowSelectedNode = useSetRecoilComponentStateV2( const setWorkflowSelectedNode = useSetRecoilComponentStateV2(
workflowSelectedNodeComponentState, workflowSelectedNodeComponentState,
); );
const handleMouseEnter = () => {
if (!isEdgeEditable) {
return;
}
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleFilterButtonClick = () => { const handleFilterButtonClick = () => {
setWorkflowSelectedNode(stepId); setWorkflowSelectedNode(stepId);
if (isDefined(filter) && isDefined(workflowVisualizerWorkflowId)) {
if (isDefined(workflowVisualizerWorkflowId)) {
openWorkflowEditStepInCommandMenu( openWorkflowEditStepInCommandMenu(
workflowVisualizerWorkflowId, workflowVisualizerWorkflowId,
'Filter', 'Filter',
IconFilter, IconFilter,
); );
} else {
handleCreateFilter();
} }
}; };
return ( return (
<StyledContainer <WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID} data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX} labelX={labelX}
labelY={labelY} labelY={labelY}
onMouseEnter={() => setHovered(true)} onMouseEnter={handleMouseEnter}
onMouseLeave={() => setHovered(false)} onMouseLeave={handleMouseLeave}
> >
<StyledOpacityOverlay <WorkflowDiagramEdgeV2VisibilityContainer shouldDisplay>
shouldDisplay={ <StyledConfiguredFilterContainer>
isSelected || hovered || isDropdownOpen || isDefined(filter) {hovered || isDropdownOpen || isSelected ? (
} <StyledIconButtonGroup
> className="nodrag nopan"
{isDefined(filter) && !hovered && !isDropdownOpen && !isSelected ? ( iconButtons={[
<StyledRoundedIconButtonGroup {
className="nodrag nopan" Icon: IconFilter,
iconButtons={[ onClick: handleFilterButtonClick,
{
Icon: IconFilterPlus,
},
]}
/>
) : (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilterPlus,
onClick: handleFilterButtonClick,
},
{
Icon: IconDotsVertical,
onClick: () => {
openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}, },
}, {
]} Icon: IconDotsVertical,
/> onClick: () => {
)} openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
},
]}
/>
) : (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
]}
/>
)}
</StyledConfiguredFilterContainer>
<Dropdown <Dropdown
dropdownId={dropdownId} dropdownId={dropdownId}
@ -181,7 +168,7 @@ export const WorkflowDiagramEdgeV2Content = ({
dropdownPlacement="bottom-start" dropdownPlacement="bottom-start"
dropdownStrategy="absolute" dropdownStrategy="absolute"
dropdownOffset={{ dropdownOffset={{
x: 0, x: 24,
y: 4, y: 4,
}} }}
onOpen={() => { onOpen={() => {
@ -196,7 +183,12 @@ export const WorkflowDiagramEdgeV2Content = ({
<MenuItem <MenuItem
text="Filter" text="Filter"
LeftIcon={IconFilter} LeftIcon={IconFilter}
onClick={() => {}} onClick={() => {
closeDropdown(dropdownId);
setHovered(false);
handleFilterButtonClick();
}}
/> />
<MenuItem <MenuItem
text="Remove Filter" text="Remove Filter"
@ -218,16 +210,11 @@ export const WorkflowDiagramEdgeV2Content = ({
onCreateNode(); onCreateNode();
}} }}
/> />
<MenuItem
text="Delete branch"
LeftIcon={IconGitBranchDeleted}
onClick={() => {}}
/>
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</DropdownContent> </DropdownContent>
} }
/> />
</StyledOpacityOverlay> </WorkflowDiagramEdgeV2VisibilityContainer>
</StyledContainer> </WorkflowDiagramEdgeV2Container>
); );
}; };

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
const StyledContainer = styled.div<{ shouldDisplay: boolean }>`
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
position: relative;
`;
export { StyledContainer as WorkflowDiagramEdgeV2VisibilityContainer };

View File

@ -0,0 +1,94 @@
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import '@xyflow/react/dist/style.css';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { WorkflowDiagramEdgeV2EmptyContent } from '../WorkflowDiagramEdgeV2EmptyContent';
const meta: Meta<typeof WorkflowDiagramEdgeV2EmptyContent> = {
title: 'Modules/Workflow/WorkflowDiagramEdgeV2EmptyContent',
component: WorkflowDiagramEdgeV2EmptyContent,
decorators: [
ComponentDecorator,
ReactflowDecorator,
(Story) => {
const workflowVisualizerComponentInstanceId =
'workflow-visualizer-test-id';
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: workflowVisualizerComponentInstanceId,
}}
>
<Story />
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
],
args: {
labelX: 0,
labelY: 0,
parentStepId: 'parent-step-id',
nextStepId: 'next-step-id',
onCreateFilter: fn(),
onCreateNode: fn(),
},
};
export default meta;
type Story = StoryObj<typeof WorkflowDiagramEdgeV2EmptyContent>;
export const ButtonsAppearOnHover: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const filterButton = buttons[0];
userEvent.hover(filterButton);
await waitFor(() => {
expect(filterButton).toBeVisible();
});
},
};
export const CreateFilter: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const filterButton = buttons[0];
userEvent.hover(filterButton);
await waitFor(() => {
expect(filterButton).toBeVisible();
});
userEvent.click(filterButton);
await waitFor(() => {
expect(args.onCreateFilter).toHaveBeenCalledTimes(1);
});
},
};
export const AddNodeAction: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const addNodeButton = buttons[1];
userEvent.hover(addNodeButton);
userEvent.click(addNodeButton);
await waitFor(() => {
expect(args.onCreateNode).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -7,11 +7,11 @@ import {
getCanvasElementForDropdownTesting, getCanvasElementForDropdownTesting,
} from 'twenty-ui/testing'; } from 'twenty-ui/testing';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator'; import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { WorkflowDiagramEdgeV2Content } from '../WorkflowDiagramEdgeV2Content'; import { WorkflowDiagramEdgeV2FilterContent } from '../WorkflowDiagramEdgeV2FilterContent';
const meta: Meta<typeof WorkflowDiagramEdgeV2Content> = { const meta: Meta<typeof WorkflowDiagramEdgeV2FilterContent> = {
title: 'Modules/Workflow/WorkflowDiagramEdgeV2Content', title: 'Modules/Workflow/WorkflowDiagramEdgeV2FilterContent',
component: WorkflowDiagramEdgeV2Content, component: WorkflowDiagramEdgeV2FilterContent,
decorators: [ decorators: [
ComponentDecorator, ComponentDecorator,
ReactflowDecorator, ReactflowDecorator,
@ -35,14 +35,13 @@ const meta: Meta<typeof WorkflowDiagramEdgeV2Content> = {
labelY: 0, labelY: 0,
parentStepId: 'parent-step-id', parentStepId: 'parent-step-id',
nextStepId: 'next-step-id', nextStepId: 'next-step-id',
onCreateFilter: fn(),
onDeleteFilter: fn(), onDeleteFilter: fn(),
onCreateNode: fn(), onCreateNode: fn(),
}, },
}; };
export default meta; export default meta;
type Story = StoryObj<typeof WorkflowDiagramEdgeV2Content>; type Story = StoryObj<typeof WorkflowDiagramEdgeV2FilterContent>;
export const ButtonsAppearOnHover: Story = { export const ButtonsAppearOnHover: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@ -59,27 +58,6 @@ export const ButtonsAppearOnHover: Story = {
}, },
}; };
export const CreateFilter: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const filterButton = buttons[0];
userEvent.hover(filterButton);
await waitFor(() => {
expect(filterButton).toBeVisible();
});
userEvent.click(filterButton);
await waitFor(() => {
expect(args.onCreateFilter).toHaveBeenCalledTimes(1);
});
},
};
export const AddNodeAction: Story = { export const AddNodeAction: Story = {
play: async ({ canvasElement, args }) => { play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);

View File

@ -7,4 +7,8 @@ export const WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION = {
markerEnd: EDGE_ROUNDED_ARROW_MARKER_ID, markerEnd: EDGE_ROUNDED_ARROW_MARKER_ID,
deletable: false, deletable: false,
selectable: false, selectable: false,
data: {
edgeType: 'default',
isEdgeEditable: false,
},
} satisfies Partial<WorkflowDiagramEdge>; } satisfies Partial<WorkflowDiagramEdge>;

View File

@ -3,18 +3,18 @@ import {
WorkflowRunStepStatus, WorkflowRunStepStatus,
WorkflowTriggerType, WorkflowTriggerType,
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { Edge, Node } from '@xyflow/react'; import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramStepNode = Node<WorkflowDiagramStepNodeData>; export type WorkflowDiagramStepNode = Node<WorkflowDiagramStepNodeData>;
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>; export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
export type WorkflowDiagramEdge = Edge<EdgeData>; export type WorkflowDiagramEdge = Edge<WorkflowDiagramEdgeData>;
export type WorkflowRunDiagramNode = Node<WorkflowRunDiagramNodeData>; export type WorkflowRunDiagramNode = Node<WorkflowRunDiagramNodeData>;
export type WorkflowRunDiagramEdge = Edge<EdgeData>;
export type WorkflowRunDiagram = { export type WorkflowRunDiagram = {
nodes: Array<WorkflowRunDiagramNode>; nodes: Array<WorkflowRunDiagramNode>;
edges: Array<WorkflowRunDiagramEdge>; edges: Array<WorkflowDiagramEdge>;
}; };
export type WorkflowDiagram = { export type WorkflowDiagram = {
@ -63,12 +63,24 @@ export type WorkflowRunDiagramNodeData = Exclude<
'runStatus' 'runStatus'
> & { runStatus: WorkflowRunStepStatus }; > & { runStatus: WorkflowRunStepStatus };
export type EdgeData = { export type WorkflowDiagramFilterEdgeData = {
stepId?: string; edgeType: 'filter';
filter?: Record<string, any>; stepId: string;
shouldDisplayEdgeOptions?: boolean; filterSettings: FilterSettings;
name: string;
runStatus?: WorkflowRunStepStatus;
isEdgeEditable: boolean;
}; };
export type WorkflowDiagramDefaultEdgeData = {
edgeType: 'default';
isEdgeEditable: boolean;
};
export type WorkflowDiagramEdgeData =
| WorkflowDiagramFilterEdgeData
| WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramNodeType = export type WorkflowDiagramNodeType =
| 'default' | 'default'
| 'empty-trigger' | 'empty-trigger'

View File

@ -2,7 +2,7 @@ import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagr
import { addEdgeOptions } from '../addEdgeOptions'; import { addEdgeOptions } from '../addEdgeOptions';
describe('addEdgeOptions', () => { describe('addEdgeOptions', () => {
it('should add shouldDisplayEdgeOptions to all edges', () => { it('should add isEdgeEditable to all edges', () => {
const diagram: WorkflowDiagram = { const diagram: WorkflowDiagram = {
nodes: [ nodes: [
{ {
@ -31,14 +31,18 @@ describe('addEdgeOptions', () => {
source: 'trigger', source: 'trigger',
target: 'action-1', target: 'action-1',
data: { data: {
shouldDisplayEdgeOptions: true, edgeType: 'default',
isEdgeEditable: true,
}, },
}, },
{ {
id: 'edge-2', id: 'edge-2',
source: 'action-1', source: 'action-1',
target: 'action-2', target: 'action-2',
data: {}, data: {
edgeType: 'default',
isEdgeEditable: false,
},
}, },
], ],
}; };
@ -53,7 +57,8 @@ describe('addEdgeOptions', () => {
source: 'trigger', source: 'trigger',
target: 'action-1', target: 'action-1',
data: { data: {
shouldDisplayEdgeOptions: true, edgeType: 'default',
isEdgeEditable: true,
}, },
}); });
@ -62,7 +67,8 @@ describe('addEdgeOptions', () => {
source: 'action-1', source: 'action-1',
target: 'action-2', target: 'action-2',
data: { data: {
shouldDisplayEdgeOptions: true, edgeType: 'default',
isEdgeEditable: true,
}, },
}); });
}); });
@ -122,15 +128,6 @@ describe('addEdgeOptions', () => {
], ],
}; };
const result = addEdgeOptions(diagram); expect(() => addEdgeOptions(diagram)).toThrow('Edge data must be defined');
expect(result.edges[0]).toEqual({
id: 'edge-1',
source: 'trigger',
target: 'action-1',
data: {
shouldDisplayEdgeOptions: true,
},
});
}); });
}); });

View File

@ -107,6 +107,10 @@ describe('generateWorkflowRunDiagram', () => {
"diagram": { "diagram": {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-0", "id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -117,6 +121,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-1", "id": "8f3b2121-f194-4ba4-9fbf-1",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -126,6 +134,10 @@ describe('generateWorkflowRunDiagram', () => {
"target": "step2", "target": "step2",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-2", "id": "8f3b2121-f194-4ba4-9fbf-2",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -296,6 +308,10 @@ describe('generateWorkflowRunDiagram', () => {
"diagram": { "diagram": {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-3", "id": "8f3b2121-f194-4ba4-9fbf-3",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -306,6 +322,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-4", "id": "8f3b2121-f194-4ba4-9fbf-4",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -316,6 +336,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-5", "id": "8f3b2121-f194-4ba4-9fbf-5",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -487,6 +511,10 @@ describe('generateWorkflowRunDiagram', () => {
"diagram": { "diagram": {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-6", "id": "8f3b2121-f194-4ba4-9fbf-6",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -497,6 +525,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-7", "id": "8f3b2121-f194-4ba4-9fbf-7",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -506,6 +538,10 @@ describe('generateWorkflowRunDiagram', () => {
"target": "step2", "target": "step2",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-8", "id": "8f3b2121-f194-4ba4-9fbf-8",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -695,6 +731,10 @@ describe('generateWorkflowRunDiagram', () => {
"diagram": { "diagram": {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-9", "id": "8f3b2121-f194-4ba4-9fbf-9",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -705,6 +745,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-10", "id": "8f3b2121-f194-4ba4-9fbf-10",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",
@ -715,6 +759,10 @@ describe('generateWorkflowRunDiagram', () => {
"type": "success", "type": "success",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-11", "id": "8f3b2121-f194-4ba4-9fbf-11",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -724,6 +772,10 @@ describe('generateWorkflowRunDiagram', () => {
"target": "step3", "target": "step3",
}, },
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-12", "id": "8f3b2121-f194-4ba4-9fbf-12",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",
@ -873,6 +925,10 @@ describe('generateWorkflowRunDiagram', () => {
"diagram": { "diagram": {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-13", "id": "8f3b2121-f194-4ba4-9fbf-13",
"markerEnd": "workflow-edge-green-arrow-rounded", "markerEnd": "workflow-edge-green-arrow-rounded",

View File

@ -129,6 +129,10 @@ describe('getWorkflowVersionDiagram', () => {
{ {
"edges": [ "edges": [
{ {
"data": {
"edgeType": "default",
"isEdgeEditable": false,
},
"deletable": false, "deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-0", "id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "workflow-edge-arrow-rounded", "markerEnd": "workflow-edge-arrow-rounded",

View File

@ -25,7 +25,10 @@ describe('transformFilterNodesAsEdges', () => {
id: 'A-C', id: 'A-C',
source: 'A', source: 'A',
target: 'C', target: 'C',
data: { stepId: 'A', shouldDisplayEdgeOptions: true }, data: {
edgeType: 'default',
isEdgeEditable: true,
},
}, },
], ],
}; };
@ -64,13 +67,13 @@ describe('transformFilterNodesAsEdges', () => {
id: 'A-B', id: 'A-B',
source: 'A', source: 'A',
target: 'B', target: 'B',
data: { stepId: 'A', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
{ {
id: 'B-C', id: 'B-C',
source: 'B', source: 'B',
target: 'C', target: 'C',
data: { stepId: 'B', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
], ],
}; };
@ -98,9 +101,12 @@ describe('transformFilterNodesAsEdges', () => {
source: 'A', source: 'A',
target: 'C', target: 'C',
data: { data: {
shouldDisplayEdgeOptions: true, edgeType: 'filter',
stepId: 'B', stepId: 'B',
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, name: 'Filter B',
runStatus: undefined,
filterSettings: {},
isEdgeEditable: false,
}, },
}); });
}); });
@ -147,25 +153,25 @@ describe('transformFilterNodesAsEdges', () => {
id: 'A-B1', id: 'A-B1',
source: 'A', source: 'A',
target: 'B1', target: 'B1',
data: { stepId: 'A', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
{ {
id: 'B1-C', id: 'B1-C',
source: 'B1', source: 'B1',
target: 'C', target: 'C',
data: { stepId: 'B1', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
{ {
id: 'C-B2', id: 'C-B2',
source: 'C', source: 'C',
target: 'B2', target: 'B2',
data: { stepId: 'C', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
{ {
id: 'B2-D', id: 'B2-D',
source: 'B2', source: 'B2',
target: 'D', target: 'D',
data: { stepId: 'B2', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
], ],
}; };
@ -189,9 +195,12 @@ describe('transformFilterNodesAsEdges', () => {
source: 'A', source: 'A',
target: 'C', target: 'C',
data: { data: {
edgeType: 'filter',
name: 'Filter B1',
runStatus: undefined,
stepId: 'B1', stepId: 'B1',
shouldDisplayEdgeOptions: true, filterSettings: {},
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' }, isEdgeEditable: false,
}, },
}); });
@ -203,9 +212,12 @@ describe('transformFilterNodesAsEdges', () => {
source: 'C', source: 'C',
target: 'D', target: 'D',
data: { data: {
edgeType: 'filter',
name: 'Filter B2',
runStatus: undefined,
stepId: 'B2', stepId: 'B2',
shouldDisplayEdgeOptions: true, filterSettings: {},
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' }, isEdgeEditable: false,
}, },
}); });
}); });
@ -229,7 +241,7 @@ describe('transformFilterNodesAsEdges', () => {
id: 'A-B', id: 'A-B',
source: 'A', source: 'A',
target: 'B', target: 'B',
data: { stepId: 'A', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
], ],
}; };
@ -281,13 +293,13 @@ describe('transformFilterNodesAsEdges', () => {
id: 'trigger-B', id: 'trigger-B',
source: 'trigger', source: 'trigger',
target: 'B', target: 'B',
data: { stepId: 'trigger', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
{ {
id: 'B-C', id: 'B-C',
source: 'B', source: 'B',
target: 'C', target: 'C',
data: { stepId: 'B', shouldDisplayEdgeOptions: true }, data: { edgeType: 'default', isEdgeEditable: true },
}, },
], ],
}; };
@ -319,13 +331,12 @@ describe('transformFilterNodesAsEdges', () => {
source: 'trigger', source: 'trigger',
target: 'C', target: 'C',
data: { data: {
edgeType: 'filter',
name: 'Filter B',
runStatus: undefined,
stepId: 'B', stepId: 'B',
shouldDisplayEdgeOptions: true, filterSettings: {},
filter: { isEdgeEditable: false,
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
},
}, },
}, },
]); ]);

View File

@ -1,4 +1,5 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isDefined } from 'twenty-shared/utils';
export const addEdgeOptions = ({ export const addEdgeOptions = ({
nodes, nodes,
@ -7,11 +8,15 @@ export const addEdgeOptions = ({
return { return {
nodes, nodes,
edges: edges.map((edge) => { edges: edges.map((edge) => {
if (!isDefined(edge.data)) {
throw new Error('Edge data must be defined');
}
return { return {
...edge, ...edge,
data: { data: {
...edge.data, ...edge.data,
shouldDisplayEdgeOptions: true, isEdgeEditable: true,
}, },
}; };
}), }),

View File

@ -1,4 +1,7 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import {
WorkflowDiagram,
WorkflowDiagramEdge,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
export const transformFilterNodesAsEdges = ({ export const transformFilterNodesAsEdges = ({
@ -29,14 +32,25 @@ export const transformFilterNodesAsEdges = ({
const outgoingEdge = edges.find((edge) => edge.source === filterNode.id); const outgoingEdge = edges.find((edge) => edge.source === filterNode.id);
if (isDefined(incomingEdge) && isDefined(outgoingEdge)) { if (isDefined(incomingEdge) && isDefined(outgoingEdge)) {
const newEdge = { if (
filterNode.data.nodeType !== 'action' ||
filterNode.data.actionType !== 'FILTER'
) {
throw new Error('Expected the filter node to be of action type');
}
const newEdge: WorkflowDiagramEdge = {
...incomingEdge, ...incomingEdge,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`, id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target, target: outgoingEdge.target,
data: { data: {
...incomingEdge.data, edgeType: 'filter',
stepId: filterNode.id, stepId: filterNode.id,
filter: filterNode.data, // TODO: Get the filter settings from the filter node
filterSettings: {},
name: filterNode.data.name,
runStatus: filterNode.data.runStatus,
isEdgeEditable: false,
}, },
}; };

View File

@ -8,12 +8,13 @@ import {
WorkflowVersion, WorkflowVersion,
WorkflowWithCurrentVersion, WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
export const useDeleteStep = ({ export const useDeleteStep = ({
workflow, workflow,
}: { }: {
workflow: WorkflowWithCurrentVersion; workflow: WorkflowWithCurrentVersion | undefined;
}) => { }) => {
const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep(); const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep();
const { updateOneRecord: updateOneWorkflowVersion } = const { updateOneRecord: updateOneWorkflowVersion } =
@ -26,8 +27,12 @@ export const useDeleteStep = ({
const { closeCommandMenu } = useCommandMenu(); const { closeCommandMenu } = useCommandMenu();
const deleteStep = async (stepId: string) => { const deleteStep = async (stepId: string) => {
assertWorkflowWithCurrentVersionIsDefined(workflow);
closeCommandMenu(); closeCommandMenu();
const workflowVersionId = await getUpdatableWorkflowVersion(workflow); const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
if (stepId === TRIGGER_STEP_ID) { if (stepId === TRIGGER_STEP_ID) {
await updateOneWorkflowVersion({ await updateOneWorkflowVersion({
idToUpdate: workflowVersionId, idToUpdate: workflowVersionId,

View File

@ -0,0 +1,251 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { getTriggerHeaderType } from '../getTriggerHeaderType';
describe('getTriggerHeaderType', () => {
describe('DATABASE_EVENT triggers', () => {
it('returns "Trigger · Record is created" for created event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Created',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Record is created');
});
it('returns "Trigger · Record is updated" for updated event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Updated',
settings: {
eventName: 'company.updated',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Record is updated');
});
it('returns "Trigger · Record is deleted" for deleted event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Deleted',
settings: {
eventName: 'company.deleted',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Record is deleted');
});
it('works with different object types', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Person Created',
settings: {
eventName: 'person.created',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Record is created');
});
});
describe('MANUAL triggers', () => {
it('returns "Trigger · Manual" for manual trigger', () => {
const trigger: WorkflowTrigger = {
type: 'MANUAL',
name: 'Manual Trigger',
settings: {
objectType: 'company',
outputSchema: {},
icon: 'IconHandMove',
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Manual');
});
it('returns "Trigger · Manual" for manual trigger without objectType', () => {
const trigger: WorkflowTrigger = {
type: 'MANUAL',
name: 'Manual Trigger',
settings: {
outputSchema: {},
icon: 'IconHandMove',
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Manual');
});
});
describe('CRON triggers', () => {
it('returns "Trigger" for cron trigger with DAYS schedule', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Scheduled Trigger',
settings: {
type: 'DAYS',
schedule: {
day: 1,
hour: 9,
minute: 0,
},
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger');
});
it('returns "Trigger" for cron trigger with HOURS schedule', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Hourly Trigger',
settings: {
type: 'HOURS',
schedule: {
hour: 2,
minute: 30,
},
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger');
});
it('returns "Trigger" for cron trigger with MINUTES schedule', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Minutely Trigger',
settings: {
type: 'MINUTES',
schedule: {
minute: 15,
},
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger');
});
it('returns "Trigger" for cron trigger with CUSTOM schedule', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Custom Trigger',
settings: {
type: 'CUSTOM',
pattern: '0 9 * * 1',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger');
});
});
describe('WEBHOOK triggers', () => {
it('returns "Trigger · Webhook" for webhook trigger with GET method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'GET',
authentication: null,
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Webhook');
});
it('returns "Trigger · Webhook" for webhook trigger with POST method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'POST',
expectedBody: {
message: 'Workflow was started',
},
authentication: null,
outputSchema: {
message: {
icon: 'IconVariable',
isLeaf: true,
label: 'message',
type: 'string',
value: 'Workflow was started',
},
},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Webhook');
});
it('returns "Trigger · Webhook" for webhook trigger with API_KEY authentication', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Secure Webhook Trigger',
settings: {
httpMethod: 'GET',
authentication: 'API_KEY',
outputSchema: {},
},
};
const result = getTriggerHeaderType(trigger);
expect(result).toBe('Trigger · Webhook');
});
});
describe('error cases', () => {
it('throws error for unknown trigger type', () => {
const trigger = {
type: 'UNKNOWN_TYPE',
name: 'Unknown Trigger',
settings: {
outputSchema: {},
},
} as unknown as WorkflowTrigger;
expect(() => getTriggerHeaderType(trigger)).toThrow(
'Unknown trigger type',
);
});
});
});

View File

@ -0,0 +1,56 @@
/* eslint-disable @nx/workspace-no-hardcoded-colors */
import { Theme } from '@emotion/react';
import { getTriggerIconColor } from '../getTriggerIconColor';
describe('getTriggerIconColor', () => {
const mockTheme: Theme = {
font: {
color: {
primary: '#2c2c2c',
secondary: '#666666',
tertiary: '#999999',
light: '#cccccc',
},
},
} as unknown as Theme;
it('returns the tertiary font color from theme', () => {
const result = getTriggerIconColor({ theme: mockTheme });
expect(result).toBe('#999999');
});
it('works with different theme configurations', () => {
const differentTheme: Theme = {
font: {
color: {
primary: '#000000',
secondary: '#444444',
tertiary: '#888888',
light: '#ffffff',
},
},
} as unknown as Theme;
const result = getTriggerIconColor({ theme: differentTheme });
expect(result).toBe('#888888');
});
it('maintains reference to theme.font.color.tertiary', () => {
const customTheme: Theme = {
font: {
color: {
primary: '#111111',
secondary: '#333333',
tertiary: '#custom-tertiary-color',
light: '#eeeeee',
},
},
} as unknown as Theme;
const result = getTriggerIconColor({ theme: customTheme });
expect(result).toBe('#custom-tertiary-color');
});
});