From 4d9facb9bd7b3473cea296edd7b01618e2fba8de Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 11 Dec 2024 17:57:42 +0100 Subject: [PATCH] Add address composite form field (#9022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create FormCountrySelectInput using the existing FormSelectFieldInput - Create AddressFormFieldInput component - Fix FormSelectFieldInput dropdown + add leftIcon Capture d’écran 2024-12-11 à 15 56 32 --- .../components/FormFieldInput.tsx | 13 ++- .../components/FormAddressFieldInput.tsx | 94 +++++++++++++++++++ .../components/FormCountrySelectInput.tsx | 63 +++++++++++++ .../components/FormSelectFieldInput.tsx | 63 +++++++------ .../FormAddressFieldInput.stories.tsx | 37 ++++++++ .../FormCountrySelectInput.stories.tsx | 26 +++++ .../input/components/SelectFieldInput.tsx | 46 +++++---- .../modules/spreadsheet-import/types/index.ts | 2 +- .../display/components/SelectDisplay.tsx | 9 +- .../field/input/components/AddressInput.tsx | 1 + .../ui/input/components/SelectInput.tsx | 5 +- .../country/components/CountrySelect.tsx | 4 +- .../src/display/tag/components/Tag.tsx | 2 +- .../components/MenuItemSelectTag.tsx | 12 ++- 14 files changed, 312 insertions(+), 65 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormAddressFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormCountrySelectInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormAddressFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormCountrySelectInput.stories.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index dd0d999da..a547d2d67 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -1,3 +1,4 @@ +import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput'; import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput'; import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput'; import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; @@ -6,9 +7,11 @@ import { FormTextFieldInput } from '@/object-record/record-field/form-types/comp import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { + FieldAddressValue, FieldFullNameValue, FieldMetadata, } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; @@ -57,8 +60,9 @@ export const FormFieldInput = ({ label={field.label} defaultValue={defaultValue as string | undefined} onPersist={onPersist} - field={field} VariablePicker={VariablePicker} + options={field.metadata.options} + clearLabel={field.label} /> ) : isFieldFullName(field) ? ( + ) : isFieldAddress(field) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormAddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormAddressFieldInput.tsx new file mode 100644 index 000000000..d6b83df15 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormAddressFieldInput.tsx @@ -0,0 +1,94 @@ +import { FormCountrySelectInput } from '@/object-record/record-field/form-types/components/FormCountrySelectInput'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer'; +import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { InputLabel } from '@/ui/input/components/InputLabel'; + +type FormAddressFieldInputProps = { + label?: string; + defaultValue: FieldAddressDraftValue | null; + onPersist: (value: FieldAddressValue) => void; + VariablePicker?: VariablePickerComponent; + readonly?: boolean; +}; + +export const FormAddressFieldInput = ({ + label, + defaultValue, + onPersist, + readonly, + VariablePicker, +}: FormAddressFieldInputProps) => { + const handleChange = + (field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => { + const updatedAddress = { + addressStreet1: defaultValue?.addressStreet1 ?? '', + addressStreet2: defaultValue?.addressStreet2 ?? '', + addressCity: defaultValue?.addressCity ?? '', + addressState: defaultValue?.addressState ?? '', + addressPostcode: defaultValue?.addressPostcode ?? '', + addressCountry: defaultValue?.addressCountry ?? '', + addressLat: defaultValue?.addressLat ?? null, + addressLng: defaultValue?.addressLng ?? null, + [field]: updatedAddressPart, + }; + onPersist(updatedAddress); + }; + + return ( + + {label ? {label} : null} + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormCountrySelectInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormCountrySelectInput.tsx new file mode 100644 index 000000000..6cd9d7ef6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormCountrySelectInput.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { IconCircleOff, IconComponentProps } from 'twenty-ui'; + +import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput'; +import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; +import { SelectOption } from '@/spreadsheet-import/types'; +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; + +export const FormCountrySelectInput = ({ + selectedCountryName, + onPersist, + readonly = false, + VariablePicker, +}: { + selectedCountryName: string; + onPersist: (countryCode: string) => void; + readonly?: boolean; + VariablePicker?: VariablePickerComponent; +}) => { + const countries = useCountries(); + + const options: SelectOption[] = useMemo(() => { + const countryList = countries.map( + ({ countryName, Flag }) => ({ + label: countryName, + value: countryName, + color: 'transparent', + icon: (props: IconComponentProps) => + Flag({ width: props.size, height: props.size }), + }), + ); + return [ + { + label: 'No country', + value: '', + icon: IconCircleOff, + }, + ...countryList, + ]; + }, [countries]); + + const onChange = (countryCode: string | null) => { + if (readonly) { + return; + } + + if (countryCode === null) { + onPersist(''); + } else { + onPersist(countryCode); + } + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSelectFieldInput.tsx index 9a22d787f..fe332d0c1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSelectFieldInput.tsx @@ -3,8 +3,6 @@ import { StyledFormFieldInputInputContainer } from '@/object-record/record-field 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'; @@ -21,11 +19,12 @@ import { Key } from 'ts-key-enum'; import { isDefined, VisibilityHidden } from 'twenty-ui'; type FormSelectFieldInputProps = { - field: FieldDefinition; label?: string; defaultValue: string | undefined; - onPersist: (value: number | null | string) => void; + onPersist: (value: string | null) => void; VariablePicker?: VariablePickerComponent; + options: SelectOption[]; + clearLabel?: string; }; const StyledDisplayModeContainer = styled.button` @@ -44,12 +43,19 @@ const StyledDisplayModeContainer = styled.button` } `; +const StyledSelectInputContainer = styled.div` + position: absolute; + z-index: 1; + top: ${({ theme }) => theme.spacing(8)}; +`; + export const FormSelectFieldInput = ({ label, - field, defaultValue, onPersist, VariablePicker, + options, + clearLabel, }: FormSelectFieldInputProps) => { const inputId = useId(); @@ -124,7 +130,7 @@ export const FormSelectFieldInput = ({ onPersist(null); }; - const selectedOption = field.metadata.options.find( + const selectedOption = options.find( (option) => option.value === draftValue.value, ); @@ -193,7 +199,7 @@ export const FormSelectFieldInput = ({ ); const optionIds = [ - `No ${field.label}`, + `No ${label}`, ...filteredOptions.map((option) => option.value), ]; @@ -215,29 +221,12 @@ export const FormSelectFieldInput = ({ {isDefined(selectedOption) ? ( ) : null} - - {draftValue.editingMode === 'edit' ? ( - - ) : null} ) : ( )} + + {draftValue.type === 'static' && + draftValue.editingMode === 'edit' && ( + + )} + - {VariablePicker ? ( + {VariablePicker && ( - ) : null} + )} ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormAddressFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormAddressFieldInput.stories.tsx new file mode 100644 index 000000000..e11292d90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormAddressFieldInput.stories.tsx @@ -0,0 +1,37 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormAddressFieldInput } from '../FormAddressFieldInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormAddressFieldInput', + component: FormAddressFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Address', + defaultValue: { + addressStreet1: '123 Main St', + addressStreet2: 'Apt 123', + addressCity: 'Springfield', + addressState: 'IL', + addressCountry: 'US', + addressPostcode: '12345', + addressLat: 39.781721, + addressLng: -89.650148, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('123 Main St'); + await canvas.findByText('Address'); + await canvas.findByText('Post Code'); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormCountrySelectInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormCountrySelectInput.stories.tsx new file mode 100644 index 000000000..8896df725 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormCountrySelectInput.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; +import { FormCountrySelectInput } from '../FormCountrySelectInput'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormCountrySelectInput', + component: FormCountrySelectInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + selectedCountryName: 'Canada', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Country'); + await canvas.findByText('Canada'); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx index bd30e957d..cc192ddbc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx @@ -60,30 +60,28 @@ export const SelectFieldInput = ({ ]; return ( -
- { - const option = filteredOptions.find( - (option) => option.value === itemId, - ); - if (isDefined(option)) { - onSubmit?.(() => persistField(option.value)); - resetSelectedItem(); - } - }} - onOptionSelected={handleSubmit} - options={fieldDefinition.metadata.options} - onCancel={onCancel} - defaultOption={selectedOption} - onFilterChange={setFilteredOptions} - onClear={ - fieldDefinition.metadata.isNullable ? handleClearField : undefined + { + const option = filteredOptions.find( + (option) => option.value === itemId, + ); + if (isDefined(option)) { + onSubmit?.(() => persistField(option.value)); + resetSelectedItem(); } - clearLabel={fieldDefinition.label} - /> -
+ }} + onOptionSelected={handleSubmit} + options={fieldDefinition.metadata.options} + onCancel={onCancel} + defaultOption={selectedOption} + onFilterChange={setFilteredOptions} + onClear={ + fieldDefinition.metadata.isNullable ? handleClearField : undefined + } + clearLabel={fieldDefinition.label} + /> ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts index 9c7cc5ff2..626ed9680 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts @@ -94,7 +94,7 @@ export type SelectOption = { // Disabled option when already select disabled?: boolean; // Option color - color?: ThemeColor; + color?: ThemeColor | 'transparent'; }; export type Input = { diff --git a/packages/twenty-front/src/modules/ui/field/display/components/SelectDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/SelectDisplay.tsx index c9ed54603..13ac37aa3 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/SelectDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/SelectDisplay.tsx @@ -1,10 +1,11 @@ -import { Tag, ThemeColor } from 'twenty-ui'; +import { IconComponent, Tag, ThemeColor } from 'twenty-ui'; type SelectDisplayProps = { - color: ThemeColor; + color: ThemeColor | 'transparent'; label: string; + Icon?: IconComponent; }; -export const SelectDisplay = ({ color, label }: SelectDisplayProps) => { - return ; +export const SelectDisplay = ({ color, label, Icon }: SelectDisplayProps) => { + return ; }; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx index ac30ef19c..c8bc395f6 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx @@ -260,6 +260,7 @@ export const AddressInput = ({ onFocus={getFocusHandler('addressPostcode')} /> diff --git a/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx index 819d33028..249692646 100644 --- a/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx @@ -109,7 +109,7 @@ export const SelectInput = ({ selected={false} text={`No ${clearLabel}`} color="transparent" - variant="outline" + variant={'outline'} onClick={() => { setSelectedOption(undefined); onClear(); @@ -122,8 +122,9 @@ export const SelectInput = ({ key={option.value} selected={selectedOption?.value === option.value} text={option.label} - color={option.color as TagColor} + color={(option.color as TagColor) ?? 'transparent'} onClick={() => handleOptionChange(option)} + LeftIcon={option.icon} /> ); })} diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx index ce9861e92..9d18f2129 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx @@ -6,9 +6,11 @@ import { useCountries } from '@/ui/input/components/internal/hooks/useCountries' import { Select, SelectOption } from '@/ui/input/components/Select'; export const CountrySelect = ({ + label, selectedCountryName, onChange, }: { + label: string; selectedCountryName: string; onChange: (countryCode: string) => void; }) => { @@ -36,7 +38,7 @@ export const CountrySelect = ({ fullWidth dropdownId={SELECT_COUNTRY_DROPDOWN_ID} options={options} - label="COUNTRY" + label={label} withSearchInput onChange={onChange} value={selectedCountryName} diff --git a/packages/twenty-ui/src/display/tag/components/Tag.tsx b/packages/twenty-ui/src/display/tag/components/Tag.tsx index a8ab14dd3..a90ad9875 100644 --- a/packages/twenty-ui/src/display/tag/components/Tag.tsx +++ b/packages/twenty-ui/src/display/tag/components/Tag.tsx @@ -27,7 +27,7 @@ const StyledTag = styled.h3<{ border-radius: ${BORDER_COMMON.radius.sm}; color: ${({ color, theme }) => color === 'transparent' - ? theme.font.color.tertiary + ? theme.font.color.secondary : theme.tag.text[color]}; display: inline-flex; font-size: ${({ theme }) => theme.font.size.md}; diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectTag.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectTag.tsx index aec752d80..1217f1199 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectTag.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectTag.tsx @@ -2,7 +2,7 @@ import { useTheme } from '@emotion/react'; import { StyledMenuItemLeftContent } from '../internals/components/StyledMenuItemBase'; -import { IconCheck, Tag } from '@ui/display'; +import { IconCheck, IconComponent, Tag } from '@ui/display'; import { ThemeColor } from '@ui/theme'; import { StyledMenuItemSelect } from './MenuItemSelect'; @@ -14,6 +14,7 @@ type MenuItemSelectTagProps = { color: ThemeColor | 'transparent'; text: string; variant?: 'solid' | 'outline'; + LeftIcon?: IconComponent | null; }; export const MenuItemSelectTag = ({ @@ -24,9 +25,9 @@ export const MenuItemSelectTag = ({ onClick, text, variant = 'solid', + LeftIcon, }: MenuItemSelectTagProps) => { const theme = useTheme(); - return ( - + {selected && }