diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 3dd492559b75..0478db13abe5 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -30,6 +30,7 @@ "workerDirectory": "public" }, "dependencies": { + "@xyflow/react": "^12.0.4", "transliteration": "^2.3.5" } } diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index 4f1267000478..3b165129832e 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -84,6 +84,7 @@ import { SettingsBilling } from '~/pages/settings/SettingsBilling'; import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; +import { WorkflowShowPage } from '~/pages/workflows/WorkflowShowPage'; import { getPageTitleFromPath } from '~/utils/title-utils'; const ProvidersThatNeedRouterContext = () => { @@ -135,6 +136,7 @@ const createRouter = ( isBillingEnabled?: boolean, isCRMMigrationEnabled?: boolean, isServerlessFunctionSettingsEnabled?: boolean, + isWorkflowEnabled?: boolean, ) => createBrowserRouter( createRoutesFromElements( @@ -163,6 +165,13 @@ const createRouter = ( } /> } /> + {isWorkflowEnabled === true ? ( + } + /> + ) : null} + { const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled( 'IS_FUNCTION_SETTINGS_ENABLED', ); + const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; @@ -336,6 +346,7 @@ export const App = () => { isBillingPageEnabled, isCRMMigrationEnabled, isServerlessFunctionSettingsEnabled, + isWorkflowEnabled, )} /> ); diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index f0b76f473928..2255c8cb56b4 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -28,4 +28,5 @@ export enum CoreObjectNameSingular { Webhook = 'webhook', WorkspaceMember = 'workspaceMember', MessageThreadSubscriber = 'messageThreadSubscriber', + Workflow = 'workflow', } diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index dbbab7ad239e..12d6f7c19e7f 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -26,6 +26,8 @@ export enum AppPath { Developers = `developers`, DevelopersCatchAll = `/${Developers}/*`, + WorkflowShowPage = `/workflow/:workflowId`, + // Impersonate Impersonate = '/impersonate/:userId', diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx index 6bcf5a056c7f..bf5ab29bebf6 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx @@ -12,6 +12,7 @@ import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/Compo import { isDefined } from 'twenty-ui'; import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { RightDrawerPages } from '../types/RightDrawerPages'; +import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow'; const StyledRightDrawerPage = styled.div` display: flex; @@ -35,6 +36,7 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = { [RightDrawerPages.ViewCalendarEvent]: , [RightDrawerPages.ViewRecord]: , [RightDrawerPages.Copilot]: , + [RightDrawerPages.Workflow]: , }; export const RightDrawerRouter = () => { diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts index 5b8c112be571..aed035783839 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageIcons.ts @@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = { [RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent', [RightDrawerPages.ViewRecord]: 'Icon123', [RightDrawerPages.Copilot]: 'IconSparkles', + [RightDrawerPages.Workflow]: 'IconSparkles', }; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts index 9bf58e09c518..bb74c9da8153 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerPageTitles.ts @@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = { [RightDrawerPages.ViewCalendarEvent]: 'Calendar Event', [RightDrawerPages.ViewRecord]: 'Record Editor', [RightDrawerPages.Copilot]: 'Copilot', + [RightDrawerPages.Workflow]: 'Workflow', }; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts index 200602767ae6..2217d437f4fd 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/types/RightDrawerPages.ts @@ -3,4 +3,5 @@ export enum RightDrawerPages { ViewCalendarEvent = 'view-calendar-event', ViewRecord = 'view-record', Copilot = 'copilot', + Workflow = 'workflow', } diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx new file mode 100644 index 000000000000..b12e749a899e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflow.tsx @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + justify-content: flex-start; + overflow-y: auto; + position: relative; +`; + +const StyledChatArea = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow-y: scroll; + padding: ${({ theme }) => theme.spacing(6)}; + padding-bottom: 0px; +`; + +const StyledNewMessageArea = styled.div` + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(6)}; + padding-top: 0px; +`; + +export const RightDrawerWorkflow = () => { + const handleCreateCodeBlock = () => {}; + + return ( + + {/* TODO */} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx new file mode 100644 index 000000000000..090d074ad131 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagram.tsx @@ -0,0 +1,86 @@ +import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx'; +import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode'; +import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; +import { + WorkflowDiagram, + WorkflowDiagramEdge, + WorkflowDiagramNode, +} from '@/workflow/types/WorkflowDiagram'; +import { getOrganizedDiagram } from '@/workflow/utils/getOrganizedDiagram'; +import { + applyEdgeChanges, + applyNodeChanges, + Background, + EdgeChange, + NodeChange, + ReactFlow, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { useMemo } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { GRAY_SCALE, isDefined } from 'twenty-ui'; + +export const WorkflowShowPageDiagram = ({ + diagram, +}: { + diagram: WorkflowDiagram; +}) => { + const { nodes, edges } = useMemo( + () => getOrganizedDiagram(diagram), + [diagram], + ); + + const setShowPageWorkflowDiagram = useSetRecoilState( + showPageWorkflowDiagramState, + ); + + const handleNodesChange = ( + nodeChanges: Array>, + ) => { + setShowPageWorkflowDiagram((diagram) => { + if (isDefined(diagram) === false) { + throw new Error( + 'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.', + ); + } + + return { + ...diagram, + nodes: applyNodeChanges(nodeChanges, diagram.nodes), + }; + }); + }; + + const handleEdgesChange = ( + edgeChanges: Array>, + ) => { + setShowPageWorkflowDiagram((diagram) => { + if (isDefined(diagram) === false) { + throw new Error( + 'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.', + ); + } + + return { + ...diagram, + edges: applyEdgeChanges(edgeChanges, diagram.edges), + }; + }); + }; + + return ( + ({ ...node, draggable: false }))} + edges={edges} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} + > + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx new file mode 100644 index 000000000000..a799480e81ff --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx.tsx @@ -0,0 +1,26 @@ +import { IconButton } from '@/ui/input/button/components/IconButton'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import styled from '@emotion/styled'; +import { Handle, Position } from '@xyflow/react'; +import { IconPlus } from 'twenty-ui'; + +export const StyledTargetHandle = styled(Handle)` + visibility: hidden; +`; + +export const WorkflowShowPageDiagramCreateStepNode = () => { + const { openRightDrawer } = useRightDrawer(); + + const handleCreateStepNodeButtonClick = () => { + openRightDrawer(RightDrawerPages.Workflow); + }; + + return ( +
+ + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramStepNode.tsx new file mode 100644 index 000000000000..f8f9a3719f12 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageDiagramStepNode.tsx @@ -0,0 +1,87 @@ +import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; +import styled from '@emotion/styled'; +import { Handle, Position } from '@xyflow/react'; + +const StyledStepNodeContainer = styled.div` + display: flex; + flex-direction: column; + + padding-bottom: 12px; + padding-top: 6px; +`; + +const StyledStepNodeType = styled.div` + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.sm} + ${({ theme }) => theme.border.radius.sm} 0 0; + + color: ${({ theme }) => theme.color.gray50}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; + position: absolute; + top: 0; + transform: translateY(-100%); + + .selectable.selected &, + .selectable:focus &, + .selectable:focus-visible & { + background-color: ${({ theme }) => theme.color.blue}; + color: ${({ theme }) => theme.font.color.inverted}; + } +`; + +const StyledStepNodeInnerContainer = styled.div` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + + position: relative; + box-shadow: ${({ theme }) => theme.boxShadow.superHeavy}; + + .selectable.selected &, + .selectable:focus &, + .selectable:focus-visible & { + background-color: ${({ theme }) => theme.color.blue10}; + border-color: ${({ theme }) => theme.color.blue}; + } +`; + +const StyledStepNodeLabel = styled.div` + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledSourceHandle = styled(Handle)` + background-color: ${({ theme }) => theme.color.gray50}; +`; + +export const StyledTargetHandle = styled(Handle)` + visibility: hidden; +`; + +export const WorkflowShowPageDiagramStepNode = ({ + data, +}: { + data: WorkflowDiagramStepNodeData; +}) => { + return ( + + {data.nodeType !== 'trigger' ? ( + + ) : null} + + + {data.nodeType} + + {data.label} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx new file mode 100644 index 000000000000..6d41e5d7fd4b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageEffect.tsx @@ -0,0 +1,61 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; +import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState'; +import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState'; +import { Workflow } from '@/workflow/types/Workflow'; +import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes'; +import { getWorkflowLastDiagramVersion } from '@/workflow/utils/getWorkflowLastDiagramVersion'; +import { useEffect } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +type WorkflowShowPageEffectProps = { + workflowId: string; +}; + +export const WorkflowShowPageEffect = ({ + workflowId, +}: WorkflowShowPageEffectProps) => { + const { + record: workflow, + loading, + error, + } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.Workflow, + objectRecordId: workflowId, + recordGqlFields: { + id: true, + name: true, + versions: true, + publishedVersionId: true, + }, + }); + + const setCurrentWorkflowData = useSetRecoilState( + showPageWorkflowDiagramState, + ); + const setCurrentWorkflowLoading = useSetRecoilState( + showPageWorkflowLoadingState, + ); + const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState); + + useEffect(() => { + const flowLastVersion = getWorkflowLastDiagramVersion(workflow); + const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion); + + setCurrentWorkflowData( + isDefined(workflow) ? flowWithCreateStepNodes : undefined, + ); + }, [setCurrentWorkflowData, workflow]); + + useEffect(() => { + setCurrentWorkflowLoading(loading); + }, [loading, setCurrentWorkflowLoading]); + + useEffect(() => { + setCurrentWorkflowError(error); + }, [error, setCurrentWorkflowError]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageHeader.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageHeader.tsx new file mode 100644 index 000000000000..7534fce17f12 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowShowPageHeader.tsx @@ -0,0 +1,30 @@ +import { PageHeader } from '@/ui/layout/page/PageHeader'; +import { useNavigate } from 'react-router-dom'; +import { IconComponent } from 'twenty-ui'; + +export const WorkflowShowPageHeader = ({ + workflowName, + headerIcon, + children, +}: { + workflowName: string; + headerIcon: IconComponent; + children?: React.ReactNode; +}) => { + const navigate = useNavigate(); + + return ( + { + navigate({ + pathname: '/objects/workflows', + }); + }} + title={workflowName} + Icon={headerIcon} + > + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramState.ts new file mode 100644 index 000000000000..614aca700b6a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowDiagramState.ts @@ -0,0 +1,9 @@ +import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; +import { createState } from 'twenty-ui'; + +export const showPageWorkflowDiagramState = createState< + WorkflowDiagram | undefined +>({ + key: 'showPageWorkflowDiagramState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowErrorState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowErrorState.ts new file mode 100644 index 000000000000..f49f5cd90099 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowErrorState.ts @@ -0,0 +1,7 @@ +import { ApolloError } from '@apollo/client'; +import { createState } from 'twenty-ui'; + +export const showPageWorkflowErrorState = createState({ + key: 'showPageWorkflowErrorState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/workflow/states/showPageWorkflowLoadingState.ts b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowLoadingState.ts new file mode 100644 index 000000000000..0c5342abb3a6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/states/showPageWorkflowLoadingState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const showPageWorkflowLoadingState = createState({ + key: 'showPageWorkflowLoadingState', + defaultValue: true, +}); diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts new file mode 100644 index 000000000000..ea5d39d1ec86 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -0,0 +1,66 @@ +type WorkflowBaseSettingsType = { + errorHandlingOptions: { + retryOnFailure: { + value: boolean; + }; + continueOnFailure: { + value: boolean; + }; + }; +}; + +export type WorkflowCodeSettingsType = WorkflowBaseSettingsType & { + serverlessFunctionId: string; +}; + +export type WorkflowActionType = 'CODE_ACTION'; + +type CommonWorkflowAction = { + id: string; + name: string; + valid: boolean; +}; + +type WorkflowCodeAction = CommonWorkflowAction & { + type: 'CODE_ACTION'; + settings: WorkflowCodeSettingsType; +}; + +export type WorkflowAction = WorkflowCodeAction; + +export type WorkflowStep = WorkflowAction; + +export type WorkflowTriggerType = 'DATABASE_EVENT'; + +type BaseTrigger = { + type: WorkflowTriggerType; + input?: object; +}; + +export type WorkflowDatabaseEventTrigger = BaseTrigger & { + type: 'DATABASE_EVENT'; + settings: { + eventName: string; + }; +}; + +export type WorkflowTrigger = WorkflowDatabaseEventTrigger; + +export type WorkflowVersion = { + id: string; + name: string; + createdAt: string; + updatedAt: string; + workflowId: string; + trigger: WorkflowTrigger; + steps: Array; + __typename: 'WorkflowVersion'; +}; + +export type Workflow = { + __typename: 'Workflow'; + id: string; + name: string; + versions: Array; + publishedVersionId: string; +}; diff --git a/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts new file mode 100644 index 000000000000..f97d5027ed0c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/types/WorkflowDiagram.ts @@ -0,0 +1,20 @@ +import { Edge, Node } from '@xyflow/react'; + +export type WorkflowDiagramNode = Node; +export type WorkflowDiagramEdge = Edge; + +export type WorkflowDiagram = { + nodes: Array; + edges: Array; +}; + +export type WorkflowDiagramStepNodeData = { + nodeType: 'trigger' | 'condition' | 'action'; + label: string; +}; + +export type WorkflowDiagramCreateStepNodeData = Record; + +export type WorkflowDiagramNodeData = + | WorkflowDiagramStepNodeData + | WorkflowDiagramCreateStepNodeData; diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts new file mode 100644 index 000000000000..e3326c795be0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/addCreateStepNodes.test.ts @@ -0,0 +1,63 @@ +import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; +import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram'; +import { addCreateStepNodes } from '../addCreateStepNodes'; + +describe('addCreateStepNodes', () => { + it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => { + const trigger: WorkflowTrigger = { + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + }, + }; + const steps: WorkflowStep[] = [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + ]; + + const diagramInitial = generateWorkflowDiagram({ trigger, steps }); + + expect(diagramInitial.nodes).toHaveLength(3); + expect(diagramInitial.edges).toHaveLength(2); + + const diagramWithCreateStepNodes = addCreateStepNodes(diagramInitial); + + expect(diagramWithCreateStepNodes.nodes).toHaveLength(4); + expect(diagramWithCreateStepNodes.edges).toHaveLength(3); + + expect(diagramWithCreateStepNodes.nodes[0].type).toBe(undefined); + expect(diagramWithCreateStepNodes.nodes[0].data.nodeType).toBe('trigger'); + + expect(diagramWithCreateStepNodes.nodes[1].type).toBe(undefined); + expect(diagramWithCreateStepNodes.nodes[1].data.nodeType).toBe('action'); + + expect(diagramWithCreateStepNodes.nodes[2].type).toBe(undefined); + expect(diagramWithCreateStepNodes.nodes[2].data.nodeType).toBe('action'); + + expect(diagramWithCreateStepNodes.nodes[3].type).toBe('create-step'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts new file mode 100644 index 000000000000..0716aa29bb83 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/generateWorkflowDiagram.test.ts @@ -0,0 +1,122 @@ +import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; +import { generateWorkflowDiagram } from '../generateWorkflowDiagram'; + +describe('generateWorkflowDiagram', () => { + it('should generate a single trigger node when no step is provided', () => { + const trigger: WorkflowTrigger = { + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + }, + }; + const steps: WorkflowStep[] = []; + + const result = generateWorkflowDiagram({ trigger, steps }); + + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + + expect(result.nodes[0]).toMatchObject({ + data: { + label: trigger.settings.eventName, + nodeType: 'trigger', + }, + }); + }); + + it('should generate a diagram with nodes and edges corresponding to the steps', () => { + const trigger: WorkflowTrigger = { + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + }, + }; + const steps: WorkflowStep[] = [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + ]; + + const result = generateWorkflowDiagram({ trigger, steps }); + + expect(result.nodes).toHaveLength(steps.length + 1); // All steps + trigger + expect(result.edges).toHaveLength(steps.length - 1 + 1); // Edges are one less than nodes + the edge from the trigger to the first node + + expect(result.nodes[0].data.nodeType).toBe('trigger'); + + const stepNodes = result.nodes.slice(1); + + for (const [index, step] of steps.entries()) { + expect(stepNodes[index].data.nodeType).toBe('action'); + expect(stepNodes[index].data.label).toBe(step.name); + } + }); + + it('should correctly link nodes with edges', () => { + const trigger: WorkflowTrigger = { + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + }, + }; + const steps: WorkflowStep[] = [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE_ACTION', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + }, + ]; + + const result = generateWorkflowDiagram({ trigger, steps }); + + expect(result.edges[0].source).toEqual(result.nodes[0].id); + expect(result.edges[0].target).toEqual(result.nodes[1].id); + + expect(result.edges[1].source).toEqual(result.nodes[1].id); + expect(result.edges[1].target).toEqual(result.nodes[2].id); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowLastDiagramVersion.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowLastDiagramVersion.test.ts new file mode 100644 index 000000000000..20c41c085bb1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowLastDiagramVersion.test.ts @@ -0,0 +1,79 @@ +import { Workflow } from '@/workflow/types/Workflow'; +import { getWorkflowLastDiagramVersion } from '../getWorkflowLastDiagramVersion'; + +describe('getWorkflowLastDiagramVersion', () => { + it('returns an empty diagram if the provided workflow is undefined', () => { + const result = getWorkflowLastDiagramVersion(undefined); + + expect(result).toEqual({ nodes: [], edges: [] }); + }); + + it('returns an empty diagram if the provided workflow has no versions', () => { + const result = getWorkflowLastDiagramVersion({ + __typename: 'Workflow', + id: 'aa', + name: 'aa', + publishedVersionId: '', + versions: [], + }); + + expect(result).toEqual({ nodes: [], edges: [] }); + }); + + it('returns the diagram for the last version', () => { + const workflow: Workflow = { + __typename: 'Workflow', + id: 'aa', + name: 'aa', + publishedVersionId: '', + versions: [ + { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }, + { + __typename: 'WorkflowVersion', + createdAt: '', + id: '1', + name: '', + steps: [ + { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE_ACTION', + valid: true, + }, + ], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + }, + ], + }; + + const result = getWorkflowLastDiagramVersion(workflow); + + // Corresponds to the trigger + 1 step + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts b/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts new file mode 100644 index 000000000000..9d2941adb274 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/addCreateStepNodes.ts @@ -0,0 +1,41 @@ +import { + WorkflowDiagram, + WorkflowDiagramEdge, + WorkflowDiagramNode, +} from '@/workflow/types/WorkflowDiagram'; +import { MarkerType } from '@xyflow/react'; +import { v4 } from 'uuid'; + +export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => { + const nodesWithoutTargets = nodes.filter((node) => + edges.every((edge) => edge.source !== node.id), + ); + + const updatedNodes: Array = nodes.slice(); + const updatedEdges: Array = edges.slice(); + + for (const node of nodesWithoutTargets) { + const newCreateStepNode: WorkflowDiagramNode = { + id: v4(), + type: 'create-step', + data: {}, + position: { x: 0, y: 0 }, + }; + + updatedNodes.push(newCreateStepNode); + + updatedEdges.push({ + id: v4(), + source: node.id, + target: newCreateStepNode.id, + markerEnd: { + type: MarkerType.ArrowClosed, + }, + }); + } + + return { + nodes: updatedNodes, + edges: updatedEdges, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts new file mode 100644 index 000000000000..6bb95f23cbba --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/generateWorkflowDiagram.ts @@ -0,0 +1,84 @@ +import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; +import { + WorkflowDiagram, + WorkflowDiagramEdge, + WorkflowDiagramNode, +} from '@/workflow/types/WorkflowDiagram'; +import { MarkerType } from '@xyflow/react'; +import { v4 } from 'uuid'; + +export const generateWorkflowDiagram = ({ + trigger, + steps, +}: { + trigger: WorkflowTrigger; + steps: Array; +}): WorkflowDiagram => { + const nodes: Array = []; + const edges: Array = []; + + // Helper function to generate nodes and edges recursively + const processNode = ( + step: WorkflowStep, + parentNodeId: string, + xPos: number, + yPos: number, + ) => { + const nodeId = v4(); + nodes.push({ + id: nodeId, + data: { + nodeType: 'action', + label: step.name, + }, + position: { + x: xPos, + y: yPos, + }, + }); + + // Create an edge from the parent node to this node + edges.push({ + id: v4(), + source: parentNodeId, + target: nodeId, + markerEnd: { + type: MarkerType.ArrowClosed, + }, + }); + + // Recursively generate flow for the next action if it exists + if (step.type !== 'CODE_ACTION') { + // processNode(action.nextAction, nodeId, xPos + 150, yPos + 100); + + throw new Error('Other types as code actions are not supported yet.'); + } + + return nodeId; + }; + + // Start with the trigger node + const triggerNodeId = v4(); + nodes.push({ + id: triggerNodeId, + data: { + nodeType: 'trigger', + label: trigger.settings.eventName, + }, + position: { + x: 0, + y: 0, + }, + }); + + let lastStepId = triggerNodeId; + + for (const step of steps) { + lastStepId = processNode(step, lastStepId, 150, 100); + } + + return { + nodes, + edges, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts b/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts new file mode 100644 index 000000000000..0aa687d71054 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/getOrganizedDiagram.ts @@ -0,0 +1,36 @@ +import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; +import Dagre from '@dagrejs/dagre'; + +/** + * Set the position of the nodes in the diagram. The positions are computed with a layouting algorithm. + */ +export const getOrganizedDiagram = ( + diagram: WorkflowDiagram, +): WorkflowDiagram => { + const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + graph.setGraph({ rankdir: 'TB' }); + + diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target)); + diagram.nodes.forEach((node) => + graph.setNode(node.id, { + ...node, + width: node.measured?.width ?? 0, + height: node.measured?.height ?? 0, + }), + ); + + Dagre.layout(graph); + + return { + nodes: diagram.nodes.map((node) => { + const position = graph.node(node.id); + // We are shifting the dagre node position (anchor=center center) to the top left + // so it matches the React Flow node anchor point (top left). + const x = position.x - (node.measured?.width ?? 0) / 2; + const y = position.y - (node.measured?.height ?? 0) / 2; + + return { ...node, position: { x, y } }; + }), + edges: diagram.edges, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts new file mode 100644 index 000000000000..448ed7fec4b6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/getWorkflowLastDiagramVersion.ts @@ -0,0 +1,27 @@ +import { Workflow } from '@/workflow/types/Workflow'; +import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; +import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram'; +import { isDefined } from 'twenty-ui'; + +const EMPTY_DIAGRAM: WorkflowDiagram = { + nodes: [], + edges: [], +}; + +export const getWorkflowLastDiagramVersion = ( + workflow: Workflow | undefined, +): WorkflowDiagram => { + if (!isDefined(workflow)) { + return EMPTY_DIAGRAM; + } + + const lastVersion = workflow.versions.at(-1); + if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) { + return EMPTY_DIAGRAM; + } + + return generateWorkflowDiagram({ + trigger: lastVersion.trigger, + steps: lastVersion.steps, + }); +}; diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 6ed0dd7f1e05..e1dd39384ad7 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -8,4 +8,5 @@ export type FeatureFlagKey = | 'IS_COPILOT_ENABLED' | 'IS_CRM_MIGRATION_ENABLED' | 'IS_FREE_ACCESS_ENABLED' - | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'; + | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED' + | 'IS_WORKFLOW_ENABLED'; diff --git a/packages/twenty-front/src/pages/workflows/WorkflowShowPage.tsx b/packages/twenty-front/src/pages/workflows/WorkflowShowPage.tsx new file mode 100644 index 000000000000..0a5125ef9081 --- /dev/null +++ b/packages/twenty-front/src/pages/workflows/WorkflowShowPage.tsx @@ -0,0 +1,64 @@ +import { PageBody } from '@/ui/layout/page/PageBody'; +import { PageContainer } from '@/ui/layout/page/PageContainer'; +import { PageTitle } from '@/ui/utilities/page-title/PageTitle'; +import { WorkflowShowPageDiagram } from '@/workflow/components/WorkflowShowPageDiagram'; +import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect'; +import { WorkflowShowPageHeader } from '@/workflow/components/WorkflowShowPageHeader'; +import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; +import styled from '@emotion/styled'; +import '@xyflow/react/dist/style.css'; +import { useParams } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { IconSettingsAutomation } from 'twenty-ui'; + +const StyledFlowContainer = styled.div` + height: 100%; + width: 100%; + + /* Below we reset the default styling of Reactflow */ + .react-flow__node-input, + .react-flow__node-default, + .react-flow__node-output, + .react-flow__node-group { + padding: 0; + } + + --xy-node-border-radius: none; + --xy-node-border: none; + --xy-node-background-color: none; + --xy-node-boxshadow-hover: none; + --xy-node-boxshadow-selected: none; +`; + +export const WorkflowShowPage = () => { + const parameters = useParams<{ + workflowId: string; + }>(); + + const workflowName = 'Test Workflow'; + + const showPageWorkflowDiagram = useRecoilValue(showPageWorkflowDiagramState); + + if (parameters.workflowId === undefined) { + return null; + } + + return ( + + + + + + + + {showPageWorkflowDiagram === undefined ? null : ( + + )} + + + + ); +}; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 9e05f1390ff2..aa95be3a50c6 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -153,6 +153,7 @@ export { IconSearch, IconSend, IconSettings, + IconSettingsAutomation, IconSortDescending, IconSparkles, IconSql, diff --git a/yarn.lock b/yarn.lock index da40f750f626..d6163be0f2c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15782,7 +15782,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.1": +"@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.1, @types/d3-drag@npm:^3.0.7": version: 3.0.7 resolution: "@types/d3-drag@npm:3.0.7" dependencies: @@ -15920,7 +15920,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.3": +"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10, @types/d3-selection@npm:^3.0.3": version: 3.0.10 resolution: "@types/d3-selection@npm:3.0.10" checksum: 10c0/de1f99ab186a08999bf394a645fd76911add1b02316270d4c07616c8383903a2b068d7e02b73b6a99a1f26bb49a2e99ef4b55a5d2ddfa165f6f3c53144897920 @@ -15987,7 +15987,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-transition@npm:*": +"@types/d3-transition@npm:*, @types/d3-transition@npm:^3.0.8": version: 3.0.8 resolution: "@types/d3-transition@npm:3.0.8" dependencies: @@ -15996,7 +15996,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.1": +"@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.1, @types/d3-zoom@npm:^3.0.8": version: 3.0.8 resolution: "@types/d3-zoom@npm:3.0.8" dependencies: @@ -18303,6 +18303,35 @@ __metadata: languageName: node linkType: hard +"@xyflow/react@npm:^12.0.4": + version: 12.0.4 + resolution: "@xyflow/react@npm:12.0.4" + dependencies: + "@xyflow/system": "npm:0.0.37" + classcat: "npm:^5.0.3" + zustand: "npm:^4.4.0" + peerDependencies: + react: ">=17" + react-dom: ">=17" + checksum: 10c0/57b04024c3cca1b5d19b5625b92a5ca5015870a5b6adf2ab2c0bcfa701f93929805777ad081e7142b9c94846ad83d65abb65041b50134515b135b6514d74766e + languageName: node + linkType: hard + +"@xyflow/system@npm:0.0.37": + version: 0.0.37 + resolution: "@xyflow/system@npm:0.0.37" + dependencies: + "@types/d3-drag": "npm:^3.0.7" + "@types/d3-selection": "npm:^3.0.10" + "@types/d3-transition": "npm:^3.0.8" + "@types/d3-zoom": "npm:^3.0.8" + d3-drag: "npm:^3.0.0" + d3-selection: "npm:^3.0.0" + d3-zoom: "npm:^3.0.0" + checksum: 10c0/60b2de70a53dc3f2b691d837f2adcd2324f2e3e19258d6928e58578ad896a7f9fa7dd20938b224e7054284542135e0d7519ab34c012d69a8ed0e15ecf452d1ee + languageName: node + linkType: hard + "@yarnpkg/esbuild-plugin-pnp@npm:^3.0.0-rc.10": version: 3.0.0-rc.15 resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15" @@ -47029,6 +47058,7 @@ __metadata: version: 0.0.0-use.local resolution: "twenty-front@workspace:packages/twenty-front" dependencies: + "@xyflow/react": "npm:^12.0.4" transliteration: "npm:^2.3.5" languageName: unknown linkType: soft @@ -50710,7 +50740,7 @@ __metadata: languageName: node linkType: hard -"zustand@npm:^4.4.1": +"zustand@npm:^4.4.0, zustand@npm:^4.4.1": version: 4.5.4 resolution: "zustand@npm:4.5.4" dependencies: