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

View File

@ -6,6 +6,7 @@ import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/components/Co
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { RightDrawerWorkflowEditStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowRunViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowRunViewStep';
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/workflow-steps/workflow-actions/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerType';
@ -29,5 +30,6 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
],
[CommandMenuPages.WorkflowStepEdit, <RightDrawerWorkflowEditStep />],
[CommandMenuPages.WorkflowStepView, <RightDrawerWorkflowViewStep />],
[CommandMenuPages.WorkflowRunStepView, <RightDrawerWorkflowRunViewStep />],
[CommandMenuPages.SearchRecords, <CommandMenuSearchRecordsPage />],
]);

View File

@ -8,5 +8,6 @@ export enum CommandMenuPages {
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepView = 'workflow-step-view',
WorkflowStepEdit = 'workflow-step-edit',
WorkflowRunStepView = 'workflow-run-step-view',
SearchRecords = 'search-records',
}

View File

@ -9,8 +9,8 @@ import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isR
import { RightDrawerContainer } from '@/ui/layout/right-drawer/components/RightDrawerContainer';
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage';
import { RightDrawerWorkflowEditStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowRunViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowRunViewStep';
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/workflow-steps/workflow-actions/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerType';
@ -28,7 +28,7 @@ const StyledRightDrawerBody = styled.div`
position: relative;
`;
const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
const RIGHT_DRAWER_PAGES_CONFIG = {
[RightDrawerPages.ViewEmailThread]: <RightDrawerEmailThread />,
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
@ -41,7 +41,8 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
),
[RightDrawerPages.WorkflowStepEdit]: <RightDrawerWorkflowEditStep />,
[RightDrawerPages.WorkflowStepView]: <RightDrawerWorkflowViewStep />,
};
[RightDrawerPages.WorkflowRunStepView]: <RightDrawerWorkflowRunViewStep />,
} satisfies Record<RightDrawerPages, JSX.Element>;
export const RightDrawerRouter = () => {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);

View File

@ -9,4 +9,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles',
[RightDrawerPages.WorkflowStepEdit]: 'IconSparkles',
[RightDrawerPages.WorkflowStepView]: 'IconSparkles',
};
[RightDrawerPages.WorkflowRunStepView]: 'IconSparkles',
} satisfies Record<RightDrawerPages, string>;

View File

@ -9,4 +9,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.WorkflowStepSelectAction]: 'Workflow',
[RightDrawerPages.WorkflowStepEdit]: 'Workflow',
[RightDrawerPages.WorkflowStepView]: 'Workflow',
};
[RightDrawerPages.WorkflowRunStepView]: 'Workflow',
} satisfies Record<RightDrawerPages, string>;

View File

@ -1,5 +0,0 @@
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
export type ComponentByRightDrawerPage = {
[componentName in RightDrawerPages]?: JSX.Element;
};

View File

@ -7,4 +7,5 @@ export enum RightDrawerPages {
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepView = 'workflow-step-view',
WorkflowStepEdit = 'workflow-step-edit',
WorkflowRunStepView = 'workflow-run-step-view',
}

View File

@ -4,24 +4,25 @@ import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPage
export const mapRightDrawerPageToCommandMenuPage = (
rightDrawerPage: RightDrawerPages,
) => {
switch (rightDrawerPage) {
case RightDrawerPages.ViewRecord:
return CommandMenuPages.ViewRecord;
case RightDrawerPages.ViewEmailThread:
return CommandMenuPages.ViewEmailThread;
case RightDrawerPages.ViewCalendarEvent:
return CommandMenuPages.ViewCalendarEvent;
case RightDrawerPages.Copilot:
return CommandMenuPages.Copilot;
case RightDrawerPages.WorkflowStepSelectTriggerType:
return CommandMenuPages.WorkflowStepSelectTriggerType;
case RightDrawerPages.WorkflowStepSelectAction:
return CommandMenuPages.WorkflowStepSelectAction;
case RightDrawerPages.WorkflowStepView:
return CommandMenuPages.WorkflowStepView;
case RightDrawerPages.WorkflowStepEdit:
return CommandMenuPages.WorkflowStepEdit;
default:
return CommandMenuPages.Root;
}
const rightDrawerPagesToCommandMenuPages: Record<
RightDrawerPages,
CommandMenuPages
> = {
[RightDrawerPages.ViewRecord]: CommandMenuPages.ViewRecord,
[RightDrawerPages.ViewEmailThread]: CommandMenuPages.ViewEmailThread,
[RightDrawerPages.ViewCalendarEvent]: CommandMenuPages.ViewCalendarEvent,
[RightDrawerPages.Copilot]: CommandMenuPages.Copilot,
[RightDrawerPages.WorkflowStepSelectTriggerType]:
CommandMenuPages.WorkflowStepSelectTriggerType,
[RightDrawerPages.WorkflowStepSelectAction]:
CommandMenuPages.WorkflowStepSelectAction,
[RightDrawerPages.WorkflowStepView]: CommandMenuPages.WorkflowStepView,
[RightDrawerPages.WorkflowRunStepView]:
CommandMenuPages.WorkflowRunStepView,
[RightDrawerPages.WorkflowStepEdit]: CommandMenuPages.WorkflowStepEdit,
};
return (
rightDrawerPagesToCommandMenuPages[rightDrawerPage] ?? CommandMenuPages.Root
);
};

