Add workflow filters on diagram (#12974)

> [!NOTE]
> The new behavior is hidden behind a feature flag. 

## Before


https://github.com/user-attachments/assets/30c6d001-d9c8-4006-b577-e4e450d58b12

## After


https://github.com/user-attachments/assets/79446976-4508-41d2-8044-4078f67c02e0
This commit is contained in:
Baptiste Devessier
2025-07-01 10:35:18 +02:00
committed by GitHub
parent 7756b472a4
commit 34e9e7d836
13 changed files with 400 additions and 43 deletions

View File

@ -697,7 +697,8 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
}
export type Field = {

View File

@ -653,7 +653,8 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
}
export type Field = {

View File

@ -8,6 +8,7 @@ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-sta
import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/components/WorkflowDiagramCustomMarkers';
import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowDiagramWaitingNodesDimensionsComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState';
import {
WorkflowDiagram,
@ -132,6 +133,9 @@ export const WorkflowDiagramCanvasBase = ({
const workflowDiagram = useRecoilComponentValueV2(
workflowDiagramComponentState,
);
const workflowDiagramPanOnDrag = useRecoilComponentValueV2(
workflowDiagramPanOnDragComponentState,
);
const workflowDiagramState = useRecoilComponentCallbackStateV2(
workflowDiagramComponentState,
);
@ -383,6 +387,7 @@ export const WorkflowDiagramCanvasBase = ({
nodesFocusable={false}
edgesFocusable={false}
nodesDraggable={false}
panOnDrag={workflowDiagramPanOnDrag}
nodesConnectable={false}
paneClickDistance={10} // Fix small unwanted user dragging does not select node
preventScrolling={false}

View File

@ -1,8 +1,16 @@
import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
import { WorkflowDiagramEdgeV1 } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1';
import { WorkflowDiagramEdgeV2 } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdgeOptions } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
} from '@xyflow/react';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramDefaultEdgeProps = EdgeProps<WorkflowDiagramEdge>;
@ -17,6 +25,10 @@ export const WorkflowDiagramDefaultEdge = ({
}: WorkflowDiagramDefaultEdgeProps) => {
const theme = useTheme();
const isWorkflowFilteringEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_FILTERING_ENABLED,
);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
sourceY,
@ -33,12 +45,22 @@ export const WorkflowDiagramDefaultEdge = ({
style={{ stroke: theme.border.color.strong }}
/>
{data?.shouldDisplayEdgeOptions && (
<WorkflowDiagramEdgeOptions
labelX={labelX}
labelY={labelY}
parentStepId={source}
nextStepId={target}
/>
<EdgeLabelRenderer>
{isWorkflowFilteringEnabled ? (
<WorkflowDiagramEdgeV2
labelX={labelX}
labelY={labelY}
parentStepId={source}
nextStepId={target}
/>
) : (
<WorkflowDiagramEdgeV1
labelY={labelY}
parentStepId={source}
nextStepId={target}
/>
)}
</EdgeLabelRenderer>
)}
</>
);

View File

@ -1,12 +1,11 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import styled from '@emotion/styled';
import { EdgeLabelRenderer } from '@xyflow/react';
import { useState } from 'react';
import { IconPlus } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { useState } from 'react';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
@ -32,18 +31,17 @@ const StyledWrapper = styled.div`
position: relative;
`;
type WorkflowDiagramEdgeOptionsProps = {
labelX?: number;
type WorkflowDiagramEdgeV1Props = {
labelY?: number;
parentStepId: string;
nextStepId: string;
};
export const WorkflowDiagramEdgeOptions = ({
export const WorkflowDiagramEdgeV1 = ({
labelY,
parentStepId,
nextStepId,
}: WorkflowDiagramEdgeOptionsProps) => {
}: WorkflowDiagramEdgeV1Props) => {
const [hovered, setHovered] = useState(false);
const { startNodeCreation } = useStartNodeCreation();
@ -57,31 +55,29 @@ export const WorkflowDiagramEdgeOptions = ({
workflowInsertStepIds.nextStepId === nextStepId;
return (
<EdgeLabelRenderer>
<StyledContainer
labelY={labelY}
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
<StyledContainer
labelY={labelY}
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
>
<StyledWrapper
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<StyledWrapper
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<StyledHoverZone />
{(hovered || isSelected) && (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconPlus,
onClick: () => {
startNodeCreation({ parentStepId, nextStepId });
},
<StyledHoverZone />
{(hovered || isSelected) && (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconPlus,
onClick: () => {
startNodeCreation({ parentStepId, nextStepId });
},
]}
/>
)}
</StyledWrapper>
</StyledContainer>
</EdgeLabelRenderer>
},
]}
/>
)}
</StyledWrapper>
</StyledContainer>
);
};

View File

@ -0,0 +1,164 @@
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 { 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;
`;
type WorkflowDiagramEdgeV2Props = {
labelX: number;
labelY: number;
parentStepId: string;
nextStepId: string;
};
export const WorkflowDiagramEdgeV2 = ({
labelX,
labelY,
parentStepId,
nextStepId,
}: WorkflowDiagramEdgeV2Props) => {
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
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}
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,
});
},
},
]}
/>
<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>
);
};

View File

@ -0,0 +1,138 @@
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { Meta, StoryObj } from '@storybook/react';
import { expect, 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';
const meta: Meta<typeof WorkflowDiagramEdgeV2> = {
title: 'Modules/Workflow/WorkflowDiagramEdgeV2',
component: WorkflowDiagramEdgeV2,
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',
},
};
export default meta;
type Story = StoryObj<typeof WorkflowDiagramEdgeV2>;
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 }) => {
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);
// TODO: Assert we created a filter
},
};
export const AddNodeAction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const dotsButton = buttons[1];
userEvent.hover(dotsButton);
await waitFor(() => {
expect(dotsButton).toBeVisible();
});
userEvent.click(dotsButton);
const addNodeButton = await within(
getCanvasElementForDropdownTesting(),
).findByText('Add Node');
userEvent.click(addNodeButton);
await waitFor(() => {
expect(canvas.queryByText('Add Node')).not.toBeInTheDocument();
});
},
};
export const DropdownInteractions: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
const dotsButton = buttons[1];
userEvent.hover(dotsButton);
await waitFor(() => {
expect(dotsButton).toBeVisible();
});
userEvent.click(dotsButton);
const dropdownCanvas = within(getCanvasElementForDropdownTesting());
await waitFor(() => {
expect(dropdownCanvas.getByText('Filter')).toBeVisible();
});
userEvent.click(canvasElement);
await waitFor(() => {
expect(dropdownCanvas.queryByText('Filter')).not.toBeInTheDocument();
});
userEvent.click(dotsButton);
await waitFor(() => {
expect(dropdownCanvas.getByText('Filter')).toBeVisible();
});
},
};

View File

@ -0,0 +1,9 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
export const workflowDiagramPanOnDragComponentState =
createComponentStateV2<boolean>({
key: 'workflowDiagramPanOnDragComponentState',
defaultValue: true,
componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
});

View File

@ -6,4 +6,5 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
}

View File

@ -12,6 +12,15 @@ describe('featureFlagValidator', () => {
).not.toThrow();
});
it('should not throw error for new workflow filtering feature flag', () => {
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(
'IS_WORKFLOW_FILTERING_ENABLED',
new CustomException('Error', 'Error'),
),
).not.toThrow();
});
it('should throw error if featureFlagKey is invalid', () => {
const invalidKey = 'InvalidKey';
const exception = new CustomException('Error', 'Error');

View File

@ -40,6 +40,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_WORKFLOW_FILTERING_ENABLED,
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_IMAP_ENABLED,
workspaceId: workspaceId,

View File

@ -142,6 +142,8 @@ export {
IconFilter,
IconFilterCog,
IconFilterOff,
IconFilterPlus,
IconFilterX,
IconFlag,
IconFlask,
IconFocusCentered,
@ -152,6 +154,7 @@ export {
IconForbid,
IconFunction,
IconGauge,
IconGitBranchDeleted,
IconGitCommit,
IconGripVertical,
IconH1,

View File

@ -204,6 +204,8 @@ export {
IconFilter,
IconFilterCog,
IconFilterOff,
IconFilterPlus,
IconFilterX,
IconFlag,
IconFlask,
IconFocusCentered,
@ -214,6 +216,7 @@ export {
IconForbid,
IconFunction,
IconGauge,
IconGitBranchDeleted,
IconGitCommit,
IconGripVertical,
IconH1,