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:
committed by
GitHub
parent
2c0d3e93d2
commit
9142bdfb92
@ -1,15 +1,18 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
|
||||
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 { 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 { 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 { JsonValue } from 'type-fest';
|
||||
|
||||
type FormFieldInputProps = {
|
||||
field: FieldMetadataItem;
|
||||
field: FieldDefinition<FieldMetadata>;
|
||||
defaultValue: JsonValue;
|
||||
onPersist: (value: JsonValue) => void;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
@ -23,7 +26,6 @@ export const FormFieldInput = ({
|
||||
}: FormFieldInputProps) => {
|
||||
return isFieldNumber(field) ? (
|
||||
<FormNumberFieldInput
|
||||
key={field.id}
|
||||
label={field.label}
|
||||
defaultValue={defaultValue as string | number | undefined}
|
||||
onPersist={onPersist}
|
||||
@ -32,7 +34,6 @@ export const FormFieldInput = ({
|
||||
/>
|
||||
) : isFieldBoolean(field) ? (
|
||||
<FormBooleanFieldInput
|
||||
key={field.id}
|
||||
label={field.label}
|
||||
defaultValue={defaultValue as string | boolean | undefined}
|
||||
onPersist={onPersist}
|
||||
@ -46,5 +47,13 @@ export const FormFieldInput = ({
|
||||
placeholder={field.label}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
) : isFieldSelect(field) ? (
|
||||
<FormSelectFieldInput
|
||||
label={field.label}
|
||||
defaultValue={defaultValue as string | undefined}
|
||||
onPersist={onPersist}
|
||||
field={field}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,5 @@
|
||||
import { Tag } from 'twenty-ui';
|
||||
|
||||
import { useSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useSelectFieldDisplay';
|
||||
import { SelectDisplay } from '@/ui/field/display/components/SelectDisplay';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const SelectFieldDisplay = () => {
|
||||
@ -15,10 +14,6 @@ export const SelectFieldDisplay = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
preventShrink
|
||||
color={selectedOption.color}
|
||||
text={selectedOption.label}
|
||||
/>
|
||||
<SelectDisplay color={selectedOption.color} label={selectedOption.label} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 { SINGLE_RECORD_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleRecordSelectBaseList';
|
||||
import { SelectOption } from '@/spreadsheet-import/types';
|
||||
import { SelectInput } from '@/ui/input/components/SelectInput';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectInput } from '@/ui/field/input/components/SelectInput';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useState } from 'react';
|
||||
@ -64,7 +63,7 @@ export const SelectFieldInput = ({
|
||||
|
||||
return (
|
||||
<div ref={setSelectWrapperRef}>
|
||||
<SelectableList
|
||||
<SelectInput
|
||||
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
|
||||
selectableItemIdArray={optionIds}
|
||||
hotkeyScope={hotkeyScope}
|
||||
@ -77,21 +76,17 @@ export const SelectFieldInput = ({
|
||||
resetSelectedItem();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectInput
|
||||
parentRef={selectWrapperRef}
|
||||
onOptionSelected={handleSubmit}
|
||||
options={fieldDefinition.metadata.options}
|
||||
onCancel={onCancel}
|
||||
defaultOption={selectedOption}
|
||||
onFilterChange={setFilteredOptions}
|
||||
onClear={
|
||||
fieldDefinition.metadata.isNullable ? handleClearField : undefined
|
||||
}
|
||||
clearLabel={fieldDefinition.label}
|
||||
hotkeyScope={hotkeyScope}
|
||||
/>
|
||||
</SelectableList>
|
||||
selectWrapperRef={selectWrapperRef}
|
||||
onOptionSelected={handleSubmit}
|
||||
options={fieldDefinition.metadata.options}
|
||||
onCancel={onCancel}
|
||||
defaultOption={selectedOption}
|
||||
onFilterChange={setFilteredOptions}
|
||||
onClear={
|
||||
fieldDefinition.metadata.isNullable ? handleClearField : undefined
|
||||
}
|
||||
clearLabel={fieldDefinition.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user