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 { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput'; import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; 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 { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { import {
FieldAddressValue,
FieldFullNameValue, FieldFullNameValue,
FieldMetadata, FieldMetadata,
} from '@/object-record/record-field/types/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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
@ -57,8 +60,9 @@ export const FormFieldInput = ({
label={field.label} label={field.label}
defaultValue={defaultValue as string | undefined} defaultValue={defaultValue as string | undefined}
onPersist={onPersist} onPersist={onPersist}
field={field}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
options={field.metadata.options}
clearLabel={field.label}
/> />
) : isFieldFullName(field) ? ( ) : isFieldFullName(field) ? (
<FormFullNameFieldInput <FormFullNameFieldInput
@ -67,5 +71,12 @@ export const FormFieldInput = ({
onPersist={onPersist} onPersist={onPersist}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
/> />
) : isFieldAddress(field) ? (
<FormAddressFieldInput
label={field.label}
defaultValue={defaultValue as FieldAddressValue}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : null; ) : 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 { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip'; import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
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 { FieldSelectMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
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';
@ -21,11 +19,12 @@ import { Key } from 'ts-key-enum';
import { isDefined, VisibilityHidden } from 'twenty-ui'; import { isDefined, VisibilityHidden } from 'twenty-ui';
type FormSelectFieldInputProps = { type FormSelectFieldInputProps = {
field: FieldDefinition<FieldSelectMetadata>;
label?: string; label?: string;
defaultValue: string | undefined; defaultValue: string | undefined;
onPersist: (value: number | null | string) => void; onPersist: (value: string | null) => void;
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
options: SelectOption[];
clearLabel?: string;
}; };
const StyledDisplayModeContainer = styled.button` 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 = ({ export const FormSelectFieldInput = ({
label, label,
field,
defaultValue, defaultValue,
onPersist, onPersist,
VariablePicker, VariablePicker,
options,
clearLabel,
}: FormSelectFieldInputProps) => { }: FormSelectFieldInputProps) => {
const inputId = useId(); const inputId = useId();
@ -124,7 +130,7 @@ export const FormSelectFieldInput = ({
onPersist(null); onPersist(null);
}; };
const selectedOption = field.metadata.options.find( const selectedOption = options.find(
(option) => option.value === draftValue.value, (option) => option.value === draftValue.value,
); );
@ -193,7 +199,7 @@ export const FormSelectFieldInput = ({
); );
const optionIds = [ const optionIds = [
`No ${field.label}`, `No ${label}`,
...filteredOptions.map((option) => option.value), ...filteredOptions.map((option) => option.value),
]; ];
@ -215,29 +221,12 @@ export const FormSelectFieldInput = ({
{isDefined(selectedOption) ? ( {isDefined(selectedOption) ? (
<SelectDisplay <SelectDisplay
color={selectedOption.color} color={selectedOption.color ?? 'transparent'}
label={selectedOption.label} label={selectedOption.label}
Icon={selectedOption.icon ?? undefined}
/> />
) : null} ) : null}
</StyledDisplayModeContainer> </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 <VariableChip
@ -246,13 +235,31 @@ export const FormSelectFieldInput = ({
/> />
)} )}
</StyledFormFieldInputInputContainer> </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 <VariablePicker
inputId={inputId} inputId={inputId}
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}
/> />
) : null} )}
</StyledFormFieldInputRowContainer> </StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer> </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 ( return (
<div> <SelectInput
<SelectInput selectableListId={SINGLE_RECORD_SELECT_BASE_LIST}
selectableListId={SINGLE_RECORD_SELECT_BASE_LIST} selectableItemIdArray={optionIds}
selectableItemIdArray={optionIds} hotkeyScope={hotkeyScope}
hotkeyScope={hotkeyScope} onEnter={(itemId) => {
onEnter={(itemId) => { const option = filteredOptions.find(
const option = filteredOptions.find( (option) => option.value === itemId,
(option) => option.value === itemId, );
); if (isDefined(option)) {
if (isDefined(option)) { onSubmit?.(() => persistField(option.value));
onSubmit?.(() => persistField(option.value)); resetSelectedItem();
resetSelectedItem();
}
}}
onOptionSelected={handleSubmit}
options={fieldDefinition.metadata.options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={
fieldDefinition.metadata.isNullable ? handleClearField : undefined
} }
clearLabel={fieldDefinition.label} }}
/> onOptionSelected={handleSubmit}
</div> 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 option when already select
disabled?: boolean; disabled?: boolean;
// Option color // Option color
color?: ThemeColor; color?: ThemeColor | 'transparent';
}; };
export type Input = { export type Input = {

View File

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

View File

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

View File

@ -109,7 +109,7 @@ export const SelectInput = ({
selected={false} selected={false}
text={`No ${clearLabel}`} text={`No ${clearLabel}`}
color="transparent" color="transparent"
variant="outline" variant={'outline'}
onClick={() => { onClick={() => {
setSelectedOption(undefined); setSelectedOption(undefined);
onClear(); onClear();
@ -122,8 +122,9 @@ export const SelectInput = ({
key={option.value} key={option.value}
selected={selectedOption?.value === option.value} selected={selectedOption?.value === option.value}
text={option.label} text={option.label}
color={option.color as TagColor} color={(option.color as TagColor) ?? 'transparent'}
onClick={() => handleOptionChange(option)} 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'; import { Select, SelectOption } from '@/ui/input/components/Select';
export const CountrySelect = ({ export const CountrySelect = ({
label,
selectedCountryName, selectedCountryName,
onChange, onChange,
}: { }: {
label: string;
selectedCountryName: string; selectedCountryName: string;
onChange: (countryCode: string) => void; onChange: (countryCode: string) => void;
}) => { }) => {
@ -36,7 +38,7 @@ export const CountrySelect = ({
fullWidth fullWidth
dropdownId={SELECT_COUNTRY_DROPDOWN_ID} dropdownId={SELECT_COUNTRY_DROPDOWN_ID}
options={options} options={options}
label="COUNTRY" label={label}
withSearchInput withSearchInput
onChange={onChange} onChange={onChange}
value={selectedCountryName} value={selectedCountryName}

View File

@ -27,7 +27,7 @@ const StyledTag = styled.h3<{
border-radius: ${BORDER_COMMON.radius.sm}; border-radius: ${BORDER_COMMON.radius.sm};
color: ${({ color, theme }) => color: ${({ color, theme }) =>
color === 'transparent' color === 'transparent'
? theme.font.color.tertiary ? theme.font.color.secondary
: theme.tag.text[color]}; : theme.tag.text[color]};
display: inline-flex; display: inline-flex;
font-size: ${({ theme }) => theme.font.size.md}; 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 { StyledMenuItemLeftContent } from '../internals/components/StyledMenuItemBase';
import { IconCheck, Tag } from '@ui/display'; import { IconCheck, IconComponent, Tag } from '@ui/display';
import { ThemeColor } from '@ui/theme'; import { ThemeColor } from '@ui/theme';
import { StyledMenuItemSelect } from './MenuItemSelect'; import { StyledMenuItemSelect } from './MenuItemSelect';
@ -14,6 +14,7 @@ type MenuItemSelectTagProps = {
color: ThemeColor | 'transparent'; color: ThemeColor | 'transparent';
text: string; text: string;
variant?: 'solid' | 'outline'; variant?: 'solid' | 'outline';
LeftIcon?: IconComponent | null;
}; };
export const MenuItemSelectTag = ({ export const MenuItemSelectTag = ({
@ -24,9 +25,9 @@ export const MenuItemSelectTag = ({
onClick, onClick,
text, text,
variant = 'solid', variant = 'solid',
LeftIcon,
}: MenuItemSelectTagProps) => { }: MenuItemSelectTagProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<StyledMenuItemSelect <StyledMenuItemSelect
onClick={onClick} onClick={onClick}
@ -35,7 +36,12 @@ export const MenuItemSelectTag = ({
isKeySelected={isKeySelected} isKeySelected={isKeySelected}
> >
<StyledMenuItemLeftContent> <StyledMenuItemLeftContent>
<Tag variant={variant} color={color} text={text} /> <Tag
variant={variant}
color={color}
text={text}
Icon={LeftIcon ?? undefined}
/>
</StyledMenuItemLeftContent> </StyledMenuItemLeftContent>
{selected && <IconCheck size={theme.icon.size.sm} />} {selected && <IconCheck size={theme.icon.size.sm} />}
</StyledMenuItemSelect> </StyledMenuItemSelect>