View File

@ -9,6 +9,7 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -26,14 +27,8 @@ const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
`;
const StyledTabListContainer = styled.div<{ shouldDisplay: boolean }>`
align-items: center;
padding-left: ${({ theme }) => theme.spacing(2)};
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: ${({ shouldDisplay }) => (shouldDisplay ? 'flex' : 'none')};
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
`;
`.withComponent(ShowPageSubContainerTabListContainer);
const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
flex: 1;

View File

@ -0,0 +1,13 @@
import styled from '@emotion/styled';
const StyledTabListContainer = styled.div`
align-items: center;
padding-left: ${({ theme }) => theme.spacing(2)};
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
`;
export { StyledTabListContainer as ShowPageSubContainerTabListContainer };

View File

@ -9,10 +9,10 @@ import { useEffect } from 'react';
import { IconComponent } from 'twenty-ui';
import { Tab } from './Tab';
export type SingleTabProps = {
export type SingleTabProps<T extends string = string> = {
title: string;
Icon?: IconComponent;
id: string;
id: T;
hide?: boolean;
disabled?: boolean;
pill?: string | React.ReactElement;

View File

@ -1,13 +1,15 @@
import { useRecoilState } from 'recoil';
import { RecoilState, useRecoilState } from 'recoil';
import { useTabListStates } from '@/ui/layout/tab/hooks/internal/useTabListStates';
export const useTabList = (tabListId?: string) => {
export const useTabList = <T extends string>(tabListId?: string) => {
const { activeTabIdState } = useTabListStates({
tabListScopeId: tabListId,
});
const [activeTabId, setActiveTabId] = useRecoilState(activeTabIdState);
const [activeTabId, setActiveTabId] = useRecoilState(
activeTabIdState as RecoilState<T | null>,
);
return {
activeTabId,

View File

@ -1,6 +1,6 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonly';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { isDefined } from 'twenty-shared';
@ -18,7 +18,7 @@ export const WorkflowRunVisualizerContent = ({
<>
<WorkflowRunVisualizerEffect workflowRun={workflowRun} />
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
<WorkflowRunDiagramCanvas versionStatus={workflowVersion.status} />
</>
);
};

View File

@ -0,0 +1,30 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { WorkflowRunDiagramCanvasEffect } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvasEffect';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowRunDiagramCanvas = ({
versionStatus,
}: {
versionStatus: WorkflowVersionStatus;
}) => {
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
}}
edgeTypes={{
default: WorkflowDiagramDefaultEdge,
success: WorkflowDiagramSuccessEdge,
}}
/>
<WorkflowRunDiagramCanvasEffect />
</ReactFlowProvider>
);
};

View File

@ -0,0 +1,61 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
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 { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import {
WorkflowDiagramNode,
WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useIcons } from 'twenty-ui';
export const WorkflowRunDiagramCanvasEffect = () => {
const { getIcon } = useIcons();
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const setHotkeyScope = useSetHotkeyScope();
const { closeCommandMenu } = useCommandMenu();
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
const isClosingStep = isDefined(selectedNode) === false;
if (isClosingStep) {
closeRightDrawer();
closeCommandMenu();
return;
}
setWorkflowSelectedNode(selectedNode.id);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData;
openRightDrawer(RightDrawerPages.WorkflowRunStepView, {
title: selectedNodeData.name,
Icon: getIcon(getWorkflowNodeIconKey(selectedNodeData)),
});
},
[
setWorkflowSelectedNode,
setHotkeyScope,
openRightDrawer,
closeRightDrawer,
closeCommandMenu,
getIcon,
],
);
useOnSelectionChange({
onChange: handleSelectionChange,
});
return null;
};

View File

@ -0,0 +1,15 @@
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useWorkflowSelectedNodeOrThrow = () => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. A node must have been selected before running this code.',
);
}
return workflowSelectedNode;
};

