Allow to add and delete fields (#10990)

- Allow to add a new field
- On field click, display a delete button
- Use id instead of names for fields



https://github.com/user-attachments/assets/4ebffe22-225a-4bae-aa49-99e66170181a
This commit is contained in:
Thomas Trompette
2025-03-18 17:24:52 +01:00
committed by GitHub
parent 6d517360d1
commit 0ce91d84c1
7 changed files with 139 additions and 38 deletions

View File

@ -86,8 +86,8 @@ export const workflowFormActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({ baseWorkflowActionSettingsSchema.extend({
input: z.array( input: z.array(
z.object({ z.object({
id: z.string().uuid(),
label: z.string(), label: z.string(),
name: z.string(),
type: z.nativeEnum(FieldMetadataType), type: z.nativeEnum(FieldMetadataType),
placeholder: z.string().optional(), placeholder: z.string().optional(),
settings: z.record(z.any()), settings: z.record(z.any()),

View File

@ -9,8 +9,16 @@ import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared'; import { useState } from 'react';
import { IconChevronDown, IconPlus, useIcons } from 'twenty-ui'; import { FieldMetadataType, isDefined } from 'twenty-shared';
import {
IconChevronDown,
IconChevronUp,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui';
import { v4 } from 'uuid';
type WorkflowEditActionFormProps = { type WorkflowEditActionFormProps = {
action: WorkflowFormAction; action: WorkflowFormAction;
@ -24,7 +32,13 @@ type WorkflowEditActionFormProps = {
}; };
}; };
const StyledContainer = styled.div` const StyledRowContainer = styled.div`
column-gap: ${({ theme }) => theme.spacing(1)};
display: grid;
grid-template-columns: 1fr 16px;
`;
const StyledFieldContainer = styled.div`
align-items: center; align-items: center;
background: transparent; background: transparent;
border: none; border: none;
@ -47,6 +61,21 @@ const StyledPlaceholder = styled.div`
width: 100%; width: 100%;
`; `;
const StyledIconButtonContainer = styled.div`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
width: 24px;
cursor: pointer;
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledAddFieldContainer = styled.div` const StyledAddFieldContainer = styled.div`
display: flex; display: flex;
color: ${({ theme }) => theme.font.color.secondary}; color: ${({ theme }) => theme.font.color.secondary};
@ -65,6 +94,19 @@ export const WorkflowEditActionForm = ({
const { t } = useLingui(); const { t } = useLingui();
const headerTitle = isDefined(action.name) ? action.name : `Form`; const headerTitle = isDefined(action.name) ? action.name : `Form`;
const headerIcon = getActionIcon(action.type); const headerIcon = getActionIcon(action.type);
const [selectedField, setSelectedField] = useState<string | null>(null);
const isFieldSelected = (fieldName: string) => selectedField === fieldName;
const handleFieldClick = (fieldName: string) => {
if (actionOptions.readonly === true) {
return;
}
if (isFieldSelected(fieldName)) {
setSelectedField(null);
} else {
setSelectedField(fieldName);
}
};
return ( return (
<> <>
@ -87,35 +129,94 @@ export const WorkflowEditActionForm = ({
/> />
<WorkflowStepBody> <WorkflowStepBody>
{action.settings.input.map((field) => ( {action.settings.input.map((field) => (
<FormFieldInputContainer key={field.name}> <FormFieldInputContainer key={field.id}>
{field.label ? <InputLabel>{field.label}</InputLabel> : null} {field.label ? <InputLabel>{field.label}</InputLabel> : null}
<FormFieldInputRowContainer> <StyledRowContainer>
<FormFieldInputInputContainer hasRightElement={false}> <FormFieldInputRowContainer>
<StyledContainer onClick={() => {}}> <FormFieldInputInputContainer
<StyledPlaceholder>{field.placeholder}</StyledPlaceholder> hasRightElement={false}
<IconChevronDown onClick={() => {
handleFieldClick(field.id);
}}
>
<StyledFieldContainer>
<StyledPlaceholder>{field.placeholder}</StyledPlaceholder>
{isFieldSelected(field.id) ? (
<IconChevronUp
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
) : (
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
)}
</StyledFieldContainer>
</FormFieldInputInputContainer>
</FormFieldInputRowContainer>
{isFieldSelected(field.id) && (
<StyledIconButtonContainer>
<IconTrash
size={theme.icon.size.md} size={theme.icon.size.md}
color={theme.font.color.tertiary} color={theme.font.color.secondary}
onClick={() => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: action.settings.input.filter(
(f) => f.id !== field.id,
),
},
});
}}
/> />
</StyledContainer> </StyledIconButtonContainer>
</FormFieldInputInputContainer> )}
</FormFieldInputRowContainer> </StyledRowContainer>
</FormFieldInputContainer> </FormFieldInputContainer>
))} ))}
{!actionOptions.readonly && ( {!actionOptions.readonly && (
<FormFieldInputContainer> <StyledRowContainer>
<FormFieldInputRowContainer> <FormFieldInputContainer>
<FormFieldInputInputContainer hasRightElement={false}> <FormFieldInputRowContainer>
<StyledContainer onClick={() => {}}> <FormFieldInputInputContainer
<StyledAddFieldContainer> hasRightElement={false}
<IconPlus size={theme.icon.size.sm} /> onClick={() => {
{t`Add Field`} actionOptions.onActionUpdate({
</StyledAddFieldContainer> ...action,
</StyledContainer> settings: {
</FormFieldInputInputContainer> ...action.settings,
</FormFieldInputRowContainer> input: [
</FormFieldInputContainer> ...action.settings.input,
{
id: v4(),
type: FieldMetadataType.TEXT,
label: 'New Field',
placeholder: 'New Field',
settings: {},
},
],
},
});
}}
>
<StyledFieldContainer>
<StyledAddFieldContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldContainer>
</StyledFieldContainer>
</FormFieldInputInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
</StyledRowContainer>
)} )}
</WorkflowStepBody> </WorkflowStepBody>
</> </>

View File

@ -17,14 +17,14 @@ const DEFAULT_ACTION = {
settings: { settings: {
input: [ input: [
{ {
name: 'company', id: 'ed00b897-519f-44cd-8201-a6502a3a9dc8',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
label: 'Company', label: 'Company',
placeholder: 'Select a company', placeholder: 'Select a company',
settings: {}, settings: {},
}, },
{ {
name: 'number', id: 'ed00b897-519f-44cd-8201-a6502a3a9dc9',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
label: 'Number', label: 'Number',
placeholder: '1000', placeholder: '1000',

View File

@ -6,17 +6,17 @@ describe('generateFakeFormResponse', () => {
it('should generate fake responses for a form schema', () => { it('should generate fake responses for a form schema', () => {
const schema = [ const schema = [
{ {
name: 'name', id: '96939213-49ac-4dee-949d-56e6c7be98e6',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
label: 'Name', label: 'Name',
}, },
{ {
name: 'age', id: '96939213-49ac-4dee-949d-56e6c7be98e7',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
label: 'Age', label: 'Age',
}, },
{ {
name: 'email', id: '96939213-49ac-4dee-949d-56e6c7be98e8',
type: FieldMetadataType.EMAILS, type: FieldMetadataType.EMAILS,
label: 'Email', label: 'Email',
}, },
@ -25,7 +25,7 @@ describe('generateFakeFormResponse', () => {
const result = generateFakeFormResponse(schema); const result = generateFakeFormResponse(schema);
expect(result).toEqual({ expect(result).toEqual({
email: { '96939213-49ac-4dee-949d-56e6c7be98e8': {
isLeaf: false, isLeaf: false,
label: 'Email', label: 'Email',
value: { value: {
@ -44,14 +44,14 @@ describe('generateFakeFormResponse', () => {
}, },
icon: undefined, icon: undefined,
}, },
name: { '96939213-49ac-4dee-949d-56e6c7be98e6': {
isLeaf: true, isLeaf: true,
label: 'Name', label: 'Name',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
value: 'My text', value: 'My text',
icon: undefined, icon: undefined,
}, },
age: { '96939213-49ac-4dee-949d-56e6c7be98e7': {
isLeaf: true, isLeaf: true,
label: 'Age', label: 'Age',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,

View File

@ -9,7 +9,7 @@ export const generateFakeFormResponse = (
formMetadata: FormFieldMetadata[], formMetadata: FormFieldMetadata[],
): Record<string, Leaf | Node> => { ): Record<string, Leaf | Node> => {
return formMetadata.reduce((acc, formFieldMetadata) => { return formMetadata.reduce((acc, formFieldMetadata) => {
acc[formFieldMetadata.name] = generateFakeField({ acc[formFieldMetadata.id] = generateFakeField({
type: formFieldMetadata.type, type: formFieldMetadata.type,
label: formFieldMetadata.label, label: formFieldMetadata.label,
}); });

View File

@ -505,14 +505,14 @@ export class WorkflowVersionStepWorkspaceService {
...BASE_STEP_DEFINITION, ...BASE_STEP_DEFINITION,
input: [ input: [
{ {
id: v4(),
label: 'Company', label: 'Company',
name: 'company',
placeholder: 'Select a company', placeholder: 'Select a company',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
}, },
{ {
id: v4(),
label: 'Number', label: 'Number',
name: 'number',
placeholder: '1000', placeholder: '1000',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
}, },

View File

@ -3,8 +3,8 @@ import { FieldMetadataType } from 'twenty-shared';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type FormFieldMetadata = { export type FormFieldMetadata = {
id: string;
label: string; label: string;
name: string;
type: FieldMetadataType; type: FieldMetadataType;
placeholder?: string; placeholder?: string;
settings?: Record<string, any>; settings?: Record<string, any>;