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:
Baptiste Devessier
2025-07-02 17:01:44 +02:00
committed by GitHub
parent ba67e0d5f4
commit e8a2d71844
21 changed files with 818 additions and 204 deletions
@@ -50,8 +50,10 @@ export const WorkflowDiagramDefaultEdge = ({
<WorkflowDiagramEdgeV2
labelX={labelX}
labelY={labelY}
stepId={data.stepId}
parentStepId={source}
nextStepId={target}
filter={data.filter}
/>
) : (
<WorkflowDiagramEdgeV1
@@ -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 });
}
}}
/>
);
};
@@ -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>
);
};
@@ -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);
});
},
};