Simple design for empty node (#15087)

I did an acceptable design for empty node and iterators for release:
- use array field for iterator node. Added an util to stringify arrays
for backward compatibily
- remove icon for empty node
- allow to select a node on empty node selection


https://github.com/user-attachments/assets/b00037a8-aa1d-4784-b973-05973649b46e
This commit is contained in:
Thomas Trompette
2025-10-14 16:58:53 +02:00
committed by GitHub
parent 23de047787
commit 5f1878c96f
11 changed files with 82 additions and 57 deletions
@@ -250,7 +250,6 @@ export const WorkflowRunStepNodeDetail = ({
return (
<WorkflowEditActionEmpty
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
@@ -224,13 +224,7 @@ export const WorkflowStepDetail = ({
);
}
case 'EMPTY': {
return (
<WorkflowEditActionEmpty
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
return <WorkflowEditActionEmpty key={stepId} actionOptions={props} />;
}
default:
return assertUnreachable(
@@ -1,12 +1,8 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { CommandMenuWorkflowSelectAction } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction';
import { CommandMenuWorkflowEditStepType } from '@/command-menu/pages/workflow/step/edit/components/CommandMenuWorkflowEditStepType';
import { type WorkflowEmptyAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionEmptyProps = {
action: WorkflowEmptyAction;
actionOptions:
| {
readonly: true;
@@ -18,37 +14,11 @@ type WorkflowEditActionEmptyProps = {
};
export const WorkflowEditActionEmpty = ({
action,
actionOptions,
}: WorkflowEditActionEmptyProps) => {
const { getIcon } = useIcons();
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Empty Node',
});
if (actionOptions.readonly === true) {
return <CommandMenuWorkflowSelectAction onActionSelected={() => {}} />;
}
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>{'Empty Node'}</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
return <CommandMenuWorkflowEditStepType />;
};
@@ -1,11 +1,15 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput';
import { type FieldArrayValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type WorkflowIteratorAction } from '@/workflow/types/Workflow';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useLingui } from '@lingui/react/macro';
import { isArray, isString } from '@sniptt/guards';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionIteratorProps = {
@@ -20,6 +24,20 @@ type WorkflowEditActionIteratorProps = {
};
};
const stringifyArrayItems = (array: FieldArrayValue) => {
return array.map((item) => {
if (!isDefined(item)) {
return '';
}
if (isString(item)) {
return item;
}
return JSON.stringify(item);
});
};
export const WorkflowEditActionIterator = ({
action,
actionOptions,
@@ -32,8 +50,18 @@ export const WorkflowEditActionIterator = ({
const { t } = useLingui();
const defaultItems = isDefined(action.settings.input.items)
? action.settings.input.items
: [];
const parsedItems = isStandaloneVariableString(defaultItems)
? defaultItems
: isArray(defaultItems)
? stringifyArrayItems(defaultItems)
: stringifyArrayItems(JSON.parse(defaultItems));
const [formData, setFormData] = useState({
items: action.settings.input.items || [],
items: parsedItems,
initialLoopStepIds: action.settings.input.initialLoopStepIds || [],
});
@@ -57,7 +85,10 @@ export const WorkflowEditActionIterator = ({
1000,
);
const handleFieldChange = (field: string, value: any) => {
const handleFieldChange = (
field: string,
value: string | FieldArrayValue,
) => {
if (actionOptions.readonly === true) {
return;
}
@@ -89,15 +120,13 @@ export const WorkflowEditActionIterator = ({
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
<FormTextFieldInput
<FormArrayFieldInput
label={t`Items to iterate over`}
placeholder={t`Enter array of items or variable expression`}
defaultValue={
Array.isArray(formData.items)
? JSON.stringify(formData.items)
: (formData.items as string) || ''
defaultValue={formData.items}
onChange={(value: string | FieldArrayValue) =>
handleFieldChange('items', value)
}
onChange={(value: string) => handleFieldChange('items', value)}
readonly={actionOptions.readonly}
VariablePicker={WorkflowVariablePicker}
/>
@@ -24,7 +24,7 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
return msg`Iterator`;
}
case 'EMPTY': {
return msg`Empty Node`;
return msg`Add an Action`;
}
default:
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
@@ -23,6 +23,8 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
return HUMAN_INPUT_ACTIONS.find((item) => item.type === actionType)?.icon;
case 'ITERATOR':
return 'IconRepeat';
case 'EMPTY':
return 'IconSettingsAutomation';
default:
return 'IconDefault';
}