View File

@ -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({

View File

@ -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}
</>
);
};

View File

@ -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

View File

@ -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 };

View File

@ -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;
}
}

View File

@ -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);

View File

@ -0,0 +1,2 @@
export const WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID =
'workflow-run-step-side-panel-tab-list';

View File

@ -17,6 +17,7 @@ import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctio
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageSubContainerTabListContainer } from '@/ui/layout/show-page/components/ShowPageSubContainerTabListContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
@ -48,14 +49,7 @@ const StyledCodeEditorContainer = styled.div`
flex-direction: column;
`;
const StyledTabListContainer = styled.div`
align-items: center;
padding-left: ${({ theme }) => theme.spacing(2)};
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(10)};
const StyledTabListContainer = styled(ShowPageSubContainerTabListContainer)`
background-color: ${({ theme }) => theme.background.secondary};
`;

View File

@ -5,57 +5,50 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { FunctionInput } from '@/workflow/workflow-steps/workflow-actions/types/FunctionInput';
import styled from '@emotion/styled';
import { isObject } from '@sniptt/guards';
import { ReactNode } from 'react';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
type WorkflowEditActionFormServerlessFunctionFieldsProps = {
functionInput: FunctionInput;
path?: string[];
readonly?: boolean;
onInputChange?: (value: any, path: string[]) => void;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowEditActionFormServerlessFunctionFields = ({
functionInput,
path = [],
VariablePicker,
readonly,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}) => {
const renderFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}): ReactNode[] => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && isObject(inputValue)) {
return (
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
{renderFields({
functionInput: inputValue,
path: currentPath,
VariablePicker,
onInputChange,
})}
</FormNestedFieldInputContainer>
</StyledContainer>
);
} else {
VariablePicker,
}: WorkflowEditActionFormServerlessFunctionFieldsProps) => {
return (
<>
{Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && isObject(inputValue)) {
return (
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={inputValue}
path={currentPath}
readonly={readonly}
onInputChange={onInputChange}
VariablePicker={VariablePicker}
/>
</FormNestedFieldInputContainer>
</StyledContainer>
);
}
return (
<FormTextFieldInput
key={pathKey}
@ -63,22 +56,10 @@ export const WorkflowEditActionFormServerlessFunctionFields = ({
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={readonly}
onPersist={(value) => onInputChange(value, currentPath)}
onPersist={(value) => onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
});
};
return (
<>
{renderFields({
functionInput,
path,
VariablePicker,
onInputChange,
readonly,
})}
</>
);

View File

@ -0,0 +1,104 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/utils/getWrongExportedFunctionMarkers';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { isDefined } from 'twenty-shared';
import { CodeEditor, useIcons } from 'twenty-ui';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column;
`;
type WorkflowReadonlyActionFormServerlessFunctionProps = {
action: WorkflowCodeAction;
};
export const WorkflowReadonlyActionFormServerlessFunction = ({
action,
}: WorkflowReadonlyActionFormServerlessFunctionProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const serverlessFunctionVersion =
action.settings.input.serverlessFunctionVersion;
const { availablePackages } = useGetAvailablePackages({
id: serverlessFunctionId,
});
const { formValues, loading } = useServerlessFunctionUpdateFormState({
serverlessFunctionId,
serverlessFunctionVersion,
});
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
await AutoTypings.create(editor, {
monaco,
preloadPackages: true,
onlySpecifiedPackages: true,
versions: availablePackages,
debounceDuration: 0,
});
};
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
const headerIcon = getActionIcon(action.type);
if (loading) {
return null;
}
return (
<StyledContainer>
<WorkflowStepHeader
Icon={getIcon(headerIcon)}
iconColor={theme.color.orange}
initialTitle={headerTitle}
headerType="Code"
disabled
/>
<WorkflowStepBody>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={action.settings.input.serverlessFunctionInput}
readonly
/>
<StyledCodeEditorContainer>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
options={{
readOnly: true,
domReadOnly: true,
}}
/>
</StyledCodeEditorContainer>
</WorkflowStepBody>
</StyledContainer>
);
};

View File

@ -3,7 +3,7 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import {
@ -24,10 +24,10 @@ export const useAvailableVariablesInWorkflowStep = ({
}): StepOutputSchema[] => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const flow = useFlowOrThrow();
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
if (!isDefined(workflow)) {
return [];
}

View File

@ -52,6 +52,9 @@ export {
IconClockPlay,
IconClockShare,
IconCode,
IconStepInto,
IconLogin2,
IconLogout,
IconCodeCircle,
IconCoins,
IconColorSwatch,