Create a right drawer for viewing steps in workflow runs (#10366)

- Improve the type-safety of the objects mapping the id of a right
drawer or side panel view to a React component
- Improve the types of the `useTabList` hook to type the available tab
identifiers strictly
- Create a specialized `WorkflowRunDiagramCanvas` component to render a
`WorkflowRunDiagramCanvasEffect` component that opens
`RightDrawerPages.WorkflowRunStepView` when a step is selected
- Create a new side panel view specifically for workflow run step
details
- Create tab list in the new side panel; all the tabs are `Node`,
`Input` and `Output`
- Create a hook `useWorkflowSelectedNodeOrThrow` not to duplicate
throwing mechanisms

Closes https://github.com/twentyhq/core-team-issues/issues/432

## Demo


https://github.com/user-attachments/assets/8d5df7dc-0b99-49a2-9a54-d3eaee80a8e6
This commit is contained in:
Baptiste Devessier
2025-02-26 16:48:24 +01:00
committed by GitHub
parent 694553608b
commit f74e4bedc4
28 changed files with 418 additions and 148 deletions
@@ -1,11 +1,9 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowEditStepContent = ({
workflow,
@@ -13,13 +11,7 @@ export const RightDrawerWorkflowEditStepContent = ({
workflow: WorkflowWithCurrentVersion;
}) => {
const flow = useFlowOrThrow();
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 workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateStep({
@@ -0,0 +1,51 @@
import { ShowPageSubContainerTabListContainer } from '@/ui/layout/show-page/components/ShowPageSubContainerTabListContainer';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import styled from '@emotion/styled';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui';
const StyledTabListContainer = styled(ShowPageSubContainerTabListContainer)`
background-color: ${({ theme }) => theme.background.secondary};
`;
type TabId = 'node' | 'input' | 'output';
export const RightDrawerWorkflowRunViewStep = () => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const { activeTabId } = useTabList<TabId>(
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
);
const tabs: SingleTabProps<TabId>[] = [
{ id: 'node', title: 'Node', Icon: IconStepInto },
{ id: 'input', title: 'Input', Icon: IconLogin2 },
{ id: 'output', title: 'Output', Icon: IconLogout },
];
return (
<>
<StyledTabListContainer>
<TabList
tabListInstanceId={WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
</StyledTabListContainer>
{activeTabId === 'node' ? (
<WorkflowStepDetail
readonly
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
/>
) : null}
</>
);
};
@@ -1,18 +1,10 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowViewStep = () => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to view its details.',
);
}
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
return (
<WorkflowStepDetail
@@ -1,13 +1,14 @@
import styled from '@emotion/styled';
const StyledWorkflowStepBody = styled.div`
background: ${({ theme }) => theme.background.primary};
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(4)};
row-gap: ${({ theme }) => theme.spacing(6)};
flex: 1 1 auto;
height: 100%;
`;
export { StyledWorkflowStepBody as WorkflowStepBody };
@@ -21,6 +21,14 @@ const WorkflowEditActionFormServerlessFunction = lazy(() =>
})),
);
const WorkflowReadonlyActionFormServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-steps/workflow-actions/components/WorkflowReadonlyActionFormServerlessFunction'
).then((module) => ({
default: module.WorkflowReadonlyActionFormServerlessFunction,
})),
);
type WorkflowStepDetailProps = {
stepId: string;
trigger: WorkflowTrigger | null;
@@ -50,6 +58,7 @@ export const WorkflowStepDetail = ({
trigger,
steps,
});
if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) {
return null;
}
@@ -60,6 +69,7 @@ export const WorkflowStepDetail = ({
case 'DATABASE_EVENT': {
return (
<WorkflowEditTriggerDatabaseEventForm
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={props}
/>
@@ -68,6 +78,7 @@ export const WorkflowStepDetail = ({
case 'MANUAL': {
return (
<WorkflowEditTriggerManualForm
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={props}
/>
@@ -76,6 +87,7 @@ export const WorkflowStepDetail = ({
case 'CRON': {
return (
<WorkflowEditTriggerCronForm
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={props}
/>
@@ -93,11 +105,18 @@ export const WorkflowStepDetail = ({
case 'CODE': {
return (
<Suspense fallback={<RightDrawerSkeletonLoader />}>
<WorkflowEditActionFormServerlessFunction
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
{props.readonly ? (
<WorkflowReadonlyActionFormServerlessFunction
key={stepId}
action={stepDefinition.definition}
/>
) : (
<WorkflowEditActionFormServerlessFunction
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
)}
</Suspense>
);
}
@@ -150,8 +169,6 @@ export const WorkflowStepDetail = ({
);
}
}
return null;
}
}
@@ -43,24 +43,38 @@ const StyledHeaderIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowStepHeaderProps = {
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
} & (
| {
disabled: true;
onTitleChange?: never;
}
| {
disabled?: boolean;
onTitleChange: (newTitle: string) => void;
}
);
export const WorkflowStepHeader = ({
onTitleChange,
Icon,
iconColor,
initialTitle,
headerType,
disabled,
}: {
onTitleChange: (newTitle: string) => void;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
disabled?: boolean;
}) => {
onTitleChange,
}: WorkflowStepHeaderProps) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
const debouncedOnTitleChange = useDebouncedCallback(onTitleChange, 100);
const debouncedOnTitleChange = useDebouncedCallback((newTitle: string) => {
onTitleChange?.(newTitle);
}, 100);
const handleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedOnTitleChange(newTitle);