Add workflow email action (#7279)

- Add the SAVE_EMAIL action. This action requires more setting
parameters than the Serverless Function action.
- Changed the way we computed the workflow diagram. It now preserves
some properties, like the `selected` property. That's necessary to not
close the right drawer when the workflow back-end data change.
- Added the possibility to set a label to a TextArea. This uses a
`<label>` HTML element and the `useId()` hook to create an id linking
the label with the input.
This commit is contained in:
Baptiste Devessier
2024-10-01 14:22:14 +02:00
committed by GitHub
parent 0d570caff5
commit cde255a031
19 changed files with 512 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import { FocusEventHandler } from 'react';
import { FocusEventHandler, useId } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@@ -10,6 +10,7 @@ import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5;
export type TextAreaProps = {
label?: string;
disabled?: boolean;
minRows?: number;
onChange?: (value: string) => void;
@@ -18,6 +19,20 @@ export type TextAreaProps = {
className?: string;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledLabel = styled.label`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledTextArea = styled(TextareaAutosize)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -48,6 +63,7 @@ const StyledTextArea = styled(TextareaAutosize)`
`;
export const TextArea = ({
label,
disabled,
placeholder,
minRows = 1,
@@ -57,6 +73,8 @@ export const TextArea = ({
}: TextAreaProps) => {
const computedMinRows = Math.min(minRows, MAX_ROWS);
const inputId = useId();
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
@@ -71,7 +89,11 @@ export const TextArea = ({
};
return (
<StyledContainer>
{label && <StyledLabel htmlFor={inputId}>{label}</StyledLabel>}
<StyledTextArea
id={inputId}
placeholder={placeholder}
maxRows={MAX_ROWS}
minRows={computedMinRows}
@@ -84,5 +106,6 @@ export const TextArea = ({
disabled={disabled}
className={className}
/>
</StyledContainer>
);
};

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ComponentDecorator } from 'twenty-ui';
import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/test';
import { TextArea, TextAreaProps } from '../TextArea';
type RenderProps = TextAreaProps;
@@ -37,3 +39,20 @@ export const Filled: Story = {
export const Disabled: Story = {
args: { disabled: true, value: 'Lorem Ipsum' },
};
export const WithLabel: Story = {
args: { label: 'My Textarea' },
play: async () => {
const canvas = within(document.body);
const label = await canvas.findByText('My Textarea');
expect(label).toBeVisible();
await userEvent.click(label);
const input = await canvas.findByRole('textbox');
expect(input).toHaveFocus();
},
};

View File

@@ -1,10 +1,12 @@
import { WorkflowEditActionForm } from '@/workflow/components/WorkflowEditActionForm';
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { useUpdateWorkflowVersionStep } from '@/workflow/hooks/useUpdateWorkflowVersionStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
@@ -75,7 +77,8 @@ export const RightDrawerWorkflowEditStepContent = ({
workflow,
});
if (stepDefinition.type === 'trigger') {
switch (stepDefinition.type) {
case 'trigger': {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
@@ -83,11 +86,30 @@ export const RightDrawerWorkflowEditStepContent = ({
/>
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<WorkflowEditActionForm
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
}
}
}
return assertUnreachable(
stepDefinition,
`Unsupported step: ${JSON.stringify(stepDefinition)}`,
);
};

View File

@@ -1,8 +1,9 @@
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 { IconCode, IconPlaylistAdd } from 'twenty-ui';
import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
@@ -32,16 +33,33 @@ export const WorkflowDiagramStepNode = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'condition': {
return null;
}
case 'action': {
switch (data.actionType) {
case 'CODE': {
return (
<StyledStepNodeLabelIconContainer>
<IconCode size={theme.icon.size.sm} color={theme.color.orange} />
<IconCode
size={theme.icon.size.sm}
color={theme.color.orange}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'SEND_EMAIL': {
return (
<StyledStepNodeLabelIconContainer>
<IconMail size={theme.icon.size.sm} color={theme.color.blue} />
</StyledStepNodeLabelIconContainer>
);
}
}
}
}
return null;
return assertUnreachable(data);
};
return (

View File

@@ -0,0 +1,61 @@
import styled from '@emotion/styled';
import React from 'react';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = 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.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowEditActionFormBase = ({
ActionIcon,
actionTitle,
actionType,
children,
}: {
ActionIcon: React.ReactNode;
actionTitle: string;
actionType: string;
children: React.ReactNode;
}) => {
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
{ActionIcon}
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>{actionTitle}</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>{actionType}</StyledTriggerHeaderType>
</StyledTriggerHeader>
{children}
</>
);
};

View File

@@ -0,0 +1,109 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { IconMail } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type SendEmailFormData = {
subject: string;
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
action,
onActionUpdate,
}: {
action: WorkflowSendEmailStep;
onActionUpdate: (action: WorkflowSendEmailStep) => void;
}) => {
const theme = useTheme();
const form = useForm<SendEmailFormData>({
defaultValues: {
subject: '',
body: '',
},
});
useEffect(() => {
form.setValue('subject', action.settings.subject ?? '');
form.setValue('body', action.settings.template ?? '');
}, [action.settings.subject, action.settings.template, form]);
const saveAction = useDebouncedCallback((formData: SendEmailFormData) => {
onActionUpdate({
...action,
settings: {
...action.settings,
title: formData.subject,
subject: formData.subject,
template: formData.body,
},
});
}, 1_000);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleSave = form.handleSubmit(saveAction);
return (
<WorkflowEditActionFormBase
ActionIcon={<IconMail color={theme.color.blue} />}
actionTitle="Send Email"
actionType="Email"
>
<StyledTriggerSettings>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<TextInput
label="Subject"
placeholder="Thank you for building such an awesome CRM!"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<TextArea
label="Body"
placeholder="Thank you so much!"
value={field.value}
minRows={4}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
</StyledTriggerSettings>
</WorkflowEditActionFormBase>
);
};

View File

@@ -1,41 +1,11 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, isDefined } from 'twenty-ui';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = 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.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
@@ -43,12 +13,12 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditActionForm = ({
export const WorkflowEditActionFormServerlessFunction = ({
action,
onActionUpdate,
}: {
action: WorkflowAction;
onActionUpdate: (trigger: WorkflowAction) => void;
action: WorkflowCodeStep;
onActionUpdate: (trigger: WorkflowCodeStep) => void;
}) => {
const theme = useTheme();
@@ -67,19 +37,11 @@ export const WorkflowEditActionForm = ({
];
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
<IconCode color={theme.color.orange} />
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
Code - Serverless Function
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>Code</StyledTriggerHeaderType>
</StyledTriggerHeader>
<WorkflowEditActionFormBase
ActionIcon={<IconCode color={theme.color.orange} />}
actionTitle="Code - Serverless Function"
actionType="Code"
>
<StyledTriggerSettings>
<Select
dropdownId="workflow-edit-action-function"
@@ -98,6 +60,6 @@ export const WorkflowEditActionForm = ({
}}
/>
</StyledTriggerSettings>
</>
</WorkflowEditActionFormBase>
);
};

View File

@@ -1,10 +1,16 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
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 { useSetRecoilState } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
type WorkflowEffectProps = {
@@ -23,6 +29,34 @@ export const WorkflowEffect = ({
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
return (currentVersion: WorkflowVersion) => {
const previousWorkflowDiagram = getSnapshotValue(
snapshot,
workflowDiagramState,
);
const nextWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
let mergedWorkflowDiagram = nextWorkflowDiagram;
if (isDefined(previousWorkflowDiagram)) {
mergedWorkflowDiagram = mergeWorkflowDiagrams(
previousWorkflowDiagram,
nextWorkflowDiagram,
);
}
const workflowDiagramWithCreateStepNodes = addCreateStepNodes(
mergedWorkflowDiagram,
);
set(workflowDiagramState, workflowDiagramWithCreateStepNodes);
};
},
[],
);
useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) {
@@ -31,12 +65,12 @@ export const WorkflowEffect = ({
return;
}
const lastWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
const workflowDiagramWithCreateStepNodes =
addCreateStepNodes(lastWorkflowDiagram);
setWorkflowDiagram(workflowDiagramWithCreateStepNodes);
}, [setWorkflowDiagram, workflowWithCurrentVersion?.currentVersion]);
computeAndMergeNewWorkflowDiagram(currentVersion);
}, [
computeAndMergeNewWorkflowDiagram,
setWorkflowDiagram,
workflowWithCurrentVersion?.currentVersion,
]);
return null;
};

View File

@@ -11,4 +11,9 @@ export const ACTIONS: Array<{
type: 'CODE',
icon: IconSettingsAutomation,
},
{
label: 'Send Email',
type: 'SEND_EMAIL',
icon: IconSettingsAutomation,
},
];

View File

@@ -25,7 +25,7 @@ export const useUpdateWorkflowVersionStep = ({
workflowId: workflow.id,
});
const updateStep = async (updatedStep: WorkflowStep) => {
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}

View File

@@ -1,4 +1,4 @@
type WorkflowBaseSettingsType = {
type BaseWorkflowStepSettings = {
errorHandlingOptions: {
retryOnFailure: {
value: boolean;
@@ -9,27 +9,42 @@ type WorkflowBaseSettingsType = {
};
};
export type WorkflowCodeSettingsType = WorkflowBaseSettingsType & {
export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
serverlessFunctionId: string;
};
export type WorkflowActionType = 'CODE';
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
subject?: string;
template?: string;
title?: string;
callToAction?: {
value: string;
href: string;
};
};
type CommonWorkflowAction = {
type BaseWorkflowStep = {
id: string;
name: string;
valid: boolean;
};
type WorkflowCodeAction = CommonWorkflowAction & {
export type WorkflowCodeStep = BaseWorkflowStep & {
type: 'CODE';
settings: WorkflowCodeSettingsType;
settings: WorkflowCodeStepSettings;
};
export type WorkflowAction = WorkflowCodeAction;
export type WorkflowSendEmailStep = BaseWorkflowStep & {
type: 'SEND_EMAIL';
settings: WorkflowSendEmailStepSettings;
};
export type WorkflowAction = WorkflowCodeStep | WorkflowSendEmailStep;
export type WorkflowStep = WorkflowAction;
export type WorkflowActionType = WorkflowAction['type'];
export type WorkflowStepType = WorkflowStep['type'];
export type WorkflowTriggerType = 'DATABASE_EVENT';

View File

@@ -1,3 +1,4 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
@@ -8,10 +9,16 @@ export type WorkflowDiagram = {
edges: Array<WorkflowDiagramEdge>;
};
export type WorkflowDiagramStepNodeData = {
nodeType: 'trigger' | 'condition' | 'action';
export type WorkflowDiagramStepNodeData =
| {
nodeType: 'trigger' | 'condition';
label: string;
};
}
| {
nodeType: 'action';
actionType: WorkflowActionType;
label: string;
};
export type WorkflowDiagramCreateStepNodeData = {
nodeType: 'create-step';

View File

@@ -72,6 +72,7 @@ describe('generateWorkflowDiagram', () => {
for (const [index, step] of steps.entries()) {
expect(stepNodes[index].data).toEqual({
nodeType: 'action',
actionType: 'CODE',
label: step.name,
});
}

View File

@@ -0,0 +1,72 @@
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { mergeWorkflowDiagrams } from '../mergeWorkflowDiagrams';
it('Preserves the properties defined in the previous version but not in the next one', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
});
});
it('Replaces duplicated properties with the next value', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
});
});

View File

@@ -0,0 +1,3 @@
export const assertUnreachable = (x: never, errorMessage?: string): never => {
throw new Error(errorMessage ?? "Didn't expect to get here.");
};

View File

@@ -33,6 +33,7 @@ export const generateWorkflowDiagram = ({
id: nodeId,
data: {
nodeType: 'action',
actionType: step.type,
label: step.name,
},
position: {

View File

@@ -26,6 +26,27 @@ export const getStepDefaultDefinition = (
},
};
}
case 'SEND_EMAIL': {
return {
id: newStepId,
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
subject: 'hello',
title: 'hello',
template: '{{title}}',
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
};
}
default: {
throw new Error(`Unknown type: ${type}`);
}

View File

@@ -0,0 +1,33 @@
import {
WorkflowDiagram,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
const nodePropertiesToPreserve: Array<keyof WorkflowDiagramNode> = ['selected'];
export const mergeWorkflowDiagrams = (
previousDiagram: WorkflowDiagram,
nextDiagram: WorkflowDiagram,
): WorkflowDiagram => {
const lastNodes = nextDiagram.nodes.map((nextNode) => {
const previousNode = previousDiagram.nodes.find(
(previousNode) => previousNode.id === nextNode.id,
);
const nodeWithPreservedProperties = nodePropertiesToPreserve.reduce(
(nodeToSet, propertyToPreserve) => {
return Object.assign(nodeToSet, {
[propertyToPreserve]: previousNode?.[propertyToPreserve],
});
},
{} as Partial<WorkflowDiagramNode>,
);
return Object.assign(nodeWithPreservedProperties, nextNode);
});
return {
nodes: lastNodes,
edges: nextDiagram.edges,
};
};

View File

@@ -1,14 +1,14 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const replaceStep = ({
export const replaceStep = <T extends WorkflowStep>({
steps: stepsInitial,
stepId,
stepToReplace,
}: {
steps: Array<WorkflowStep>;
stepId: string;
stepToReplace: Partial<Omit<WorkflowStep, 'id'>>;
stepToReplace: Partial<Omit<T, 'id'>>;
}) => {
const steps = structuredClone(stepsInitial);