Refacto workflow folders (#9302)

- Create separated folders for sections
- Add components
- Add utils and clean old ones
- Add constants
- Rename search variables folder and components

Next steps:
- clean hooks
- clean states
This commit is contained in:
Thomas Trompette
2024-12-31 17:08:14 +01:00
committed by GitHub
parent d4d8883794
commit 9e74ffae52
109 changed files with 195 additions and 840 deletions

View File

@ -1,16 +0,0 @@
import { RightDrawerWorkflowEditStepContent } from '@/workflow/components/RightDrawerWorkflowEditStepContent';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStep = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowEditStepContent workflow={workflow} />;
};

View File

@ -1,34 +0,0 @@
import { WorkflowStepDetail } from '@/workflow/components/WorkflowStepDetail';
import { useUpdateStep } from '@/workflow/hooks/useUpdateStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStepContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateStep({
workflow,
});
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflow.currentVersion}
onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger}
/>
);
};

View File

@ -1,16 +0,0 @@
import { RightDrawerWorkflowSelectActionContent } from '@/workflow/components/RightDrawerWorkflowSelectActionContent';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectAction = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowSelectActionContent workflow={workflow} />;
};

View File

@ -1,40 +0,0 @@
import { ACTIONS } from '@/workflow/constants/Actions';
import { useCreateStep } from '@/workflow/hooks/useCreateStep';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import styled from '@emotion/styled';
import { MenuItem } from 'twenty-ui';
const StyledActionListContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
export const RightDrawerWorkflowSelectActionContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { createStep } = useCreateStep({
workflow,
});
return (
<StyledActionListContainer>
{ACTIONS.map((action) => (
<MenuItem
key={action.type}
LeftIcon={action.icon}
text={action.label}
onClick={() => {
return createStep(action.type);
}}
/>
))}
</StyledActionListContainer>
);
};

View File

@ -1,16 +0,0 @@
import { RightDrawerWorkflowSelectTriggerTypeContent } from '@/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectTriggerType = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowSelectTriggerTypeContent workflow={workflow} />;
};

View File

@ -1,59 +0,0 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { TRIGGER_TYPES } from '@/workflow/constants/TriggerTypes';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { getTriggerDefaultDefinition } from '@/workflow/utils/getTriggerDefaultDefinition';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { MenuItem } from 'twenty-ui';
const StyledActionListContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
export const RightDrawerWorkflowSelectTriggerTypeContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { openRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
return (
<StyledActionListContainer>
{TRIGGER_TYPES.map((action) => (
<MenuItem
key={action.type}
LeftIcon={action.icon}
text={action.name}
onClick={async () => {
await updateTrigger(
getTriggerDefaultDefinition({
type: action.type,
activeObjectMetadataItems,
}),
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
}}
/>
))}
</StyledActionListContainer>
);
};

View File

@ -1,22 +0,0 @@
import { RightDrawerWorkflowViewStepContent } from '@/workflow/components/RightDrawerWorkflowViewStepContent';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStep = () => {
const workflowVersionId = useRecoilValue(workflowVersionIdState);
if (!isDefined(workflowVersionId)) {
throw new Error('Expected a workflow version id');
}
const workflowVersion = useWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return (
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} />
);
};

View File

@ -1,26 +0,0 @@
import { WorkflowStepDetail } from '@/workflow/components/WorkflowStepDetail';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStepContent = ({
workflowVersion,
}: {
workflowVersion: WorkflowVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflowVersion}
readonly
/>
);
};

View File

