Add address composite form field (#9022)

- Create FormCountrySelectInput using the existing FormSelectFieldInput
- Create AddressFormFieldInput component
- Fix FormSelectFieldInput dropdown + add leftIcon

<img width="554" alt="Capture d’écran 2024-12-11 à 15 56 32"
src="https://github.com/user-attachments/assets/c3019f29-af76-44e1-96bd-a0c6283674e1"
/>
This commit is contained in:
Thomas Trompette
2024-12-11 17:57:42 +01:00
committed by GitHub
parent 2c4a77a7b7
commit 4d9facb9bd
14 changed files with 312 additions and 65 deletions

View File

@ -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) ? (
<FormFullNameFieldInput
@ -67,5 +71,12 @@ export const FormFieldInput = ({
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldAddress(field) ? (
<FormAddressFieldInput
label={field.label}
defaultValue={defaultValue as FieldAddressValue}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : null;
};

View File

@ -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 (
<StyledFormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormCompositeFieldInputContainer>
<FormTextFieldInput
label="Address 1"
defaultValue={defaultValue?.addressStreet1 ?? ''}
onPersist={handleChange('addressStreet1')}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="Street address"
/>
<FormTextFieldInput
label="Address 2"
defaultValue={defaultValue?.addressStreet2 ?? ''}
onPersist={handleChange('addressStreet2')}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="Street address 2"
/>
<FormTextFieldInput
label="City"
defaultValue={defaultValue?.addressCity ?? ''}
onPersist={handleChange('addressCity')}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="City"
/>
<FormTextFieldInput
label="State"
defaultValue={defaultValue?.addressState ?? ''}
onPersist={handleChange('addressState')}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="State"
/>
<FormTextFieldInput
label="Post Code"
defaultValue={defaultValue?.addressPostcode ?? ''}
onPersist={handleChange('addressPostcode')}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="Post Code"
/>
<FormCountrySelectInput
selectedCountryName={defaultValue?.addressCountry ?? ''}
onPersist={handleChange('addressCountry')}
readonly={readonly}
VariablePicker={VariablePicker}
/>
</StyledFormCompositeFieldInputContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -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<SelectOption>(
({ 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 (
<FormSelectFieldInput
label="Country"
onPersist={onChange}
options={options}
defaultValue={selectedCountryName}
VariablePicker={VariablePicker}
/>
);
};

View File

@ -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<FieldSelectMetadata>;
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) ? (
<SelectDisplay
color={selectedOption.color}
color={selectedOption.color ?? 'transparent'}
label={selectedOption.label}
Icon={selectedOption.icon ?? undefined}
/>
) : null}
</StyledDisplayModeContainer>
{draftValue.editingMode === 'edit' ? (
<SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={handleSelectEnter}
onOptionSelected={handleSubmit}
options={field.metadata.options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={
field.metadata.isNullable ? handleClearField : undefined
}
clearLabel={field.label}
/>
) : null}
</>
) : (
<VariableChip
@ -246,13 +235,31 @@ export const FormSelectFieldInput = ({
/>
)}
</StyledFormFieldInputInputContainer>
<StyledSelectInputContainer>
{draftValue.type === 'static' &&
draftValue.editingMode === 'edit' && (
<SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={handleSelectEnter}
onOptionSelected={handleSubmit}
options={options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={handleClearField}
clearLabel={clearLabel}
/>
)}
</StyledSelectInputContainer>
{VariablePicker ? (
{VariablePicker && (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
)}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);

View File

@ -0,0 +1,37 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormAddressFieldInput } from '../FormAddressFieldInput';
const meta: Meta<typeof FormAddressFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormAddressFieldInput',
component: FormAddressFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormAddressFieldInput>;
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');
},
};

View File

@ -0,0 +1,26 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FormCountrySelectInput } from '../FormCountrySelectInput';
const meta: Meta<typeof FormCountrySelectInput> = {
title: 'UI/Data/Field/Form/Input/FormCountrySelectInput',
component: FormCountrySelectInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormCountrySelectInput>;
export const Default: Story = {
args: {
selectedCountryName: 'Canada',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Country');
await canvas.findByText('Canada');
},
};

View File

@ -60,30 +60,28 @@ export const SelectFieldInput = ({
];
return (
<div>
<SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
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
<SelectInput
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = filteredOptions.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
onSubmit?.(() => persistField(option.value));
resetSelectedItem();
}
clearLabel={fieldDefinition.label}
/>
</div>
}}
onOptionSelected={handleSubmit}
options={fieldDefinition.metadata.options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={
fieldDefinition.metadata.isNullable ? handleClearField : undefined
}
clearLabel={fieldDefinition.label}
/>
);
};

View File

@ -94,7 +94,7 @@ export type SelectOption = {
// Disabled option when already select
disabled?: boolean;
// Option color
color?: ThemeColor;
color?: ThemeColor | 'transparent';
};
export type Input = {

View File

@ -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 <Tag preventShrink color={color} text={label} />;
export const SelectDisplay = ({ color, label, Icon }: SelectDisplayProps) => {
return <Tag preventShrink color={color} text={label} Icon={Icon} />;
};

View File

@ -260,6 +260,7 @@ export const AddressInput = ({
onFocus={getFocusHandler('addressPostcode')}
/>
<CountrySelect
label="COUNTRY"
onChange={getChangeHandler('addressCountry')}
selectedCountryName={internalValue.addressCountry ?? ''}
/>

View File

@ -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}
/>
);
})}

View File

@ -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}

View File

@ -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};

View File

@ -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 (
<StyledMenuItemSelect
onClick={onClick}
@ -35,7 +36,12 @@ export const MenuItemSelectTag = ({
isKeySelected={isKeySelected}
>
<StyledMenuItemLeftContent>
<Tag variant={variant} color={color} text={text} />
<Tag
variant={variant}
color={color}
text={text}
Icon={LeftIcon ?? undefined}
/>
</StyledMenuItemLeftContent>
{selected && <IconCheck size={theme.icon.size.sm} />}
</StyledMenuItemSelect>