Form action field base settings (#11035)

- Add settings for text and number fields
- Settings are for now the same but I still separated with two
components because they will evolve


https://github.com/user-attachments/assets/96b7fffd-c3a1-45b9-aeaa-45d63505de3c
This commit is contained in:
Thomas Trompette
2025-03-19 18:44:02 +01:00
committed by GitHub
parent 8b513a7d3b
commit 7b0bf7c4b0
12 changed files with 481 additions and 24 deletions

View File

@ -4,12 +4,12 @@ import { FormFieldInputRowContainer } from '@/object-record/record-field/form-ty
import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor';
import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent';
import { useId } from 'react';
import { isDefined } from 'twenty-shared';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputHint } from '@/ui/input/components/InputHint';
type FormTextFieldInputProps = {
label?: string;

View File

@ -1,7 +1,10 @@
import styled from '@emotion/styled';
import { Editor, EditorContent } from '@tiptap/react';
const StyledEditor = styled.div<{ multiline?: boolean; readonly?: boolean }>`
const StyledEditor = styled.div<{
multiline?: boolean;
readonly?: boolean;
}>`
width: 100%;
display: flex;
box-sizing: border-box;

View File

@ -88,9 +88,12 @@ export const workflowFormActionSettingsSchema =
z.object({
id: z.string().uuid(),
label: z.string(),
type: z.nativeEnum(FieldMetadataType),
type: z.union([
z.literal(FieldMetadataType.TEXT),
z.literal(FieldMetadataType.NUMBER),
]),
placeholder: z.string().optional(),
settings: z.record(z.any()),
settings: z.record(z.any()).optional(),
}),
),
});

View File

@ -4,9 +4,9 @@ import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrTh
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionForm';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionForm';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';

View File

