diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 8e57eca80..8a3d0cac8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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 = { diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 15f5cc988..468951424 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -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 = { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index d9bf1284f..fc42b7912 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -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} diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx index fc508b3f4..8d5f87d3a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx @@ -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; @@ -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 && ( - + + {isWorkflowFilteringEnabled ? ( + + ) : ( + + )} + )} ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1.tsx similarity index 63% rename from packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx rename to packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1.tsx index d98185ba4..1c48bac3c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV1.tsx @@ -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 ( - - + setHovered(true)} + onMouseLeave={() => setHovered(false)} > - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - {(hovered || isSelected) && ( - { - startNodeCreation({ parentStepId, nextStepId }); - }, + + {(hovered || isSelected) && ( + { + startNodeCreation({ parentStepId, nextStepId }); }, - ]} - /> - )} - - - + }, + ]} + /> + )} + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2.tsx new file mode 100644 index 000000000..b14fb982d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2.tsx @@ -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 ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {}, + }, + { + Icon: IconDotsVertical, + onClick: () => { + openDropdown({ + dropdownComponentInstanceIdFromProps: dropdownId, + }); + }, + }, + ]} + /> + + } + data-select-disable + dropdownPlacement="bottom-start" + dropdownStrategy="absolute" + dropdownOffset={{ + x: 0, + y: 4, + }} + onOpen={() => { + setWorkflowDiagramPanOnDrag(false); + }} + onClose={() => { + setWorkflowDiagramPanOnDrag(true); + }} + dropdownComponents={ + + + {}} + /> + {}} + /> + { + closeDropdown(dropdownId); + setHovered(false); + + startNodeCreation({ parentStepId, nextStepId }); + }} + /> + {}} + /> + + + } + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx new file mode 100644 index 000000000..5bd3f2380 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx @@ -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 = { + title: 'Modules/Workflow/WorkflowDiagramEdgeV2', + component: WorkflowDiagramEdgeV2, + decorators: [ + ComponentDecorator, + ReactflowDecorator, + (Story) => { + const workflowVisualizerComponentInstanceId = + 'workflow-visualizer-test-id'; + + return ( + + + + ); + }, + ], + args: { + labelX: 0, + labelY: 0, + parentStepId: 'parent-step-id', + nextStepId: 'next-step-id', + }, +}; + +export default meta; +type Story = StoryObj; + +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(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState.ts new file mode 100644 index 000000000..7dfbcb3fe --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState.ts @@ -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({ + key: 'workflowDiagramPanOnDragComponentState', + defaultValue: true, + componentInstanceContext: WorkflowVisualizerComponentInstanceContext, + }); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 69ee5b76b..1a7ce3cd8 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.spec.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.spec.ts index 5bc5c308c..ce8c9e99b 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.spec.ts @@ -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'); diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 2e0d02d0a..44c9a953d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -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, diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 83870d2a1..b43141066 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -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, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index fc5b9f2c9..254e00558 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -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,