Add Select form field (#8815)

Closes https://github.com/twentyhq/twenty/pull/8815

I took inspiration from existing parts of the codebase. Please, see the
comments I left below.

Remaining questions:

- I'm not sure about the best way to handle hotkey scopes in the
components easily



https://github.com/user-attachments/assets/7a6dd144-d528-4f68-97cd-c9181f3954f9
This commit is contained in:
Baptiste Devessier 2024-12-04 15:39:14 +01:00 committed by GitHub
parent 2c0d3e93d2
commit 9142bdfb92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 410 additions and 63 deletions

View File

@ -1,15 +1,18 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput'; import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
type FormFieldInputProps = { type FormFieldInputProps = {
field: FieldMetadataItem; field: FieldDefinition<FieldMetadata>;
defaultValue: JsonValue; defaultValue: JsonValue;
onPersist: (value: JsonValue) => void; onPersist: (value: JsonValue) => void;
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
@ -23,7 +26,6 @@ export const FormFieldInput = ({
}: FormFieldInputProps) => { }: FormFieldInputProps) => {
return isFieldNumber(field) ? ( return isFieldNumber(field) ? (
<FormNumberFieldInput <FormNumberFieldInput
key={field.id}
label={field.label} label={field.label}
defaultValue={defaultValue as string | number | undefined} defaultValue={defaultValue as string | number | undefined}
onPersist={onPersist} onPersist={onPersist}
@ -32,7 +34,6 @@ export const FormFieldInput = ({
/> />
) : isFieldBoolean(field) ? ( ) : isFieldBoolean(field) ? (
<FormBooleanFieldInput <FormBooleanFieldInput
key={field.id}
label={field.label} label={field.label}
defaultValue={defaultValue as string | boolean | undefined} defaultValue={defaultValue as string | boolean | undefined}
onPersist={onPersist} onPersist={onPersist}
@ -46,5 +47,13 @@ export const FormFieldInput = ({
placeholder={field.label} placeholder={field.label}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
/> />
) : isFieldSelect(field) ? (
<FormSelectFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}
field={field}
VariablePicker={VariablePicker}
/>
) : null; ) : null;
}; };

View File

@ -0,0 +1,264 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldSelectMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { SINGLE_RECORD_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleRecordSelectBaseList';
import { SelectOption } from '@/spreadsheet-import/types';
import { SelectDisplay } from '@/ui/field/display/components/SelectDisplay';
import { SelectInput } from '@/ui/field/input/components/SelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
import { useId, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined, VisibilityHidden } from 'twenty-ui';
type FormSelectFieldInputProps = {
field: FieldDefinition<FieldSelectMetadata>;
label?: string;
defaultValue: string | undefined;
onPersist: (value: number | null | string) => void;
VariablePicker?: VariablePickerComponent;
};
const StyledDisplayModeContainer = styled.button`
width: 100%;
align-items: center;
display: flex;
cursor: pointer;
border: none;
background: transparent;
font-family: inherit;
padding-inline: ${({ theme }) => theme.spacing(2)};
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
export const FormSelectFieldInput = ({
label,
field,
defaultValue,
onPersist,
VariablePicker,
}: FormSelectFieldInputProps) => {
const inputId = useId();
const hotkeyScope = InlineCellHotkeyScope.InlineCell;
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const [draftValue, setDraftValue] = useState<
| {
type: 'static';
value: string;
editingMode: 'view' | 'edit';
}
| {
type: 'variable';
value: string;
}
>(
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: isDefined(defaultValue) ? String(defaultValue) : '',
editingMode: 'view',
},
);
const onSubmit = (option: string) => {
setDraftValue({
type: 'static',
value: option,
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
onPersist(option);
};
const onCancel = () => {
if (draftValue.type !== 'static') {
throw new Error('Can only be called when editing a static value');
}
setDraftValue({
...draftValue,
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
};
const [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
const { resetSelectedItem } = useSelectableList(
SINGLE_RECORD_SELECT_BASE_LIST,
);
const clearField = () => {
setDraftValue({
type: 'static',
editingMode: 'view',
value: '',
});
onPersist(null);
};
const selectedOption = field.metadata.options.find(
(option) => option.value === draftValue.value,
);
const handleClearField = () => {
clearField();
goBackToPreviousHotkeyScope();
};
const handleSubmit = (option: SelectOption) => {
onSubmit(option.value);
resetSelectedItem();
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
value: '',
editingMode: 'view',
});
onPersist(null);
};
const handleVariableTagInsert = (variableName: string) => {
setDraftValue({
type: 'variable',
value: variableName,
});
onPersist(variableName);
};
const handleDisplayModeClick = () => {
if (draftValue.type !== 'static') {
throw new Error(
'This function can only be called when editing a static value.',
);
}
setDraftValue({
...draftValue,
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
};
const handleSelectEnter = (itemId: string) => {
const option = filteredOptions.find((option) => option.value === itemId);
if (isDefined(option)) {
onSubmit(option.value);
resetSelectedItem();
}
};
useScopedHotkeys(
Key.Escape,
() => {
onCancel();
resetSelectedItem();
},
hotkeyScope,
[onCancel, resetSelectedItem],
);
const optionIds = [
`No ${field.label}`,
...filteredOptions.map((option) => option.value),
];
return (
<StyledFormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
ref={setSelectWrapperRef}
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
<>
<StyledDisplayModeContainer
data-open={draftValue.editingMode === 'edit'}
onClick={handleDisplayModeClick}
>
<VisibilityHidden>Edit</VisibilityHidden>
{isDefined(selectedOption) ? (
<SelectDisplay
color={selectedOption.color}
label={selectedOption.label}
/>
) : null}
</StyledDisplayModeContainer>
{draftValue.editingMode === 'edit' ? (
<SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={handleSelectEnter}
selectWrapperRef={selectWrapperRef}
onOptionSelected={handleSubmit}
options={field.metadata.options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={
field.metadata.isNullable ? handleClearField : undefined
}
clearLabel={field.label}
/>
) : null}
</>
) : (
<VariableChip
rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -1,6 +1,5 @@
import { Tag } from 'twenty-ui';
import { useSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useSelectFieldDisplay'; import { useSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useSelectFieldDisplay';
import { SelectDisplay } from '@/ui/field/display/components/SelectDisplay';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
export const SelectFieldDisplay = () => { export const SelectFieldDisplay = () => {
@ -15,10 +14,6 @@ export const SelectFieldDisplay = () => {
} }
return ( return (
<Tag <SelectDisplay color={selectedOption.color} label={selectedOption.label} />
preventShrink
color={selectedOption.color}
text={selectedOption.label}
/>
); );
}; };

View File

@ -3,8 +3,7 @@ import { useSelectField } from '@/object-record/record-field/meta-types/hooks/us
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { SINGLE_RECORD_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleRecordSelectBaseList'; import { SINGLE_RECORD_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleRecordSelectBaseList';
import { SelectOption } from '@/spreadsheet-import/types'; import { SelectOption } from '@/spreadsheet-import/types';
import { SelectInput } from '@/ui/input/components/SelectInput'; import { SelectInput } from '@/ui/field/input/components/SelectInput';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useState } from 'react'; import { useState } from 'react';
@ -64,7 +63,7 @@ export const SelectFieldInput = ({
return ( return (
<div ref={setSelectWrapperRef}> <div ref={setSelectWrapperRef}>
<SelectableList <SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST} selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds} selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
@ -77,21 +76,17 @@ export const SelectFieldInput = ({
resetSelectedItem(); resetSelectedItem();
} }
}} }}
> selectWrapperRef={selectWrapperRef}
<SelectInput onOptionSelected={handleSubmit}
parentRef={selectWrapperRef} options={fieldDefinition.metadata.options}
onOptionSelected={handleSubmit} onCancel={onCancel}
options={fieldDefinition.metadata.options} defaultOption={selectedOption}
onCancel={onCancel} onFilterChange={setFilteredOptions}
defaultOption={selectedOption} onClear={
onFilterChange={setFilteredOptions} fieldDefinition.metadata.isNullable ? handleClearField : undefined
onClear={ }
fieldDefinition.metadata.isNullable ? handleClearField : undefined clearLabel={fieldDefinition.label}
} />
clearLabel={fieldDefinition.label}
hotkeyScope={hotkeyScope}
/>
</SelectableList>
</div> </div>
); );
}; };

View File

@ -0,0 +1,10 @@
import { Tag, ThemeColor } from 'twenty-ui';
type SelectDisplayProps = {
color: ThemeColor;
label: string;
};
export const SelectDisplay = ({ color, label }: SelectDisplayProps) => {
return <Tag preventShrink color={color} text={label} />;
};

View File

@ -0,0 +1,55 @@
import { SelectOption } from '@/spreadsheet-import/types';
import { SelectInput as SelectBaseInput } from '@/ui/input/components/SelectInput';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { ReferenceType } from '@floating-ui/react';
type SelectInputProps = {
selectableListId: string;
selectableItemIdArray: string[];
hotkeyScope: string;
onEnter: (itemId: string) => void;
selectWrapperRef?: ReferenceType | null | undefined;
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
onCancel?: () => void;
defaultOption?: SelectOption | undefined;
onFilterChange?: ((filteredOptions: SelectOption[]) => void) | undefined;
onClear?: (() => void) | undefined;
clearLabel?: string;
};
export const SelectInput = ({
selectableListId,
selectableItemIdArray,
hotkeyScope,
onEnter,
selectWrapperRef,
onOptionSelected,
options,
onCancel,
defaultOption,
onFilterChange,
onClear,
clearLabel,
}: SelectInputProps) => {
return (
<SelectableList
selectableListId={selectableListId}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={hotkeyScope}
onEnter={onEnter}
>
<SelectBaseInput
parentRef={selectWrapperRef}
onOptionSelected={onOptionSelected}
options={options}
onCancel={onCancel}
defaultOption={defaultOption}
onFilterChange={onFilterChange}
onClear={onClear}
clearLabel={clearLabel}
hotkeyScope={hotkeyScope}
/>
</SelectableList>
);
};

View File

@ -18,10 +18,8 @@ export const EditableFilterChip = ({
<SortOrFilterChip <SortOrFilterChip
key={viewFilter.id} key={viewFilter.id}
testId={viewFilter.id} testId={viewFilter.id}
labelKey={viewFilter.definition.label} labelKey={`${viewFilter.definition.label}${getOperandLabelShort(viewFilter.operand)}`}
labelValue={`${getOperandLabelShort(viewFilter.operand)} ${ labelValue={viewFilter.displayValue}
viewFilter.displayValue
}`}
Icon={getIcon(viewFilter.definition.iconName)} Icon={getIcon(viewFilter.definition.iconName)}
onRemove={onRemove} onRemove={onRemove}
/> />

View File

@ -33,35 +33,44 @@ const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>`
return theme.color.blue; return theme.color.blue;
} }
}}; }};
height: 26px;
box-sizing: border-box;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-shrink: 0; flex-shrink: 0;
font-size: ${({ theme }) => theme.font.size.sm}; font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => theme.spacing(0.5) + ' ' + theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(0.5)};
margin-left: ${({ theme }) => theme.spacing(2)}; padding-left: ${({ theme }) => theme.spacing(1)};
column-gap: ${({ theme }) => theme.spacing(1)};
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
max-height: ${({ theme }) => theme.spacing(4.5)}; margin-left: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledIcon = styled.div` const StyledIcon = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
margin-right: ${({ theme }) => theme.spacing(1)};
`; `;
const StyledDelete = styled.div<{ variant: SortOrFitlerChipVariant }>` const StyledDelete = styled.button<{ variant: SortOrFitlerChipVariant }>`
box-sizing: border-box;
height: 20px;
width: 20px;
display: flex;
justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
padding: ${({ theme }) => theme.spacing(0.5)};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm}; font-size: ${({ theme }) => theme.font.size.sm};
margin-left: ${({ theme }) => theme.spacing(2)};
margin-top: 1px;
user-select: none; user-select: none;
padding: 0;
margin: 0;
background: none;
border: none;
color: inherit;
&:hover { &:hover {
background-color: ${({ theme, variant }) => { background-color: ${({ theme, variant }) => {
switch (variant) { switch (variant) {

View File

@ -1,4 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput'; import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select'; import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
@ -55,6 +57,33 @@ export const WorkflowEditActionFormRecordCreate = ({
}); });
const isFormDisabled = actionOptions.readonly; const isFormDisabled = actionOptions.readonly;
const objectNameSingular = formData.objectName;
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const inlineFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.Relation &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = inlineFieldMetadataItems.map(
(fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
);
const handleFieldChange = ( const handleFieldChange = (
fieldName: keyof CreateRecordFormData, fieldName: keyof CreateRecordFormData,
updatedValue: JsonValue, updatedValue: JsonValue,
@ -76,23 +105,6 @@ export const WorkflowEditActionFormRecordCreate = ({
}); });
}, [action.settings.input]); }, [action.settings.input]);
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const editableFields = selectedObjectMetadataItem.fields.filter(
(field) =>
field.type !== FieldMetadataType.Relation &&
!field.isSystem &&
field.isActive,
);
const saveAction = useDebouncedCallback( const saveAction = useDebouncedCallback(
async (formData: CreateRecordFormData) => { async (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
@ -162,16 +174,16 @@ export const WorkflowEditActionFormRecordCreate = ({
<HorizontalSeparator noMargin /> <HorizontalSeparator noMargin />
{editableFields.map((field) => { {inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.name] as JsonValue; const currentValue = formData[field.metadata.fieldName] as JsonValue;
return ( return (
<FormFieldInput <FormFieldInput
key={field.id} key={field.metadata.fieldName}
defaultValue={currentValue} defaultValue={currentValue}
field={field} field={field}
onPersist={(value) => { onPersist={(value) => {
handleFieldChange(field.name, value); handleFieldChange(field.metadata.fieldName, value);
}} }}
VariablePicker={WorkflowVariablePicker} VariablePicker={WorkflowVariablePicker}
/> />