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 { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
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 { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
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 { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
@ -19,6 +21,7 @@ import {
FieldLinksValue,
FieldMetadata,
FieldMultiSelectValue,
FieldPhonesValue,
} 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';
@ -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 { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
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 { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { JsonValue } from 'type-fest';
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
type FormFieldInputProps = {
field: FieldDefinition<FieldMetadata>;
@ -109,6 +112,13 @@ export const FormFieldInput = ({
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldPhones(field) ? (
<FormPhoneFieldInput
label={field.label}
defaultValue={defaultValue as FieldPhonesValue | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldDate(field) ? (
<FormDateFieldInput
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,
}: {
selectedCountryName: string;
onPersist: (countryCode: string) => void;
onPersist: (country: string) => void;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
}) => {
@ -39,15 +39,15 @@ export const FormCountrySelectInput = ({
];
}, [countries]);
const onChange = (countryCode: string | null) => {
const onChange = (country: string | null) => {
if (readonly) {
return;
}
if (countryCode === null) {
if (country === null) {
onPersist('');
} 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 { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
@ -24,6 +25,7 @@ type FormNumberFieldInputProps = {
defaultValue: number | string | undefined;
onPersist: (value: number | null | string) => void;
VariablePicker?: VariablePickerComponent;
hint?: string;
};
export const FormNumberFieldInput = ({
@ -32,6 +34,7 @@ export const FormNumberFieldInput = ({
defaultValue,
onPersist,
VariablePicker,
hint,
}: FormNumberFieldInputProps) => {
const inputId = useId();
@ -125,6 +128,8 @@ export const FormNumberFieldInput = ({
/>
) : null}
</FormFieldInputRowContainer>
{hint ? <FormFieldHint>{hint}</FormFieldHint> : null}
</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'}
label={selectedOption.label}
Icon={selectedOption.icon ?? undefined}
isUsedInForm
/>
) : null}
</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';
label: string;
Icon?: IconComponent;
isUsedInForm?: boolean;
};
export const SelectDisplay = ({ color, label, Icon }: SelectDisplayProps) => {
return <Tag preventShrink color={color} text={label} Icon={Icon} />;
export const SelectDisplay = ({
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;
variant: TagVariant;
preventShrink?: boolean;
preventPadding?: boolean;
}>`
align-items: center;
background: ${({ color, theme }) => {
@ -52,7 +53,7 @@ const StyledTag = styled.h3<{
height: ${spacing5};
margin: 0;
overflow: hidden;
padding: 0 ${spacing2};
padding: ${({ preventPadding }) => (preventPadding ? '0' : `0 ${spacing2}`)};
border: ${({ variant, theme }) =>
variant === 'outline' || variant === 'border'
? `1px ${variant === 'border' ? 'solid' : 'dashed'} ${theme.border.color.strong}`
@ -91,6 +92,7 @@ type TagProps = {
weight?: TagWeight;
variant?: TagVariant;
preventShrink?: boolean;
preventPadding?: boolean;
};
// 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',
variant = 'solid',
preventShrink,
preventPadding,
}: TagProps) => {
const { theme } = useContext(ThemeContext);
@ -115,6 +118,7 @@ export const Tag = ({
weight={weight}
variant={variant}
preventShrink={preventShrink}
preventPadding={preventPadding}
>
{isDefined(Icon) ? (
<StyledIconContainer>