@ -1,130 +0,0 @@
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import React from 'react';
import { isDefined, OverflowingTextWithTooltip } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
type Variant = 'placeholder';
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.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-left: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
align-self: flex-start;
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
}
`;
const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-style: ${({ variant }) =>
variant === 'placeholder' ? 'dashed' : null};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
box-shadow: ${({ variant, theme }) =>
variant === 'placeholder' ? 'none' : 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<{ variant?: Variant }>`
align-items: center;
display: flex;
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.medium};
column-gap: ${({ theme }) => theme.spacing(3)};
color: ${({ variant, theme }) =>
variant === 'placeholder'
? theme.font.color.extraLight
: theme.font.color.primary};
max-width: 200px;
`;
const StyledSourceHandle = styled(Handle)`
background-color: ${({ theme }) => theme.color.gray50};
`;
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
const StyledRightFloatingElementContainer = styled.div`
display: flex;
align-items: center;
position: absolute;
right: ${({ theme }) => theme.spacing(-3)};
bottom: 0;
top: 0;
transform: translateX(100%);
`;
export const WorkflowDiagramBaseStepNode = ({
nodeType,
name,
variant,
Icon,
RightFloatingElement,
}: {
nodeType: WorkflowDiagramStepNodeData['nodeType'];
name: string;
variant?: Variant;
Icon?: React.ReactNode;
RightFloatingElement?: React.ReactNode;
}) => {
return (
<StyledStepNodeContainer>
{nodeType !== 'trigger' ? (
<StyledTargetHandle type="target" position={Position.Top} />
) : null}
<StyledStepNodeType>{capitalize(nodeType)}</StyledStepNodeType>
<StyledStepNodeInnerContainer variant={variant}>
<StyledStepNodeLabel variant={variant}>
{Icon}
<OverflowingTextWithTooltip text={name} />
</StyledStepNodeLabel>
{isDefined(RightFloatingElement) ? (
<StyledRightFloatingElementContainer>
{RightFloatingElement}
</StyledRightFloatingElementContainer>
) : null}
</StyledStepNodeInnerContainer>
<StyledSourceHandle type="source" position={Position.Bottom} />
</StyledStepNodeContainer>
);
};

View File

@ -1,232 +0,0 @@
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { WorkflowVersionStatusTag } from '@/workflow/components/WorkflowVersionStatusTag';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
WorkflowDiagramNodeType,
} from '@/workflow/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/utils/getOrganizedDiagram';
import styled from '@emotion/styled';
import {
applyEdgeChanges,
applyNodeChanges,
Background,
EdgeChange,
FitViewOptions,
getNodesBounds,
NodeChange,
NodeProps,
ReactFlow,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import React, { useEffect, useMemo, useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { GRAY_SCALE, isDefined, THEME_COMMON } from 'twenty-ui';
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
import { workflowReactFlowRefState } from '@/workflow/states/workflowReactFlowRefState';
const StyledResetReactflowStyles = styled.div`
height: 100%;
width: 100%;
position: relative;
/* 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;
width: auto;
text-align: start;
white-space: nowrap;
}
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
--xy-node-boxshadow-hover: none;
--xy-node-boxshadow-selected: none;
`;
const StyledStatusTagContainer = styled.div`
left: 0;
top: 0;
position: absolute;
padding: ${({ theme }) => theme.spacing(2)};
`;
const defaultFitViewOptions = {
minZoom: 1,
maxZoom: 1,
} satisfies FitViewOptions;
export const WorkflowDiagramCanvasBase = ({
diagram,
status,
nodeTypes,
children,
}: {
diagram: WorkflowDiagram;
status: WorkflowVersionStatus;
nodeTypes: Partial<
Record<
WorkflowDiagramNodeType,
React.ComponentType<
NodeProps & {
data: any;
type: any;
}
>
>
>;
children?: React.ReactNode;
}) => {
const reactflow = useReactFlow();
const setWorkflowReactFlowRefState = useSetRecoilState(
workflowReactFlowRefState,
);
const { nodes, edges } = useMemo(
() => getOrganizedDiagram(diagram),
[diagram],
);
const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState);
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
const isMobile = useIsMobile();
const rightDrawerState = !isRightDrawerOpen
? 'closed'
: isRightDrawerMinimized
? 'minimized'
: isMobile
? 'fullScreen'
: 'normal';
const rightDrawerWidth = Number(
THEME_COMMON.rightDrawerWidth.replace('px', ''),
);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const handleNodesChange = (
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
) => {
setWorkflowDiagram((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<EdgeChange<WorkflowDiagramEdge>>,
) => {
setWorkflowDiagram((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),
};
});
};
useListenRightDrawerClose(() => {
reactflow.setNodes((nodes) =>
nodes.map((node) => ({ ...node, selected: false })),
);
});
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isDefined(containerRef.current) || !reactflow.viewportInitialized) {
return;
}
const currentViewport = reactflow.getViewport();
const flowBounds = getNodesBounds(reactflow.getNodes());
let visibleRightDrawerWidth = 0;
if (rightDrawerState === 'normal') {
visibleRightDrawerWidth = rightDrawerWidth;
}
const viewportX =
(containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 -
flowBounds.width / 2;
reactflow.setViewport(
{
...currentViewport,
x: viewportX - visibleRightDrawerWidth,
},
{ duration: 300 },
);
}, [reactflow, rightDrawerState, rightDrawerWidth]);
return (
<StyledResetReactflowStyles ref={containerRef}>
<ReactFlow
ref={(node) => {
if (isDefined(node)) {
setWorkflowReactFlowRefState({ current: node });
}
}}
onInit={() => {
if (!isDefined(containerRef.current)) {
throw new Error('Expect the container ref to be defined');
}
const flowBounds = getNodesBounds(reactflow.getNodes());
reactflow.setViewport({
x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2,
y: 150,
zoom: defaultFitViewOptions.maxZoom,
});
}}
minZoom={defaultFitViewOptions.minZoom}
maxZoom={defaultFitViewOptions.maxZoom}
nodeTypes={nodeTypes}
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
proOptions={{ hideAttribution: true }}
multiSelectionKeyCode={null}
nodesFocusable={false}
edgesFocusable={false}
nodesDraggable={false}
paneClickDistance={10} // Fix small unwanted user dragging does not select node
>
<Background color={GRAY_SCALE.gray25} size={2} />
{children}
</ReactFlow>
<StyledStatusTagContainer>
<WorkflowVersionStatusTag versionStatus={status} />
</StyledStatusTagContainer>
</StyledResetReactflowStyles>
);
};

View File

@ -1,32 +0,0 @@
import { WorkflowDiagramCanvasBase } from '@/workflow/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/components/WorkflowDiagramStepNodeEditable';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasEditable = ({
diagram,
workflowWithCurrentVersion,
}: {
diagram: WorkflowDiagram;
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
}) => {
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
diagram={diagram}
status={workflowWithCurrentVersion.currentVersion.status}
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
>
<WorkflowDiagramCanvasEditableEffect />
</WorkflowDiagramCanvasBase>
</ReactFlowProvider>
);
};

View File

@ -1,73 +0,0 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { CREATE_STEP_STEP_ID } from '@/workflow/constants/CreateStepStepId';
import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/constants/EmptyTriggerStepId';
import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowDiagramCanvasEditableEffect = () => {
const { startNodeCreation } = useStartNodeCreation();
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setHotkeyScope = useSetHotkeyScope();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
const isClosingStep = isDefined(selectedNode) === false;
if (isClosingStep) {
closeRightDrawer();
return;
}
const isEmptyTriggerNode = selectedNode.type === EMPTY_TRIGGER_STEP_ID;
if (isEmptyTriggerNode) {
openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType);
return;
}
const isCreateStepNode = selectedNode.type === CREATE_STEP_STEP_ID;
if (isCreateStepNode) {
if (selectedNode.data.nodeType !== 'create-step') {
throw new Error('Expected selected node to be a create step node.');
}
startNodeCreation(selectedNode.data.parentNodeId);
return;
}
setWorkflowSelectedNode(selectedNode.id);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
},
[
setHotkeyScope,
closeRightDrawer,
openRightDrawer,
setWorkflowSelectedNode,
startNodeCreation,
],
);
useOnSelectionChange({
onChange: handleSelectionChange,
});
useTriggerNodeSelection();
return null;
};

View File

@ -1,30 +0,0 @@
import { WorkflowDiagramCanvasBase } from '@/workflow/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasReadonlyEffect } from '@/workflow/components/WorkflowDiagramCanvasReadonlyEffect';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasReadonly = ({
diagram,
workflowVersion,
}: {
diagram: WorkflowDiagram;
workflowVersion: WorkflowVersion;
}) => {
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
diagram={diagram}
status={workflowVersion.status}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
>
<WorkflowDiagramCanvasReadonlyEffect />
</WorkflowDiagramCanvasBase>
</ReactFlowProvider>
);
};

View File

@ -1,48 +0,0 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowDiagramCanvasReadonlyEffect = () => {
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const setHotkeyScope = useSetHotkeyScope();
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
const isClosingStep = isDefined(selectedNode) === false;
if (isClosingStep) {
closeRightDrawer();
return;
}
setWorkflowSelectedNode(selectedNode.id);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.WorkflowStepView);
},
[
closeRightDrawer,
openRightDrawer,
setWorkflowSelectedNode,
setHotkeyScope,
],
);
useOnSelectionChange({
onChange: handleSelectionChange,
});
useTriggerNodeSelection();
return null;
};

View File

@ -1,17 +0,0 @@
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import { IconButton, IconPlus } from 'twenty-ui';
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
export const WorkflowDiagramCreateStepNode = () => {
return (
<>
<StyledTargetHandle type="target" position={Position.Top} />
<IconButton Icon={IconPlus} size="medium" />
</>
);
};

View File

@ -1,85 +0,0 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/utils/mergeWorkflowDiagrams';
import { useEffect } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowDiagramEffect = ({
workflowWithCurrentVersion,
}: {
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
}) => {
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
return (currentVersion: WorkflowVersion) => {
const previousWorkflowDiagram = getSnapshotValue(
snapshot,
workflowDiagramState,
);
const nextWorkflowDiagram = addCreateStepNodes(
getWorkflowVersionDiagram(currentVersion),
);
let mergedWorkflowDiagram = nextWorkflowDiagram;
if (isDefined(previousWorkflowDiagram)) {
mergedWorkflowDiagram = mergeWorkflowDiagrams(
previousWorkflowDiagram,
nextWorkflowDiagram,
);
}
const lastCreatedStepId = getSnapshotValue(
snapshot,
workflowLastCreatedStepIdState,
);
if (isDefined(lastCreatedStepId)) {
mergedWorkflowDiagram.nodes = mergedWorkflowDiagram.nodes.map(
(node) => {
if (node.id === lastCreatedStepId) {
return {
...node,
selected: true,
};
}
return node;
},
);
set(workflowLastCreatedStepIdState, undefined);
}
set(workflowDiagramState, mergedWorkflowDiagram);
};
},
[],
);
useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) {
setWorkflowDiagram(undefined);
return;
}
computeAndMergeNewWorkflowDiagram(currentVersion);
}, [
computeAndMergeNewWorkflowDiagram,
setWorkflowDiagram,
workflowWithCurrentVersion?.currentVersion,
]);
return null;
};

View File

@ -1,30 +0,0 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramEmptyTrigger = () => {
const theme = useTheme();
return (
<WorkflowDiagramBaseStepNode
name="Add a Trigger"
nodeType="trigger"
variant="placeholder"
Icon={
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd size={16} color={theme.font.color.tertiary} />
</StyledStepNodeLabelIconContainer>
}
/>
);
};

View File

@ -1,107 +0,0 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconAddressBook,
IconCode,
IconHandMove,
IconMail,
IconPlaylistAdd,
} from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramStepNodeBase = ({
data,
RightFloatingElement,
}: {
data: WorkflowDiagramStepNodeData;
RightFloatingElement?: React.ReactNode;
}) => {
const theme = useTheme();
const renderStepIcon = () => {
switch (data.nodeType) {
case 'trigger': {
switch (data.triggerType) {
case 'DATABASE_EVENT': {
return (
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd
size={theme.icon.size.lg}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'MANUAL': {
return (
<StyledStepNodeLabelIconContainer>
<IconHandMove
size={theme.icon.size.lg}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
return assertUnreachable(data.triggerType);
}
case 'action': {
switch (data.actionType) {
case 'CODE': {
return (
<StyledStepNodeLabelIconContainer>
<IconCode
size={theme.icon.size.lg}
color={theme.color.orange}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'SEND_EMAIL': {
return (
<StyledStepNodeLabelIconContainer>
<IconMail size={theme.icon.size.lg} color={theme.color.blue} />
</StyledStepNodeLabelIconContainer>
);
}
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD': {
return (
<StyledStepNodeLabelIconContainer>
<IconAddressBook
size={theme.icon.size.lg}
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
}
}
return assertUnreachable(data);
};
return (
<WorkflowDiagramBaseStepNode
name={data.name}
nodeType={data.nodeType}
Icon={renderStepIcon()}
RightFloatingElement={RightFloatingElement}
/>
);
};

View File

@ -1,44 +0,0 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase';
import { useDeleteStep } from '@/workflow/hooks/useDeleteStep';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { useRecoilValue } from 'recoil';
import { FloatingIconButton, IconTrash } from 'twenty-ui';
export const WorkflowDiagramStepNodeEditable = ({
id,
data,
selected,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
selected?: boolean;
}) => {
const workflowId = useRecoilValue(workflowIdState);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
const { deleteStep } = useDeleteStep({
workflow: workflowWithCurrentVersion,
});
return (
<WorkflowDiagramStepNodeBase
data={data}
RightFloatingElement={
selected ? (
<FloatingIconButton
size="medium"
Icon={IconTrash}
onClick={() => {
deleteStep(id);
}}
/>
) : undefined
}
/>
);
};

View File

@ -1,10 +0,0 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
export const WorkflowDiagramStepNodeReadonly = ({
data,
}: {
data: WorkflowDiagramStepNodeData;
}) => {
return <WorkflowDiagramStepNodeBase data={data} />;
};

View File

@ -1,151 +0,0 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react';
import { IconPlaylistAdd, isDefined } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger;
triggerOptions:
| {
readonly: true;
onTriggerUpdate?: undefined;
}
| {
readonly?: false;
onTriggerUpdate: (trigger: WorkflowDatabaseEventTrigger) => void;
};
};
export const WorkflowEditTriggerDatabaseEventForm = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerDatabaseEventFormProps) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const triggerEvent = isDefined(trigger)
? splitWorkflowTriggerEventName(trigger.settings.eventName)
: undefined;
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
}));
const recordTypeMetadata = isDefined(triggerEvent)
? activeObjectMetadataItems.find(
(item) => item.nameSingular === triggerEvent.objectType,
)
: undefined;
const selectedEvent = isDefined(triggerEvent)
? OBJECT_EVENT_TRIGGERS.find(
(availableEvent) => availableEvent.value === triggerEvent.event,
)
: undefined;
const headerTitle = isDefined(trigger.name)
? trigger.name
: isDefined(recordTypeMetadata) && isDefined(selectedEvent)
? `When a ${recordTypeMetadata.labelSingular} is ${selectedEvent.label}`
: '-';
const headerType = isDefined(selectedEvent)
? `Trigger · Record is ${selectedEvent.label}`
: '-';
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
outputSchema: {},
},
},
);
}}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS}
disabled={triggerOptions.readonly}
onChange={(updatedEvent) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata?.[0].value}.${updatedEvent}`,
outputSchema: {},
},
},
);
}}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -1,117 +0,0 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/constants/ManualTriggerAvailabilityOptions';
import {
WorkflowManualTrigger,
WorkflowManualTriggerAvailability,
} from '@/workflow/types/Workflow';
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined, useIcons } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerManualFormProps = {
trigger: WorkflowManualTrigger;
triggerOptions:
| {
readonly: true;
onTriggerUpdate?: undefined;
}
| {
readonly?: false;
onTriggerUpdate: (trigger: WorkflowManualTrigger) => void;
};
};
export const WorkflowEditTriggerManualForm = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerManualFormProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
Icon: getIcon(item.icon),
}));
const manualTriggerAvailability: WorkflowManualTriggerAvailability =
isDefined(trigger.settings.objectType)
? 'WHEN_RECORD_SELECTED'
: 'EVERYWHERE';
const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger';
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
disabled={triggerOptions.readonly}
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
});
}}
/>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(updatedObject) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
outputSchema: {},
},
});
}}
/>
) : null}
</WorkflowStepBody>
</>
);
};

View File

@ -1,6 +1,6 @@
import { WorkflowVersionVisualizer } from '@/workflow/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/components/WorkflowVersionVisualizerEffect';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { isDefined } from 'twenty-ui';
export const WorkflowRunVersionVisualizer = ({

View File

@ -1,68 +0,0 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import {
RecordId,
Variable,
} from '@/workflow/components/WorkflowSingleRecordPicker';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
const StyledRecordChip = styled(RecordChip)`
margin: ${({ theme }) => theme.spacing(2)};
`;
const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
margin: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowSingleRecordFieldChipProps = {
draftValue:
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
};
export const WorkflowSingleRecordFieldChip = ({
draftValue,
selectedRecord,
objectNameSingular,
onRemove,
}: WorkflowSingleRecordFieldChipProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
if (
!!draftValue &&
draftValue.type === 'variable' &&
isStandaloneVariableString(draftValue.value)
) {
return (
<VariableChip
rawVariableName={objectMetadataItem.labelSingular}
onRemove={onRemove}
/>
);
}
if (!!draftValue && draftValue.type === 'static' && !!selectedRecord) {
return (
<StyledRecordChip
record={selectedRecord}
objectNameSingular={objectNameSingular}
/>
);
}
return <StyledPlaceholder>Select a {objectNameSingular}</StyledPlaceholder>;
};

View File

@ -1,199 +0,0 @@
import {
IconChevronDown,
IconForbid,
isDefined,
LightIconButton,
} from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { RecordForSelect } from '@/object-record/relation-picker/types/RecordForSelect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { WorkflowSingleRecordFieldChip } from '@/workflow/components/WorkflowSingleRecordFieldChip';
import { SearchVariablesDropdown } from '@/workflow/search-variables/components/SearchVariablesDropdown';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { isValidUuid } from '~/utils/isValidUuid';
const StyledFormSelectContainer = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
border-bottom-right-radius: none;
border-top-right-radius: none;
box-sizing: border-box;
display: flex;
overflow: 'hidden';
width: 100%;
justify-content: space-between;
align-items: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledSearchVariablesDropdownContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
${({ theme }) => css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme }) => css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export type RecordId = string;
export type Variable = string;
type WorkflowSingleRecordPickerValue =
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
export type WorkflowSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
onChange: (value: RecordId | Variable) => void;
objectNameSingular: string;
};
export const WorkflowSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
onChange,
}: WorkflowSingleRecordPickerProps) => {
const draftValue: WorkflowSingleRecordPickerValue =
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
};
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular,
withSoftDeleted: true,
skip: !isValidUuid(defaultValue),
});
const dropdownId = `workflow-record-picker-${objectNameSingular}`;
const variablesDropdownId = `workflow-record-picker-${objectNameSingular}-variables`;
const { closeDropdown } = useDropdown(dropdownId);
const { setRecordPickerSearchFilter } = useRecordPicker({
recordPickerInstanceId: dropdownId,
});
const handleCloseRelationPickerDropdown = useCallback(() => {
setRecordPickerSearchFilter('');
}, [setRecordPickerSearchFilter]);
const handleRecordSelected = (
selectedEntity: RecordForSelect | null | undefined,
) => {
onChange?.(selectedEntity?.record?.id ?? '');
closeDropdown();
};
const handleVariableTagInsert = (variable: string) => {
onChange?.(variable);
closeDropdown();
};
const handleUnlinkVariable = () => {
closeDropdown();
onChange('');
};
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
/>
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconChevronDown}
accent="tertiary"
/>
}
dropdownComponents={
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<SingleRecordSelect
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
selectedRecordIds={
draftValue?.value &&
!isStandaloneVariableString(draftValue.value)
? [draftValue.value]
: []
}
/>
</RecordPickerComponentInstanceContext.Provider>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledFormSelectContainer>
<StyledSearchVariablesDropdownContainer>
<SearchVariablesDropdown
inputId={variablesDropdownId}
onVariableSelect={handleVariableTagInsert}
disabled={false}
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,12 +0,0 @@
import styled from '@emotion/styled';
const StyledWorkflowStepBody = styled.div`
display: flex;
flex-direction: column;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(6)};
row-gap: ${({ theme }) => theme.spacing(6)};
flex: 1 1 auto;
`;
export { StyledWorkflowStepBody as WorkflowStepBody };

View File

@ -1,143 +0,0 @@
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/components/WorkflowEditTriggerManualForm';
import {
WorkflowAction,
WorkflowTrigger,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { Suspense, lazy } from 'react';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
const WorkflowEditActionFormServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction'
).then((module) => ({
default: module.WorkflowEditActionFormServerlessFunction,
})),
);
type WorkflowStepDetailProps =
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly: true;
onTriggerUpdate?: undefined;
onActionUpdate?: undefined;
}
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onActionUpdate: (action: WorkflowAction) => void;
};
export const WorkflowStepDetail = ({
stepId,
workflowVersion,
...props
}: WorkflowStepDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
workflowVersion,
});
if (!isDefined(stepDefinition)) {
return null;
}
switch (stepDefinition.type) {
case 'trigger': {
if (!isDefined(stepDefinition.definition)) {
throw new Error(
'Expected the trigger to be defined at this point. Ensure the trigger has been set with a default value before trying to edit it.',
);
}
switch (stepDefinition.definition.type) {
case 'DATABASE_EVENT': {
return (
<WorkflowEditTriggerDatabaseEventForm
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
case 'MANUAL': {
return (
<WorkflowEditTriggerManualForm
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
}
return assertUnreachable(
stepDefinition.definition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<Suspense fallback={<RightDrawerSkeletonLoader />}>
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
actionOptions={props}
/>
</Suspense>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'CREATE_RECORD': {
return (
<WorkflowEditActionFormCreateRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'UPDATE_RECORD': {
return (
<WorkflowEditActionFormUpdateRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'DELETE_RECORD': {
return (
<WorkflowEditActionFormDeleteRecord
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
}
return null;
}
}
return assertUnreachable(
stepDefinition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
};

View File

@ -1,94 +0,0 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import styled from '@emotion/styled';
import React, { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { IconComponent } from 'packages/twenty-ui';
import { useTheme } from '@emotion/react';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: row;
padding: ${({ theme }) => theme.spacing(4)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
width: 420px;
overflow: hidden;
`;
const StyledHeaderType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowStepHeader = ({
onTitleChange,
Icon,
iconColor,
initialTitle,
headerType,
}: {
onTitleChange: (newTitle: string) => void;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
}) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
const debouncedOnTitleChange = useDebouncedCallback(onTitleChange, 100);
const handleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedOnTitleChange(newTitle);
};
return (
<StyledHeader>
<StyledHeaderIconContainer>
{
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
}
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
);
};

View File

@ -1,57 +0,0 @@
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { SearchVariablesDropdown } from '@/workflow/search-variables/components/SearchVariablesDropdown';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
export const StyledSearchVariablesDropdownContainer = styled.div<{
multiline?: boolean;
readonly?: boolean;
}>`
align-items: center;
display: flex;
justify-content: center;
${({ theme, readonly }) =>
!readonly &&
css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme, multiline }) =>
multiline
? css`
border-radius: ${theme.border.radius.sm};
padding: ${theme.spacing(0.5)} ${theme.spacing(0)};
position: absolute;
right: ${theme.spacing(0)};
top: ${theme.spacing(0)};
`
: css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export const WorkflowVariablePicker: VariablePickerComponent = ({
inputId,
disabled,
multiline,
onVariableSelect,
}) => {
return (
<StyledSearchVariablesDropdownContainer
multiline={multiline}
readonly={disabled}
>
<SearchVariablesDropdown
inputId={inputId}
onVariableSelect={onVariableSelect}
disabled={disabled}
/>
</StyledSearchVariablesDropdownContainer>
);
};

View File

@ -1,22 +0,0 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { Tag } from 'twenty-ui';
export const WorkflowVersionStatusTag = ({
versionStatus,
}: {
versionStatus: WorkflowVersionStatus;
}) => {
if (versionStatus === 'ACTIVE') {
return <Tag color="green" text="Active" />;
}
if (versionStatus === 'DRAFT') {
return <Tag color="yellow" text="Draft" />;
}
if (versionStatus === 'ARCHIVED') {
return <Tag color="gray" text="Archived" />;
}
return <Tag color="gray" text="Deactivated" />;
};

View File

@ -1,23 +0,0 @@
import { WorkflowDiagramCanvasReadonly } from '@/workflow/components/WorkflowDiagramCanvasReadonly';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import '@xyflow/react/dist/style.css';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVersionVisualizer = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const workflowDiagram = useRecoilValue(workflowDiagramState);
return isDefined(workflowDiagram) && isDefined(workflowVersion) ? (
<WorkflowDiagramCanvasReadonly
diagram={workflowDiagram}
workflowVersion={workflowVersion}
/>
) : null;
};

View File

@ -1,36 +0,0 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVersionVisualizerEffect = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
setWorkflowVersionId(workflowVersionId);
}, [setWorkflowVersionId, workflowVersionId]);
useEffect(() => {
if (!isDefined(workflowVersion)) {
setWorkflowDiagram(undefined);
return;
}
const nextWorkflowDiagram = getWorkflowVersionDiagram(workflowVersion);
setWorkflowDiagram(nextWorkflowDiagram);
}, [setWorkflowDiagram, workflowVersion]);
return null;
};

View File

@ -1,34 +0,0 @@
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { WorkflowDiagramCanvasEditable } from '@/workflow/components/WorkflowDiagramCanvasEditable';
import { WorkflowDiagramEffect } from '@/workflow/components/WorkflowDiagramEffect';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import '@xyflow/react/dist/style.css';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVisualizer = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const workflowId = targetableObject.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const workflowDiagram = useRecoilValue(workflowDiagramState);
return (
<>
<WorkflowDiagramEffect
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
{isDefined(workflowDiagram) && isDefined(workflowWithCurrentVersion) ? (
<WorkflowDiagramCanvasEditable
diagram={workflowDiagram}
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
) : null}
</>
);
};

View File

@ -1,17 +0,0 @@
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
export const WorkflowVisualizerEffect = ({
workflowId,
}: {
workflowId: string;
}) => {
const setWorkflowId = useSetRecoilState(workflowIdState);
useEffect(() => {
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
return null;
};

View File

@ -1,43 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator, CatalogStory, ComponentDecorator } from 'twenty-ui';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowVersionStatusTag } from '../WorkflowVersionStatusTag';
const meta: Meta<typeof WorkflowVersionStatusTag> = {
title: 'Modules/Workflow/WorkflowVersionStatusTag',
component: WorkflowVersionStatusTag,
};
export default meta;
type Story = StoryObj<typeof WorkflowVersionStatusTag>;
export const Default: Story = {
args: {
versionStatus: 'DRAFT',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof WorkflowVersionStatusTag> = {
argTypes: {
versionStatus: { table: { disable: true } },
},
parameters: {
catalog: {
dimensions: [
{
name: 'version status',
values: [
'DRAFT',
'ACTIVE',
'DEACTIVATED',
'ARCHIVED',
] satisfies WorkflowVersionStatus[],
props: (versionStatus: WorkflowVersionStatus) => ({ versionStatus }),
},
],
},
},
decorators: [CatalogDecorator],
};