Make workflow step name editable (#8677)

- Use TextInput in header title
- add onTitleChange prop
- rename field name instead of label

To fix :
- padding right on title comes from current TextInput component. It
needs to be refactored


https://github.com/user-attachments/assets/535cd6d3-866b-4a61-9c5d-cdbe7710396a
This commit is contained in:
Thomas Trompette 2024-11-22 16:25:01 +01:00 committed by GitHub
parent 4d8445a34a
commit 5ec6cb0e6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 217 additions and 157 deletions

View File

@ -19,10 +19,19 @@ type TextInputProps = {
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
onChange?: (newText: string) => void;
copyButton?: boolean;
shouldTrim?: boolean;
};
const getValue = (value: string, shouldTrim: boolean) => {
if (shouldTrim) {
return value.trim();
}
return value;
};
export const TextInput = ({
@ -37,6 +46,7 @@ export const TextInput = ({
onClickOutside,
onChange,
copyButton = true,
shouldTrim = true,
}: TextInputProps) => {
const [internalText, setInternalText] = useState(value);
@ -44,12 +54,12 @@ export const TextInput = ({
const copyRef = useRef<HTMLDivElement>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setInternalText(event.target.value.trim());
onChange?.(event.target.value.trim());
setInternalText(getValue(event.target.value, shouldTrim));
onChange?.(getValue(event.target.value, shouldTrim));
};
useEffect(() => {
setInternalText(value.trim());
}, [value]);
setInternalText(getValue(value, shouldTrim));
}, [value, shouldTrim]);
useRegisterInputEvents({
inputRef: wrapperRef,

View File

@ -39,7 +39,7 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({
<MenuItem
key={action.type}
LeftIcon={action.icon}
text={action.label}
text={action.name}
onClick={async () => {
await updateTrigger(
getTriggerDefaultDefinition({

View File

@ -2,7 +2,7 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import React from 'react';
import { isDefined } from 'twenty-ui';
import { isDefined, OverflowingTextWithTooltip } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
type Variant = 'placeholder';
@ -68,6 +68,7 @@ const StyledStepNodeLabel = styled.div<{ variant?: Variant }>`
variant === 'placeholder'
? theme.font.color.extraLight
: theme.font.color.primary};
max-width: 200px;
`;
const StyledSourceHandle = styled(Handle)`
@ -90,13 +91,13 @@ const StyledRightFloatingElementContainer = styled.div`
export const WorkflowDiagramBaseStepNode = ({
nodeType,
label,
name,
variant,
Icon,
RightFloatingElement,
}: {
nodeType: WorkflowDiagramStepNodeData['nodeType'];
label: string;
name: string;
variant?: Variant;
Icon?: React.ReactNode;
RightFloatingElement?: React.ReactNode;
@ -113,7 +114,7 @@ export const WorkflowDiagramBaseStepNode = ({
<StyledStepNodeLabel variant={variant}>
{Icon}
{label}
<OverflowingTextWithTooltip text={name} />
</StyledStepNodeLabel>
{isDefined(RightFloatingElement) ? (

View File

@ -17,7 +17,7 @@ export const WorkflowDiagramEmptyTrigger = () => {
return (
<WorkflowDiagramBaseStepNode
label="Add a Trigger"
name="Add a Trigger"
nodeType="trigger"
variant="placeholder"
Icon={

View File

@ -100,8 +100,8 @@ export const WorkflowDiagramStepNodeBase = ({
return (
<WorkflowDiagramBaseStepNode
name={data.name}
nodeType={data.nodeType}
label={data.label}
Icon={renderStepIcon()}
RightFloatingElement={RightFloatingElement}
/>

View File

@ -119,15 +119,27 @@ export const WorkflowEditActionFormRecordCreate = ({
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={
<IconAddressBook
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
}
headerTitle="Record Create"
headerTitle={headerTitle}
headerType="Action"
>
<Select

View File

@ -166,11 +166,23 @@ export const WorkflowEditActionFormSendEmail = ({
}
});
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
return (
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={<IconMail color={theme.color.blue} />}
headerTitle="Send Email"
headerTitle={headerTitle}
headerType="Email"
>
<Controller

View File

@ -217,10 +217,24 @@ export const WorkflowEditActionFormServerlessFunctionInner = ({
});
};
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions?.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={<IconCode color={theme.color.orange} />}
headerTitle="Code - Serverless Function"
headerTitle={headerTitle}
headerType="Code"
>
<Select

View File

@ -1,5 +1,7 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import styled from '@emotion/styled';
import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
@ -40,22 +42,36 @@ const StyledContentContainer = styled.div`
`;
export const WorkflowEditGenericFormBase = ({
onTitleChange,
HeaderIcon,
headerTitle,
headerType,
children,
}: {
onTitleChange: (newTitle: string) => void;
HeaderIcon: React.ReactNode;
headerTitle: string;
headerType: string;
children: React.ReactNode;
}) => {
const debouncedOnTitleChange = useDebouncedCallback(onTitleChange, 100);
return (
<>
<StyledHeader>
<StyledHeaderIconContainer>{HeaderIcon}</StyledHeaderIconContainer>
<StyledHeaderTitle>{headerTitle}</StyledHeaderTitle>
<StyledHeaderTitle>
<TextInput
value={headerTitle}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={debouncedOnTitleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeader>

View File

@ -1,50 +1,12 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
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 styled from '@emotion/styled';
import { IconPlaylistAdd, 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;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger;
triggerOptions:
@ -87,27 +49,32 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
)
: undefined;
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
<IconPlaylistAdd color={theme.font.color.tertiary} />
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
{isDefined(recordTypeMetadata) && isDefined(selectedEvent)
const headerTitle = isDefined(trigger.name)
? trigger.name
: isDefined(recordTypeMetadata) && isDefined(selectedEvent)
? `When a ${recordTypeMetadata.labelSingular} is ${selectedEvent.label}`
: '-'}
</StyledTriggerHeaderTitle>
: '-';
<StyledTriggerHeaderType>
{isDefined(selectedEvent)
const headerType = isDefined(selectedEvent)
? `Trigger · Record is ${selectedEvent.label}`
: '-'}
</StyledTriggerHeaderType>
</StyledTriggerHeader>
: '-';
<StyledTriggerSettings>
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
HeaderIcon={<IconPlaylistAdd color={theme.font.color.tertiary} />}
headerTitle={headerTitle}
headerType={headerType}
>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
@ -131,6 +98,7 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
@ -163,6 +131,7 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata[0].value}.${updatedEvent}`,
@ -172,7 +141,6 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
);
}}
/>
</StyledTriggerSettings>
</>
</WorkflowEditGenericFormBase>
);
};

View File

@ -44,10 +44,22 @@ export const WorkflowEditTriggerManualForm = ({
? 'WHEN_RECORD_SELECTED'
: 'EVERYWHERE';
const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
HeaderIcon={<IconHandMove color={theme.font.color.tertiary} />}
headerTitle="Manual Trigger"
headerTitle={headerTitle}
headerType="Trigger · Manual"
>
<Select

View File

@ -2,17 +2,17 @@ import { WorkflowTriggerType } from '@/workflow/types/Workflow';
import { IconComponent, IconSettingsAutomation } from 'twenty-ui';
export const TRIGGER_TYPES: Array<{
label: string;
name: string;
type: WorkflowTriggerType;
icon: IconComponent;
}> = [
{
label: 'Database Event',
name: 'Database Event',
type: 'DATABASE_EVENT',
icon: IconSettingsAutomation,
},
{
label: 'Manual',
name: 'Manual Trigger',
type: 'MANUAL',
icon: IconSettingsAutomation,
},

View File

@ -49,7 +49,9 @@ export const useAvailableVariablesInWorkflowStep = (): StepOutputSchema[] => {
) {
result.push({
id: 'trigger',
name: getTriggerStepName(workflow.currentVersion.trigger),
name: isDefined(workflow.currentVersion.trigger.name)
? workflow.currentVersion.trigger.name
: getTriggerStepName(workflow.currentVersion.trigger),
outputSchema: workflow.currentVersion.trigger.settings.outputSchema,
});
}

View File

@ -109,8 +109,8 @@ export type WorkflowActionType =
export type WorkflowStepType = WorkflowActionType;
type BaseTrigger = {
name?: string;
type: string;
input?: object;
};
export type WorkflowDatabaseEventTrigger = BaseTrigger & {

View File

@ -16,12 +16,12 @@ export type WorkflowDiagramStepNodeData =
| {
nodeType: 'trigger';
triggerType: WorkflowTriggerType;
label: string;
name: string;
}
| {
nodeType: 'action';
actionType: WorkflowActionType;
label: string;
name: string;
};
export type WorkflowDiagramCreateStepNodeData = {

View File

@ -5,6 +5,7 @@ import { addCreateStepNodes } from '../addCreateStepNodes';
describe('addCreateStepNodes', () => {
it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',

View File

@ -4,6 +4,7 @@ import { generateWorkflowDiagram } from '../generateWorkflowDiagram';
describe('generateWorkflowDiagram', () => {
it('should generate a single trigger node when no step is provided', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
@ -19,7 +20,6 @@ describe('generateWorkflowDiagram', () => {
expect(result.nodes[0]).toMatchObject({
data: {
label: 'Company is Created',
nodeType: 'trigger',
},
});
@ -27,6 +27,7 @@ describe('generateWorkflowDiagram', () => {
it('should generate a diagram with nodes and edges corresponding to the steps', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
@ -85,13 +86,14 @@ describe('generateWorkflowDiagram', () => {
expect(stepNodes[index].data).toEqual({
nodeType: 'action',
actionType: 'CODE',
label: step.name,
name: step.name,
});
}
});
it('should correctly link nodes with edges', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',

View File

@ -42,6 +42,7 @@ describe('getWorkflowVersionDiagram', () => {
name: '',
steps: null,
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -53,7 +54,7 @@ describe('getWorkflowVersionDiagram', () => {
nodes: [
{
data: {
label: 'Company is Created',
name: 'Company created',
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
},
@ -93,6 +94,7 @@ describe('getWorkflowVersionDiagram', () => {
},
],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},

View File

@ -11,6 +11,7 @@ describe('insertStep', () => {
name: '',
steps: [],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -54,6 +55,7 @@ describe('insertStep', () => {
name: '',
steps: [],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -135,6 +137,7 @@ describe('insertStep', () => {
},
],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -220,6 +223,7 @@ describe('insertStep', () => {
},
],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},

View File

@ -5,7 +5,7 @@ it('Preserves the properties defined in the previous version but not in the next
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
@ -16,7 +16,7 @@ it('Preserves the properties defined in the previous version but not in the next
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
@ -27,7 +27,7 @@ it('Preserves the properties defined in the previous version but not in the next
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
@ -41,7 +41,7 @@ it('Replaces duplicated properties with the next value', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
@ -51,7 +51,7 @@ it('Replaces duplicated properties with the next value', () => {
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
@ -62,7 +62,7 @@ it('Replaces duplicated properties with the next value', () => {
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},

View File

@ -28,6 +28,7 @@ it('returns a deep copy of the provided steps array instead of mutating it', ()
name: '',
steps: [stepToBeRemoved],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -108,6 +109,7 @@ it('removes a step in a non-empty steps array', () => {
},
],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},

View File

@ -29,6 +29,7 @@ describe('replaceStep', () => {
name: '',
steps: [stepToBeReplaced],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
@ -123,6 +124,7 @@ describe('replaceStep', () => {
},
],
trigger: {
name: 'Company created',
settings: {
eventName: 'company.created',
outputSchema: {},

View File

@ -53,7 +53,7 @@ export const generateWorkflowDiagram = ({
data: {
nodeType: 'action',
actionType: nodeActionType,
label: nodeLabel,
name: isDefined(step.name) ? step.name : nodeLabel,
},
position: {
x: xPos,
@ -110,7 +110,7 @@ export const generateWorkflowDiagram = ({
data: {
nodeType: 'trigger',
triggerType: trigger.type,
label: triggerLabel,
name: isDefined(trigger.name) ? trigger.name : triggerLabel,
},
position: {
x: 0,

View File

@ -65,7 +65,7 @@ export const getStepDefaultDefinition = ({
case 'RECORD_CRUD.CREATE': {
return {
id: newStepId,
name: 'Record Create',
name: 'Create Record',
type: 'RECORD_CRUD',
valid: false,
settings: {