Add form select field (#14538)

- create a WorkflowFormFieldInput component that takes a fieldMetadataId
as input and display a form field input
- add a select field for forms


https://github.com/user-attachments/assets/42f8ce98-c17a-4809-b1be-032e3066a596
This commit is contained in:
Thomas Trompette
2025-09-16 16:26:36 +02:00
committed by GitHub
parent 4f63f55686
commit e9f277fe93
10 changed files with 258 additions and 6 deletions
@@ -13,7 +13,6 @@ import { useTheme } from '@emotion/react';
import { useId, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { IconCircleOff } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
type FormSelectFieldInputProps = {
@@ -92,12 +91,19 @@ export const FormSelectFieldInput = ({
(option) => option.value === draftValue.value,
);
const emptyOption = {
label: `No ${label}`,
const defaultEmptyOption = {
label: `No ${label ?? 'value'}`,
value: '',
Icon: IconCircleOff,
};
const existingEmptyOption = options.find(
(option) => !isDefined(option.value) || option.value === '',
);
const optionsWithEmptyOption = isDefined(existingEmptyOption)
? options
: [defaultEmptyOption, ...options];
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
@@ -132,10 +138,10 @@ export const FormSelectFieldInput = ({
{draftValue.type === 'static' ? (
<Select
dropdownId={`${instanceId}-select-display`}
options={options}
options={optionsWithEmptyOption}
value={selectedOption?.value}
onChange={onSelect}
emptyOption={emptyOption}
emptyOption={existingEmptyOption ?? defaultEmptyOption}
fullWidth
hasRightElement={isDefined(VariablePicker) && !readonly}
withSearchInput
@@ -0,0 +1,45 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { isDefined } from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
type WorkflowFormFieldInputProps = {
fieldMetadataId: string;
defaultValue: JsonValue;
readonly: boolean;
onChange: (value: JsonValue) => void;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowFormFieldInput = ({
fieldMetadataId,
defaultValue,
readonly,
onChange,
VariablePicker,
}: WorkflowFormFieldInputProps) => {
const { fieldMetadataItem, objectMetadataItem } =
useFieldMetadataItemById(fieldMetadataId);
if (!isDefined(fieldMetadataItem) || !isDefined(objectMetadataItem)) {
return null;
}
const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
});
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
defaultValue={defaultValue}
field={fieldDefinition}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
/>
);
};
@@ -9,6 +9,7 @@ import { type WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useUpdateWorkflowRunStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep';
import { WorkflowFormFieldInput } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowFormFieldInput';
import { useSubmitFormStep } from '@/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
@@ -142,6 +143,29 @@ export const WorkflowEditActionFormFiller = ({
);
}
if (field.type === 'SELECT') {
const selectedFieldId = field.settings?.selectedFieldId;
if (!isDefined(selectedFieldId)) {
return null;
}
return (
<WorkflowFormFieldInput
key={field.id}
fieldMetadataId={selectedFieldId}
defaultValue={field.value}
readonly={actionOptions.readonly}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
/>
);
}
return (
<FormFieldInput
key={field.id}
@@ -1,5 +1,6 @@
import { WorkflowFormFieldSettingsDate } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsDate';
import { WorkflowFormFieldSettingsRecordPicker } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsRecordPicker';
import { WorkflowFormFieldSettingsSelect } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsSelect';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
@@ -26,6 +27,10 @@ export const WorkflowFormFieldSettingsByType = ({
return (
<WorkflowFormFieldSettingsDate field={field} onChange={onChange} />
);
case FieldMetadataType.SELECT:
return (
<WorkflowFormFieldSettingsSelect field={field} onChange={onChange} />
);
case 'RECORD':
return (
<WorkflowFormFieldSettingsRecordPicker
@@ -0,0 +1,125 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import styled from '@emotion/styled';
import camelCase from 'lodash.camelcase';
import { FieldMetadataType } from 'twenty-shared/types';
type WorkflowFormFieldSettingsSelectProps = {
field: WorkflowFormActionField;
onChange: (updatedField: WorkflowFormActionField) => void;
};
const StyledFormFieldSettingsSelect = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledRowContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowFormFieldSettingsSelect = ({
field,
onChange,
}: WorkflowFormFieldSettingsSelectProps) => {
const selectTypeOptions = [
{
label: 'Existing Field',
value: 'EXISTING_FIELD',
},
];
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const selectFieldId = field.settings?.selectedFieldId;
const fieldOptions = activeNonSystemObjectMetadataItems.reduce(
(acc, objectMetadataItem) => {
return acc.concat(
objectMetadataItem.fields
.filter(
(field) =>
field.isActive &&
!field.isSystem &&
field.type === FieldMetadataType.SELECT,
)
.map((field) => ({
label: `${objectMetadataItem.labelSingular} > ${field.label}`,
value: field.id,
})),
);
},
[] as { label: string; value: string }[],
);
return (
<StyledFormFieldSettingsSelect>
<StyledRowContainer>
<FormFieldInputContainer>
<InputLabel>Label</InputLabel>
<FormTextFieldInput
onChange={(newLabel: string) => {
onChange({
...field,
label: newLabel,
name: camelCase(newLabel),
});
}}
defaultValue={field.label}
placeholder={
getDefaultFormFieldSettings(FieldMetadataType.SELECT).label
}
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<InputLabel>Select Type</InputLabel>
<FormSelectFieldInput
onChange={(newSelectType: string | null) => {
if (newSelectType === null) {
return;
}
onChange({
...field,
settings: {
...field.settings,
selectType: newSelectType,
},
});
}}
defaultValue={'EXISTING_FIELD'}
options={selectTypeOptions}
readonly
/>
</FormFieldInputContainer>
</StyledRowContainer>
<FormFieldInputContainer>
<InputLabel>Field</InputLabel>
<FormSelectFieldInput
onChange={(newSelectFieldId: string | null) => {
if (newSelectFieldId === null) {
return;
}
onChange({
...field,
settings: {
...field.settings,
selectedFieldId: newSelectFieldId,
},
});
}}
defaultValue={selectFieldId}
options={fieldOptions}
/>
</FormFieldInputContainer>
</StyledFormFieldSettingsSelect>
);
};
@@ -145,3 +145,31 @@ export const DateFieldSettings: Story = {
expect(args.onClose).toHaveBeenCalled();
},
};
export const SelectFieldSettings: Story = {
args: {
field: {
id: 'field-5',
name: 'select',
label: 'Select Field',
type: FieldMetadataType.SELECT,
settings: {
selectedFieldId: 'field-1',
},
},
onClose: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const fieldTypeSelect = await canvas.findByText('Select');
expect(fieldTypeSelect).toBeVisible();
const selectTypeSelect = await canvas.findByText('Select Type');
expect(selectTypeSelect).toBeVisible();
const closeButton = await canvas.findByTestId('close-button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},
};
@@ -5,6 +5,7 @@ import {
IllustrationIconCalendarEvent,
IllustrationIconNumbers,
IllustrationIconOneToMany,
IllustrationIconTag,
IllustrationIconText,
} from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
@@ -31,4 +32,9 @@ export const FORM_SELECT_FIELD_TYPE_OPTIONS: SelectOption<WorkflowFormFieldType>
value: 'RECORD',
Icon: IllustrationIconOneToMany,
},
{
label: 'Select',
value: FieldMetadataType.SELECT,
Icon: IllustrationIconTag,
},
];
@@ -4,4 +4,5 @@ export type WorkflowFormFieldType =
| FieldMetadataType.TEXT
| FieldMetadataType.NUMBER
| FieldMetadataType.DATE
| FieldMetadataType.SELECT
| 'RECORD';
@@ -36,6 +36,17 @@ export const getDefaultFormFieldSettings = (type: WorkflowFormFieldType) => {
objectName: 'company',
},
};
case FieldMetadataType.SELECT:
return {
id: v4(),
name: 'select',
label: 'Select',
placeholder: 'Choose a value',
settings: {
selectType: 'EXISTING_FIELD',
selectedFieldId: undefined,
},
};
default:
assertUnreachable(type);
}
@@ -158,6 +158,7 @@ export const workflowFormActionSettingsSchema =
z.literal(FieldMetadataType.TEXT),
z.literal(FieldMetadataType.NUMBER),
z.literal(FieldMetadataType.DATE),
z.literal(FieldMetadataType.SELECT),
z.literal('RECORD'),
]),
placeholder: z.string().optional(),