Workflow phone number field (#9324)

Add phone number field

<img width="517" alt="Capture d’écran 2025-01-02 à 18 10 06"
src="https://github.com/user-attachments/assets/3c3ac7e6-a7fa-487b-820f-674d42217561"
/>
This commit is contained in:
Thomas Trompette
2025-01-02 18:52:27 +01:00
committed by GitHub
parent 5d857fbfb5
commit 759dcfa910
11 changed files with 239 additions and 8 deletions

View File

@ -1,11 +1,13 @@
import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput'; 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 { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput'; import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput'; import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput'; import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput'; import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput'; import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormPhoneFieldInput } from '@/object-record/record-field/form-types/components/FormPhoneFieldInput';
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput'; import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput'; 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';
@ -19,6 +21,7 @@ import {
FieldLinksValue, FieldLinksValue,
FieldMetadata, FieldMetadata,
FieldMultiSelectValue, FieldMultiSelectValue,
FieldPhonesValue,
} 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 { 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';
@ -29,12 +32,12 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; 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 { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
type FormFieldInputProps = { type FormFieldInputProps = {
field: FieldDefinition<FieldMetadata>; field: FieldDefinition<FieldMetadata>;
@ -109,6 +112,13 @@ export const FormFieldInput = ({
onPersist={onPersist} onPersist={onPersist}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
/> />
) : isFieldPhones(field) ? (
<FormPhoneFieldInput
label={field.label}
defaultValue={defaultValue as FieldPhonesValue | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldDate(field) ? ( ) : isFieldDate(field) ? (
<FormDateFieldInput <FormDateFieldInput
label={field.label} label={field.label}

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 FormCountryCodeSelectInput = ({
selectedCountryCode,
onPersist,
readonly = false,
VariablePicker,
}: {
selectedCountryCode: string;
onPersist: (countryCode: string) => void;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
}) => {
const countries = useCountries();
const options: SelectOption[] = useMemo(() => {
const countryList = countries.map<SelectOption>(
({ countryName, countryCode, callingCode, Flag }) => ({
label: `${countryName} (+${callingCode})`,
value: countryCode,
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 Code"
onPersist={onChange}
options={options}
defaultValue={selectedCountryCode}
VariablePicker={VariablePicker}
/>
);
};

View File

@ -13,7 +13,7 @@ export const FormCountrySelectInput = ({
VariablePicker, VariablePicker,
}: { }: {
selectedCountryName: string; selectedCountryName: string;
onPersist: (countryCode: string) => void; onPersist: (country: string) => void;
readonly?: boolean; readonly?: boolean;
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
}) => { }) => {
@ -39,15 +39,15 @@ export const FormCountrySelectInput = ({
]; ];
}, [countries]); }, [countries]);
const onChange = (countryCode: string | null) => { const onChange = (country: string | null) => {
if (readonly) { if (readonly) {
return; return;
} }
if (countryCode === null) { if (country === null) {
onPersist(''); onPersist('');
} else { } else {
onPersist(countryCode); onPersist(country);
} }
}; };

View File

@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledFormFieldHint = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
export const FormFieldHint = StyledFormFieldHint;

View File

@ -1,3 +1,4 @@
import { FormFieldHint } from '@/object-record/record-field/form-types/components/FormFieldHint';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer'; import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
@ -24,6 +25,7 @@ type FormNumberFieldInputProps = {
defaultValue: number | string | undefined; defaultValue: number | string | undefined;
onPersist: (value: number | null | string) => void; onPersist: (value: number | null | string) => void;
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
hint?: string;
}; };
export const FormNumberFieldInput = ({ export const FormNumberFieldInput = ({
@ -32,6 +34,7 @@ export const FormNumberFieldInput = ({
defaultValue, defaultValue,
onPersist, onPersist,
VariablePicker, VariablePicker,
hint,
}: FormNumberFieldInputProps) => { }: FormNumberFieldInputProps) => {
const inputId = useId(); const inputId = useId();
@ -125,6 +128,8 @@ export const FormNumberFieldInput = ({
/> />
) : null} ) : null}
</FormFieldInputRowContainer> </FormFieldInputRowContainer>
{hint ? <FormFieldHint>{hint}</FormFieldHint> : null}
</FormFieldInputContainer> </FormFieldInputContainer>
); );
}; };

View File

@ -0,0 +1,64 @@
import { FormCountryCodeSelectInput } from '@/object-record/record-field/form-types/components/FormCountryCodeSelectInput';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { CountryCode, getCountryCallingCode } from 'libphonenumber-js';
type FormPhoneFieldInputProps = {
label?: string;
defaultValue?: FieldPhonesValue;
onPersist: (value: FieldPhonesValue) => void;
VariablePicker?: VariablePickerComponent;
readonly?: boolean;
};
export const FormPhoneFieldInput = ({
label,
defaultValue,
onPersist,
readonly,
VariablePicker,
}: FormPhoneFieldInputProps) => {
const handleCountryChange = (newCountry: string) => {
const newCallingCode = getCountryCallingCode(newCountry as CountryCode);
onPersist({
primaryPhoneCountryCode: newCountry,
primaryPhoneCallingCode: newCallingCode,
primaryPhoneNumber: '',
});
};
const handleNumberChange = (number: string | number | null) => {
onPersist({
primaryPhoneCountryCode: defaultValue?.primaryPhoneCountryCode || '',
primaryPhoneCallingCode: defaultValue?.primaryPhoneCallingCode || '',
primaryPhoneNumber: number ? `${number}` : '',
});
};
return (
<FormFieldInputContainer>
{label && <InputLabel>{label}</InputLabel>}
<FormNestedFieldInputContainer>
<FormCountryCodeSelectInput
selectedCountryCode={defaultValue?.primaryPhoneCountryCode || ''}
onPersist={handleCountryChange}
readonly={readonly}
VariablePicker={VariablePicker}
/>
<FormNumberFieldInput
label="Phone Number"
defaultValue={defaultValue?.primaryPhoneNumber || ''}
onPersist={handleNumberChange}
VariablePicker={VariablePicker}
placeholder="Enter phone number"
hint="Without calling code"
/>
</FormNestedFieldInputContainer>
</FormFieldInputContainer>
);
};

View File

@ -225,6 +225,7 @@ export const FormSelectFieldInput = ({
color={selectedOption.color ?? 'transparent'} color={selectedOption.color ?? 'transparent'}
label={selectedOption.label} label={selectedOption.label}
Icon={selectedOption.icon ?? undefined} Icon={selectedOption.icon ?? undefined}
isUsedInForm
/> />
) : null} ) : null}
</StyledDisplayModeContainer> </StyledDisplayModeContainer>

View File

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

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { FormPhoneFieldInput } from '../FormPhoneFieldInput';
const meta: Meta<typeof FormPhoneFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormPhoneFieldInput',
component: FormPhoneFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormPhoneFieldInput>;
const defaultPhoneValue: FieldPhonesValue = {
primaryPhoneNumber: '0612345678',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '33',
};
export const Default: Story = {
args: {
label: 'Phone',
defaultValue: defaultPhoneValue,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Phone');
},
};

View File

@ -4,8 +4,22 @@ type SelectDisplayProps = {
color: ThemeColor | 'transparent'; color: ThemeColor | 'transparent';
label: string; label: string;
Icon?: IconComponent; Icon?: IconComponent;
isUsedInForm?: boolean;
}; };
export const SelectDisplay = ({ color, label, Icon }: SelectDisplayProps) => { export const SelectDisplay = ({
return <Tag preventShrink color={color} text={label} Icon={Icon} />; color,
label,
Icon,
isUsedInForm,
}: SelectDisplayProps) => {
return (
<Tag
preventShrink
color={color}
text={label}
Icon={Icon}
preventPadding={isUsedInForm}
/>
);
}; };

View File

@ -21,6 +21,7 @@ const StyledTag = styled.h3<{
weight: TagWeight; weight: TagWeight;
variant: TagVariant; variant: TagVariant;
preventShrink?: boolean; preventShrink?: boolean;
preventPadding?: boolean;
}>` }>`
align-items: center; align-items: center;
background: ${({ color, theme }) => { background: ${({ color, theme }) => {
@ -52,7 +53,7 @@ const StyledTag = styled.h3<{
height: ${spacing5}; height: ${spacing5};
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
padding: 0 ${spacing2}; padding: ${({ preventPadding }) => (preventPadding ? '0' : `0 ${spacing2}`)};
border: ${({ variant, theme }) => border: ${({ variant, theme }) =>
variant === 'outline' || variant === 'border' variant === 'outline' || variant === 'border'
? `1px ${variant === 'border' ? 'solid' : 'dashed'} ${theme.border.color.strong}` ? `1px ${variant === 'border' ? 'solid' : 'dashed'} ${theme.border.color.strong}`
@ -91,6 +92,7 @@ type TagProps = {
weight?: TagWeight; weight?: TagWeight;
variant?: TagVariant; variant?: TagVariant;
preventShrink?: boolean; preventShrink?: boolean;
preventPadding?: boolean;
}; };
// TODO: Find a way to have ellipsis and shrinkable tag in tag list while keeping good perf for table cells // TODO: Find a way to have ellipsis and shrinkable tag in tag list while keeping good perf for table cells
@ -103,6 +105,7 @@ export const Tag = ({
weight = 'regular', weight = 'regular',
variant = 'solid', variant = 'solid',
preventShrink, preventShrink,
preventPadding,
}: TagProps) => { }: TagProps) => {
const { theme } = useContext(ThemeContext); const { theme } = useContext(ThemeContext);
@ -115,6 +118,7 @@ export const Tag = ({
weight={weight} weight={weight}
variant={variant} variant={variant}
preventShrink={preventShrink} preventShrink={preventShrink}
preventPadding={preventPadding}
> >
{isDefined(Icon) ? ( {isDefined(Icon) ? (
<StyledIconContainer> <StyledIconContainer>