@ -5,11 +5,13 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FieldMetadataType, isDefined } from 'twenty-shared';
import {
IconChevronDown,
@ -18,9 +20,11 @@ import {
IconTrash,
useIcons,
} from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
type WorkflowEditActionFormProps = {
export type WorkflowEditActionFormProps = {
action: WorkflowFormAction;
actionOptions:
| {
@ -32,6 +36,16 @@ type WorkflowEditActionFormProps = {
};
};
export type WorkflowFormActionField = {
id: string;
label: string;
type: FieldMetadataType.TEXT | FieldMetadataType.NUMBER;
placeholder?: string;
settings?: Record<string, unknown>;
};
type FormData = WorkflowFormActionField[];
const StyledRowContainer = styled.div`
column-gap: ${({ theme }) => theme.spacing(1)};
display: grid;
@ -92,6 +106,9 @@ export const WorkflowEditActionForm = ({
const theme = useTheme();
const { getIcon } = useIcons();
const { t } = useLingui();
const [formData, setFormData] = useState<FormData>(action.settings.input);
const headerTitle = isDefined(action.name) ? action.name : `Form`;
const headerIcon = getActionIcon(action.type);
const [selectedField, setSelectedField] = useState<string | null>(null);
@ -108,6 +125,40 @@ export const WorkflowEditActionForm = ({
}
};
const onFieldUpdate = (updatedField: WorkflowFormActionField) => {
if (actionOptions.readonly === true) {
return;
}
const updatedFormData = formData.map((currentField) =>
currentField.id === updatedField.id ? updatedField : currentField,
);
setFormData(updatedFormData);
saveAction(updatedFormData);
};
const saveAction = useDebouncedCallback(async (formData: FormData) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: formData,
},
});
}, 1_000);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<>
<WorkflowStepHeader
@ -128,7 +179,7 @@ export const WorkflowEditActionForm = ({
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
{action.settings.input.map((field) => (
{formData.map((field) => (
<FormFieldInputContainer key={field.id}>
{field.label ? <InputLabel>{field.label}</InputLabel> : null}
@ -166,19 +217,32 @@ export const WorkflowEditActionForm = ({
return;
}
const updatedFormData = formData.filter(
(currentField) => currentField.id !== field.id,
);
setFormData(updatedFormData);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: action.settings.input.filter(
(f) => f.id !== field.id,
),
input: updatedFormData,
},
});
}}
/>
</StyledIconButtonContainer>
)}
{isFieldSelected(field.id) && (
<WorkflowEditActionFormFieldSettings
field={field}
onChange={onFieldUpdate}
onClose={() => {
setSelectedField(null);
}}
/>
)}
</StyledRowContainer>
</FormFieldInputContainer>
))}
@ -189,20 +253,24 @@ export const WorkflowEditActionForm = ({
<FormFieldInputInputContainer
hasRightElement={false}
onClick={() => {
const { label, placeholder } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const newField: WorkflowFormActionField = {
id: v4(),
type: FieldMetadataType.TEXT,
label,
placeholder,
};
setFormData([...formData, newField]);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: [
...action.settings.input,
{
id: v4(),
type: FieldMetadataType.TEXT,
label: 'New Field',
placeholder: 'New Field',
settings: {},
},
],
input: [...action.settings.input, newField],
},
});
}}

View File

@ -0,0 +1,140 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionForm';
import { WorkflowFormFieldSettingsByType } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared';
import {
IconSettingsAutomation,
IconX,
IllustrationIconNumbers,
IllustrationIconText,
LightIconButton,
} from 'twenty-ui';
type WorkflowEditActionFormFieldSettingsProps = {
field: WorkflowFormActionField;
onChange: (field: WorkflowFormActionField) => void;
onClose: () => void;
};
const StyledFormFieldSettingsContainer = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledSettingsContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledSettingsHeader = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: grid;
gap: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(3)};
grid-template-columns: 1fr 24px;
padding-bottom: ${({ theme }) => theme.spacing(3)};
`;
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledCloseButtonContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowEditActionFormFieldSettings = ({
field,
onChange,
onClose,
}: WorkflowEditActionFormFieldSettingsProps) => {
const theme = useTheme();
const onSubFieldUpdate = (fieldName: string, value: any) => {
onChange({
...field,
[fieldName]: value,
});
};
return (
<StyledFormFieldSettingsContainer>
<StyledSettingsHeader>
<StyledTitleContainer>
<IconSettingsAutomation
size={theme.icon.size.md}
color={theme.font.color.primary}
/>
{t`Input settings`}
</StyledTitleContainer>
<StyledCloseButtonContainer>
<LightIconButton
Icon={IconX}
size="small"
accent="secondary"
onClick={onClose}
/>
</StyledCloseButtonContainer>
</StyledSettingsHeader>
<StyledSettingsContent>
<FormFieldInputContainer>
<InputLabel>Type</InputLabel>
<FormSelectFieldInput
options={[
{
label: getDefaultFormFieldSettings(FieldMetadataType.TEXT)
.label,
value: FieldMetadataType.TEXT,
icon: IllustrationIconText,
},
{
label: getDefaultFormFieldSettings(FieldMetadataType.NUMBER)
.label,
value: FieldMetadataType.NUMBER,
icon: IllustrationIconNumbers,
},
]}
onPersist={(newType: string | null) => {
if (newType === null) {
return;
}
const type = newType as
| FieldMetadataType.TEXT
| FieldMetadataType.NUMBER;
const { label, placeholder } = getDefaultFormFieldSettings(type);
onChange({
...field,
type,
label,
placeholder,
});
}}
defaultValue={field.type}
preventDisplayPadding
/>
</FormFieldInputContainer>
<WorkflowFormFieldSettingsByType
field={field}
onChange={onSubFieldUpdate}
/>
</StyledSettingsContent>
</StyledFormFieldSettingsContainer>
);
};

View File

@ -0,0 +1,37 @@
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionForm';
import { assertUnreachable, FieldMetadataType } from 'twenty-shared';
import { WorkflowFormFieldSettingsNumber } from './WorkflowFormFieldSettingsNumber';
import { WorkflowFormFieldSettingsText } from './WorkflowFormFieldSettingsText';
export const WorkflowFormFieldSettingsByType = ({
field,
onChange,
}: {
field: WorkflowFormActionField;
onChange: (fieldName: string, value: string | null) => void;
}) => {
switch (field.type) {
case FieldMetadataType.TEXT:
return (
<WorkflowFormFieldSettingsText
label={field.label}
placeholder={field.placeholder}
onChange={(fieldName, value) => {
onChange(fieldName, value);
}}
/>
);
case FieldMetadataType.NUMBER:
return (
<WorkflowFormFieldSettingsNumber
label={field.label}
placeholder={field.placeholder}
onChange={(fieldName, value) => {
onChange(fieldName, value);
}}
/>
);
default:
return assertUnreachable(field.type, 'Unknown form field type');
}
};

View File

@ -0,0 +1,48 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
type WorkflowFormFieldSettingsNumberProps = {
label?: string;
placeholder?: string;
onChange: (fieldName: string, value: string | null) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowFormFieldSettingsNumber = ({
label,
placeholder,
onChange,
}: WorkflowFormFieldSettingsNumberProps) => {
return (
<StyledContainer>
<FormFieldInputContainer>
<InputLabel>Label</InputLabel>
<FormTextFieldInput
onPersist={(newLabel: string | null) => {
onChange('label', newLabel);
}}
defaultValue={label ?? t`Number`}
placeholder={t`Text`}
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<InputLabel>Placeholder</InputLabel>
<FormTextFieldInput
onPersist={(newPlaceholder: string | null) => {
onChange('placeholder', newPlaceholder);
}}
defaultValue={placeholder ?? '1000'}
placeholder={'1000'}
/>
</FormFieldInputContainer>
</StyledContainer>
);
};

View File

@ -0,0 +1,47 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import styled from '@emotion/styled';
type WorkflowFormFieldSettingsTextProps = {
label?: string;
placeholder?: string;
onChange: (fieldName: string, value: string | null) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowFormFieldSettingsText = ({
label,
placeholder,
onChange,
}: WorkflowFormFieldSettingsTextProps) => {
return (
<StyledContainer>
<FormFieldInputContainer>
<InputLabel>Label</InputLabel>
<FormTextFieldInput
onPersist={(newLabel: string | null) => {
onChange('label', newLabel);
}}
defaultValue={label}
placeholder={'Text'}
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<InputLabel>Placeholder</InputLabel>
<FormTextFieldInput
onPersist={(newPlaceholder: string | null) => {
onChange('placeholder', newPlaceholder);
}}
defaultValue={placeholder}
placeholder={'Enter your text'}
/>
</FormFieldInputContainer>
</StyledContainer>
);
};

View File

@ -1,5 +1,5 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionForm';
import { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionForm';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared';
@ -44,7 +44,7 @@ const DEFAULT_ACTION = {
} satisfies WorkflowFormAction;
const meta: Meta<typeof WorkflowEditActionForm> = {
title: 'Modules/Workflow/WorkflowEditActionForm',
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionForm',
component: WorkflowEditActionForm,
parameters: {
msw: graphqlMocks,

View File

@ -0,0 +1,90 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowEditActionFormFieldSettings } from '../WorkflowEditActionFormFieldSettings';
const meta: Meta<typeof WorkflowEditActionFormFieldSettings> = {
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFieldSettings',
component: WorkflowEditActionFormFieldSettings,
decorators: [
WorkflowStepActionDrawerDecorator,
ComponentDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormFieldSettings>;
const mockAction: WorkflowFormAction = {
id: 'form-action-1',
type: 'FORM',
name: 'Test Form',
valid: true,
settings: {
input: [
{
id: 'field-1',
label: 'Text Field',
type: FieldMetadataType.TEXT,
placeholder: 'Enter text',
settings: {},
},
],
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
export const TextFieldSettings: Story = {
args: {
field: mockAction.settings.input[0],
onClose: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const typeSelect = await canvas.findByText('Text');
expect(typeSelect).toBeVisible();
const placeholderInput = await canvas.findByText('Enter text');
expect(placeholderInput).toBeVisible();
const closeButton = await canvas.findByRole('button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},
};
export const NumberFieldSettings: Story = {
args: {
field: {
id: 'field-2',
label: 'Number Field',
type: FieldMetadataType.NUMBER,
placeholder: 'Enter number',
settings: {},
},
onClose: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const typeSelect = await canvas.findByText('Number');
expect(typeSelect).toBeVisible();
const placeholderInput = await canvas.findByText('Enter number');
expect(placeholderInput).toBeInTheDocument();
const closeButton = await canvas.findByRole('button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},
};

View File

@ -0,0 +1,21 @@
import { FieldMetadataType } from 'twenty-shared';
export const getDefaultFormFieldSettings = (type: FieldMetadataType) => {
switch (type) {
case FieldMetadataType.TEXT:
return {
label: 'Text',
placeholder: 'Enter your text',
};
case FieldMetadataType.NUMBER:
return {
label: 'Number',
placeholder: '1000',
};
default:
return {
label: type.charAt(0).toUpperCase() + type.slice(1),
placeholder: 'Enter your value',
};
}
};