From 3171d0c87b3f3ca98e8d4f25bc253fc8f6d409e2 Mon Sep 17 00:00:00 2001 From: rostaklein Date: Thu, 28 Mar 2024 16:50:38 +0100 Subject: [PATCH] feat: address composite field (#4492) Added new Address field input type. --------- Co-authored-by: Lucas Bordeau --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.tsx | 1 + ...atFieldMetadataItemsAsFilterDefinitions.ts | 49 +- .../utils/mapFieldMetadataToGraphQLQuery.ts | 12 + .../types/FilterType.ts | 1 + .../record-field/components/FieldDisplay.tsx | 4 + .../record-field/components/FieldInput.tsx | 10 + .../record-field/hooks/usePersistField.ts | 9 +- .../components/AddressFieldDisplay.tsx | 17 + .../meta-types/hooks/useAddressField.ts | 57 + .../input/components/AddressFieldInput.tsx | 85 ++ .../__stories__/AddressFieldInput.stories.tsx | 137 ++ .../types/FieldInputDraftValue.ts | 15 +- .../record-field/types/FieldMetadata.ts | 19 +- .../types/guards/assertFieldMetadata.ts | 9 +- .../types/guards/isFieldAddress.ts | 6 + .../types/guards/isFieldAddressValue.ts | 19 + .../record-field/utils/isFieldValueEmpty.ts | 14 + .../types/ObjectRecordQueryFilter.ts | 10 + .../utils/isRecordMatchingFilter.ts | 25 + .../utils/generateEmptyFieldValue.ts | 12 + .../constants/SettingsFieldTypeConfigs.ts | 15 + ...SettingsDataModelFieldSettingsFormCard.tsx | 1 + .../ui/display/icon/types/IconComponent.ts | 6 +- .../field/input/components/AddressInput.tsx | 254 ++++ .../input/components/DoubleTextInput.tsx | 6 +- .../ui/field/input/components/PhoneInput.tsx | 4 +- .../ui/field/input/components/TextInput.tsx | 4 +- .../components/EntityTitleDoubleTextInput.tsx | 4 +- .../modules/ui/input/components/Select.tsx | 16 +- .../modules/ui/input/components/TextInput.tsx | 30 +- .../country/components/CountrySelect.tsx | 37 + .../constants/SelectCountryDropdownId.ts | 1 + .../components/internal/hooks/useCountries.ts | 37 + ...x => PhoneCountryPickerDropdownButton.tsx} | 50 +- ...x => PhoneCountryPickerDropdownSelect.tsx} | 5 +- .../components/internal/types/Country.ts | 9 + .../typeorm-seeds/workspace/companies.ts | 14 - .../type-definitions.generator.ts | 4 +- ...p-field-metadata-to-graphql-query.utils.ts | 14 + .../open-api/utils/components.utils.ts | 1 + .../composite-types/address.composite-type.ts | 195 +++ .../field-metadata/composite-types/index.ts | 2 + .../dtos/default-value.input.ts | 34 + .../field-metadata/field-metadata.entity.ts | 2 + .../field-metadata-default-value.interface.ts | 2 + ...ld-metadata-target-column-map.interface.ts | 12 + .../utils/generate-default-value.ts | 11 + .../utils/generate-target-column-map.util.ts | 11 + .../is-composite-field-metadata-type.util.ts | 3 +- .../validate-default-value-for-type.util.ts | 2 + .../companies-demo.json | 1200 ++++++++--------- .../demo-objects-prefill-data/company.ts | 2 +- .../company.object-metadata.ts | 2 +- .../src/utils/computeInputFields.ts | 52 + .../twenty-zapier/src/utils/data.types.ts | 1 + 56 files changed, 1839 insertions(+), 716 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts create mode 100644 packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/country/constants/SelectCountryDropdownId.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/hooks/useCountries.ts rename packages/twenty-front/src/modules/ui/input/components/internal/phone/components/{CountryPickerDropdownButton.tsx => PhoneCountryPickerDropdownButton.tsx} (68%) rename packages/twenty-front/src/modules/ui/input/components/internal/phone/components/{CountryPickerDropdownSelect.tsx => PhoneCountryPickerDropdownSelect.tsx} (96%) create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/types/Country.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/address.composite-type.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index cea7f2cd3..facd710fc 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -265,6 +265,7 @@ export type FieldDeleteResponse = { /** Type of the field */ export enum FieldMetadataType { + Address = 'ADDRESS', Boolean = 'BOOLEAN', Currency = 'CURRENCY', DateTime = 'DATE_TIME', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index bb598e60c..fac67dfd2 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -179,6 +179,7 @@ export type FieldDeleteResponse = { /** Type of the field */ export enum FieldMetadataType { + Address = 'ADDRESS', Boolean = 'BOOLEAN', Currency = 'CURRENCY', DateTime = 'DATE_TIME', diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 1774d0c1a..359b15396 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -18,6 +18,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ FieldMetadataType.Number, FieldMetadataType.Link, FieldMetadataType.FullName, + FieldMetadataType.Address, FieldMetadataType.Relation, FieldMetadataType.Select, FieldMetadataType.Currency, @@ -52,24 +53,32 @@ export const formatFieldMetadataItemAsFilterDefinition = ({ field.toRelationMetadata?.fromObjectMetadata.namePlural, relationObjectMetadataNameSingular: field.toRelationMetadata?.fromObjectMetadata.nameSingular, - type: - field.type === FieldMetadataType.DateTime - ? 'DATE_TIME' - : field.type === FieldMetadataType.Link - ? 'LINK' - : field.type === FieldMetadataType.FullName - ? 'FULL_NAME' - : field.type === FieldMetadataType.Number - ? 'NUMBER' - : field.type === FieldMetadataType.Currency - ? 'CURRENCY' - : field.type === FieldMetadataType.Email - ? 'TEXT' - : field.type === FieldMetadataType.Phone - ? 'TEXT' - : field.type === FieldMetadataType.Relation - ? 'RELATION' - : field.type === FieldMetadataType.Select - ? 'SELECT' - : 'TEXT', + type: getFilterType(field.type), }); + +const getFilterType = (fieldType: FieldMetadataType) => { + switch (fieldType) { + case FieldMetadataType.DateTime: + return 'DATE_TIME'; + case FieldMetadataType.Link: + return 'LINK'; + case FieldMetadataType.FullName: + return 'FULL_NAME'; + case FieldMetadataType.Number: + return 'NUMBER'; + case FieldMetadataType.Currency: + return 'CURRENCY'; + case FieldMetadataType.Email: + return 'EMAIL'; + case FieldMetadataType.Phone: + return 'PHONE'; + case FieldMetadataType.Relation: + return 'RELATION'; + case FieldMetadataType.Select: + return 'SELECT'; + case FieldMetadataType.Address: + return 'ADDRESS'; + default: + return 'TEXT'; + } +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index e6dc29a1c..3c70a01ad 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -106,6 +106,18 @@ ${mapObjectMetadataToGraphQLQuery({ { firstName lastName +}`; + } else if (fieldType === 'ADDRESS') { + return `${field.name} +{ + addressStreet1 + addressStreet2 + addressCity + addressState + addressCountry + addressPostcode + addressLat + addressLng }`; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts index 7ec43a222..b55986058 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts @@ -8,4 +8,5 @@ export type FilterType = | 'FULL_NAME' | 'LINK' | 'RELATION' + | 'ADDRESS' | 'SELECT'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 93efc4001..fd24519a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -1,6 +1,7 @@ import { useContext } from 'react'; import { FieldContext } from '../contexts/FieldContext'; +import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyFieldDisplay'; import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay'; @@ -13,6 +14,7 @@ import { RelationFieldDisplay } from '../meta-types/display/components/RelationF import { SelectFieldDisplay } from '../meta-types/display/components/SelectFieldDisplay'; import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay'; import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay'; +import { isFieldAddress } from '../types/guards/isFieldAddress'; import { isFieldCurrency } from '../types/guards/isFieldCurrency'; import { isFieldDateTime } from '../types/guards/isFieldDateTime'; import { isFieldEmail } from '../types/guards/isFieldEmail'; @@ -55,5 +57,7 @@ export const FieldDisplay = () => { ) : isFieldSelect(fieldDefinition) ? ( + ) : isFieldAddress(fieldDefinition) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index e69b3ff29..63ddafac2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -1,5 +1,6 @@ import { useContext } from 'react'; +import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; @@ -19,6 +20,7 @@ import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInpu import { RelationFieldInput } from '../meta-types/input/components/RelationFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; import { FieldInputEvent } from '../types/FieldInputEvent'; +import { isFieldAddress } from '../types/guards/isFieldAddress'; import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldCurrency } from '../types/guards/isFieldCurrency'; import { isFieldDateTime } from '../types/guards/isFieldDateTime'; @@ -127,6 +129,14 @@ export const FieldInput = ({ ) : isFieldSelect(fieldDefinition) ? ( + ) : isFieldAddress(fieldDefinition) ? ( + ) : ( <> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index aea51e8a6..e3db24455 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -1,6 +1,8 @@ import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; @@ -82,6 +84,10 @@ export const usePersistField = () => { const fieldIsSelect = isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); + const fieldIsAddress = + isFieldAddress(fieldDefinition) && + isFieldAddressValue(valueToPersist); + if ( fieldIsRelation || fieldIsText || @@ -94,7 +100,8 @@ export const usePersistField = () => { fieldIsLink || fieldIsCurrency || fieldIsFullName || - fieldIsSelect + fieldIsSelect || + fieldIsAddress ) { const fieldName = fieldDefinition.metadata.fieldName; set( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx new file mode 100644 index 000000000..598052900 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx @@ -0,0 +1,17 @@ +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { TextDisplay } from '@/ui/field/display/components/TextDisplay'; + +export const AddressFieldDisplay = () => { + const { fieldValue } = useAddressField(); + + const content = [ + fieldValue?.addressStreet1, + fieldValue?.addressStreet2, + fieldValue?.addressCity, + fieldValue?.addressCountry, + ] + .filter(Boolean) + .join(', '); + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts new file mode 100644 index 000000000..152d6c2ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts @@ -0,0 +1,57 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { usePersistField } from '../../hooks/usePersistField'; +import { FieldAddressValue } from '../../types/FieldMetadata'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; +import { isFieldAddress } from '../../types/guards/isFieldAddress'; + +export const useAddressField = () => { + const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.Address, + isFieldAddress, + fieldDefinition, + ); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + + const persistField = usePersistField(); + + const persistAddressField = (newValue: FieldAddressValue) => { + if (!isFieldAddressValue(newValue)) { + return; + } + + persistField(newValue); + }; + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${entityId}-${fieldName}`); + + const draftValue = useRecoilValue(getDraftValueSelector()); + + return { + fieldDefinition, + fieldValue, + setFieldValue, + draftValue, + setDraftValue, + hotkeyScope, + persistAddressField, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx new file mode 100644 index 000000000..952a5e356 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx @@ -0,0 +1,85 @@ +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { AddressInput } from '@/ui/field/input/components/AddressInput'; +import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay'; + +import { usePersistField } from '../../../hooks/usePersistField'; + +import { FieldInputEvent } from './DateFieldInput'; + +export type AddressFieldInputProps = { + onClickOutside?: FieldInputEvent; + onEnter?: FieldInputEvent; + onEscape?: FieldInputEvent; + onTab?: FieldInputEvent; + onShiftTab?: FieldInputEvent; +}; + +export const AddressFieldInput = ({ + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: AddressFieldInputProps) => { + const { hotkeyScope, draftValue, setDraftValue } = useAddressField(); + + const persistField = usePersistField(); + + const convertToAddress = ( + newAddress: FieldAddressDraftValue | undefined, + ): FieldAddressDraftValue => { + return { + addressStreet1: newAddress?.addressStreet1 ?? '', + addressStreet2: newAddress?.addressStreet2 ?? null, + addressCity: newAddress?.addressCity ?? null, + addressState: newAddress?.addressState ?? null, + addressCountry: newAddress?.addressCountry ?? null, + addressPostcode: newAddress?.addressPostcode ?? null, + addressLat: newAddress?.addressLat ?? null, + addressLng: newAddress?.addressLng ?? null, + }; + }; + + const handleEnter = (newAddress: FieldAddressDraftValue) => { + onEnter?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleTab = (newAddress: FieldAddressDraftValue) => { + onTab?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleShiftTab = (newAddress: FieldAddressDraftValue) => { + onShiftTab?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleEscape = (newAddress: FieldAddressDraftValue) => { + onEscape?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleClickOutside = ( + event: MouseEvent | TouchEvent, + newAddress: FieldAddressDraftValue, + ) => { + onClickOutside?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleChange = (newAddress: FieldAddressDraftValue) => { + setDraftValue(convertToAddress(newAddress)); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx new file mode 100644 index 000000000..9afa22d27 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx @@ -0,0 +1,137 @@ +import { useEffect } from 'react'; +import { Decorator, Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, waitFor } from '@storybook/test'; + +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { + AddressInput, + AddressInputProps, +} from '@/ui/field/input/components/AddressInput'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; + +const AddressValueSetterEffect = ({ + value, +}: { + value: FieldAddressDraftValue; +}) => { + const { setFieldValue } = useAddressField(); + + useEffect(() => { + setFieldValue(value); + }, [setFieldValue, value]); + + return <>; +}; + +type AddressInputWithContextProps = AddressInputProps & { + value: string; + entityId?: string; +}; + +const AddressInputWithContext = ({ + entityId, + value, + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: AddressInputWithContextProps) => { + const setHotKeyScope = useSetHotkeyScope(); + + useEffect(() => { + setHotKeyScope('hotkey-scope'); + }, [setHotKeyScope]); + + return ( +
+ + + + +
+
+ ); +}; + +const enterJestFn = fn(); +const escapeJestfn = fn(); +const clickOutsideJestFn = fn(); +const tabJestFn = fn(); +const shiftTabJestFn = fn(); + +const clearMocksDecorator: Decorator = (Story, context) => { + if (context.parameters.clearMocks === true) { + enterJestFn.mockClear(); + escapeJestfn.mockClear(); + clickOutsideJestFn.mockClear(); + tabJestFn.mockClear(); + shiftTabJestFn.mockClear(); + } + return ; +}; + +const meta: Meta = { + title: 'UI/Data/Field/Input/AddressInput', + component: AddressInputWithContext, + args: { + value: 'text', + onEnter: enterJestFn, + onEscape: escapeJestfn, + onClickOutside: clickOutsideJestFn, + onTab: tabJestFn, + onShiftTab: shiftTabJestFn, + }, + argTypes: { + onEnter: { control: false }, + onEscape: { control: false }, + onClickOutside: { control: false }, + onTab: { control: false }, + onShiftTab: { control: false }, + }, + decorators: [clearMocksDecorator], + parameters: { + clearMocks: true, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Enter: Story = { + play: async () => { + expect(enterJestFn).toHaveBeenCalledTimes(0); + + await waitFor(() => { + userEvent.keyboard('{enter}'); + expect(enterJestFn).toHaveBeenCalledTimes(1); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 6aea22052..1639e3d6d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -1,5 +1,6 @@ import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { + FieldAddressValue, FieldBooleanValue, FieldCurrencyValue, FieldDateTimeValue, @@ -28,6 +29,16 @@ export type FieldCurrencyDraftValue = { amount: string; }; export type FieldFullNameDraftValue = { firstName: string; lastName: string }; +export type FieldAddressDraftValue = { + addressStreet1: string; + addressStreet2: string | null; + addressCity: string | null; + addressState: string | null; + addressPostcode: string | null; + addressCountry: string | null; + addressLat: number | null; + addressLng: number | null; +}; export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldTextDraftValue @@ -55,4 +66,6 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldSelectDraftValue : FieldValue extends FieldRelationValue ? FieldRelationDraftValue - : never; + : FieldValue extends FieldAddressValue + ? FieldAddressDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 7f2281983..f682bd422 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -69,6 +69,12 @@ export type FieldRatingMetadata = { fieldName: string; }; +export type FieldAddressMetadata = { + objectMetadataNameSingular?: string; + placeHolder: string; + fieldName: string; +}; + export type FieldRawJsonMetadata = { objectMetadataNameSingular?: string; fieldName: string; @@ -109,7 +115,8 @@ export type FieldMetadata = | FieldRelationMetadata | FieldSelectMetadata | FieldTextMetadata - | FieldUuidMetadata; + | FieldUuidMetadata + | FieldAddressMetadata; export type FieldTextValue = string; export type FieldUUidValue = string; @@ -125,6 +132,16 @@ export type FieldCurrencyValue = { amountMicros: number | null; }; export type FieldFullNameValue = { firstName: string; lastName: string }; +export type FieldAddressValue = { + addressStreet1: string; + addressStreet2: string | null; + addressCity: string | null; + addressState: string | null; + addressPostcode: string | null; + addressCountry: string | null; + addressLat: number | null; + addressLng: number | null; +}; export type FieldRatingValue = (typeof RATING_VALUES)[number]; export type FieldSelectValue = string | null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 24cb92e91..37d4fc84b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -2,6 +2,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldDefinition } from '../FieldDefinition'; import { + FieldAddressMetadata, FieldBooleanMetadata, FieldCurrencyMetadata, FieldDateTimeMetadata, @@ -49,9 +50,11 @@ type AssertFieldMetadataFunction = < ? FieldTextMetadata : E extends 'UUID' ? FieldUuidMetadata - : E extends 'RAW_JSON' - ? FieldRawJsonMetadata - : never, + : E extends 'ADDRESS' + ? FieldAddressMetadata + : E extends 'RAW_JSON' + ? FieldRawJsonMetadata + : never, >( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts new file mode 100644 index 000000000..f5ffb55bf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts @@ -0,0 +1,6 @@ +import { FieldDefinition } from '../FieldDefinition'; +import { FieldAddressMetadata, FieldMetadata } from '../FieldMetadata'; + +export const isFieldAddress = ( + field: Pick, 'type'>, +): field is FieldDefinition => field.type === 'ADDRESS'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts new file mode 100644 index 000000000..8bc33766e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { FieldAddressValue } from '../FieldMetadata'; + +const addressSchema = z.object({ + addressStreet1: z.string(), + addressStreet2: z.string().nullable(), + addressCity: z.string().nullable(), + addressState: z.string().nullable(), + addressPostcode: z.string().nullable(), + addressCountry: z.string().nullable(), + addressLat: z.number().nullable(), + addressLng: z.number().nullable(), +}); + +export const isFieldAddressValue = ( + fieldValue: unknown, +): fieldValue is FieldAddressValue => + addressSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 3bd94a09f..62ee380f7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -1,5 +1,7 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; @@ -68,6 +70,18 @@ export const isFieldValueEmpty = ({ return !isFieldLinkValue(fieldValue) || isValueEmpty(fieldValue?.url); } + if (isFieldAddress(fieldDefinition)) { + return ( + !isFieldAddressValue(fieldValue) || + (isValueEmpty(fieldValue?.addressStreet1) && + isValueEmpty(fieldValue?.addressStreet2) && + isValueEmpty(fieldValue?.addressCity) && + isValueEmpty(fieldValue?.addressState) && + isValueEmpty(fieldValue?.addressPostcode) && + isValueEmpty(fieldValue?.addressCountry)) + ); + } + throw new Error( `Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`, ); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts index e86ac4da6..768221f5f 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts @@ -71,6 +71,15 @@ export type FullNameFilter = { lastName?: StringFilter; }; +export type AddressFilter = { + addressStreet1?: StringFilter; + addressStreet2?: StringFilter; + addressCity?: StringFilter; + addressState?: StringFilter; + addressCountry?: StringFilter; + addressPostcode?: StringFilter; +}; + export type LeafFilter = | UUIDFilter | StringFilter @@ -80,6 +89,7 @@ export type LeafFilter = | URLFilter | FullNameFilter | BooleanFilter + | AddressFilter | undefined; export type AndObjectRecordFilter = { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 4d90608ab..13e93c06a 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -2,6 +2,7 @@ import { isObject } from '@sniptt/guards'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { + AddressFilter, AndObjectRecordFilter, BooleanFilter, CurrencyFilter, @@ -180,6 +181,30 @@ export const isRecordMatchingFilter = ({ })) ); } + case FieldMetadataType.Address: { + const addressFilter = filterValue as AddressFilter; + + const keys = [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressCountry', + 'addressPostcode', + ] as const; + + return keys.some((key) => { + const value = addressFilter[key]; + if (value === undefined) { + return false; + } + + return isMatchingStringFilter({ + stringFilter: value, + value: record[filterKey][key], + }); + }); + } case FieldMetadataType.DateTime: { return isMatchingDateFilter({ dateFilter: filterValue as DateFilter, diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 65b043438..80db04662 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -25,6 +25,18 @@ export const generateEmptyFieldValue = ( lastName: '', }; } + case FieldMetadataType.Address: { + return { + addressStreet1: '', + addressStreet2: '', + addressCity: '', + addressState: '', + addressCountry: '', + addressPostcode: '', + addressLat: null, + addressLng: null, + }; + } case FieldMetadataType.DateTime: { return null; } diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts index b0a02592c..3f6dfc652 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts @@ -8,6 +8,7 @@ import { IconKey, IconLink, IconMail, + IconMap, IconNumbers, IconPhone, IconRelationManyToMany, @@ -101,4 +102,18 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record< Icon: IconUser, defaultValue: { firstName: 'John', lastName: 'Doe' }, }, + [FieldMetadataType.Address]: { + label: 'Address', + Icon: IconMap, + defaultValue: { + addressStreet1: '456 Oak Street', + addressStreet2: 'Unit 3B', + addressCity: 'Springfield', + addressState: 'California', + addressCountry: 'United States', + addressPostcode: '90210', + addressLat: 34.0522, + addressLng: -118.2437, + }, + }, }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index 72c9ac9cd..9a63f3afa 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -66,6 +66,7 @@ const previewableTypes = [ FieldMetadataType.Rating, FieldMetadataType.Relation, FieldMetadataType.Text, + FieldMetadataType.Address, ]; export const SettingsDataModelFieldSettingsFormCard = ({ diff --git a/packages/twenty-front/src/modules/ui/display/icon/types/IconComponent.ts b/packages/twenty-front/src/modules/ui/display/icon/types/IconComponent.ts index b66ffc568..17b108bda 100644 --- a/packages/twenty-front/src/modules/ui/display/icon/types/IconComponent.ts +++ b/packages/twenty-front/src/modules/ui/display/icon/types/IconComponent.ts @@ -1,8 +1,10 @@ import { FunctionComponent } from 'react'; -export type IconComponent = FunctionComponent<{ +export type IconComponentProps = { className?: string; color?: string; size?: number; stroke?: number; -}>; +}; + +export type IconComponent = FunctionComponent; 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 new file mode 100644 index 000000000..40e83f39e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx @@ -0,0 +1,254 @@ +import { RefObject, useEffect, useRef, useState } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { flip, offset, useFloating } from '@floating-ui/react'; +import { Key } from 'ts-key-enum'; + +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { CountrySelect } from '@/ui/input/components/internal/country/components/CountrySelect'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; +import { isDefined } from '~/utils/isDefined'; + +const StyledAddressContainer = styled.div` + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + + padding: 4px 8px; + + width: 100%; + min-width: 260px; + > div { + margin-bottom: 6px; + } +`; + +const StyledHalfRowContainer = styled.div` + display: flex; + gap: 8px; +`; + +export type AddressInputProps = { + value: FieldAddressValue; + onTab: (newAddress: FieldAddressDraftValue) => void; + onShiftTab: (newAddress: FieldAddressDraftValue) => void; + onEnter: (newAddress: FieldAddressDraftValue) => void; + onEscape: (newAddress: FieldAddressDraftValue) => void; + onClickOutside: ( + event: MouseEvent | TouchEvent, + newAddress: FieldAddressDraftValue, + ) => void; + hotkeyScope: string; + clearable?: boolean; + onChange?: (updatedValue: FieldAddressDraftValue) => void; +}; + +export const AddressInput = ({ + value, + hotkeyScope, + onTab, + onShiftTab, + onEnter, + onEscape, + onClickOutside, + onChange, +}: AddressInputProps) => { + const theme = useTheme(); + + const [internalValue, setInternalValue] = useState(value); + const addressStreet1InputRef = useRef(null); + const addressStreet2InputRef = useRef(null); + const addressCityInputRef = useRef(null); + const addressStateInputRef = useRef(null); + const addressPostCodeInputRef = useRef(null); + + const inputRefs: { + [key in keyof FieldAddressDraftValue]?: RefObject; + } = { + addressStreet1: addressStreet1InputRef, + addressStreet2: addressStreet2InputRef, + addressCity: addressCityInputRef, + addressState: addressStateInputRef, + addressPostcode: addressPostCodeInputRef, + }; + + const [focusPosition, setFocusPosition] = + useState('addressStreet1'); + + const wrapperRef = useRef(null); + + const { refs, floatingStyles } = useFloating({ + placement: 'top-start', + middleware: [ + flip(), + offset({ + mainAxis: theme.spacingMultiplicator * 2, + }), + ], + }); + + const getChangeHandler = + (field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => { + const updatedAddress = { ...value, [field]: updatedAddressPart }; + setInternalValue(updatedAddress); + onChange?.(updatedAddress); + }; + + const getFocusHandler = (fieldName: keyof FieldAddressDraftValue) => () => { + setFocusPosition(fieldName); + + inputRefs[fieldName]?.current?.focus(); + }; + + useScopedHotkeys( + 'tab', + () => { + const currentFocusPosition = Object.keys(inputRefs).findIndex( + (key) => key === focusPosition, + ); + const maxFocusPosition = Object.keys(inputRefs).length - 1; + + const nextFocusPosition = currentFocusPosition + 1; + + const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition; + + if (isFocusPositionAfterLast) { + onTab?.(internalValue); + } else { + const nextFocusFieldName = Object.keys(inputRefs)[ + nextFocusPosition + ] as keyof FieldAddressDraftValue; + + setFocusPosition(nextFocusFieldName); + inputRefs[nextFocusFieldName]?.current?.focus(); + } + }, + hotkeyScope, + [onTab, internalValue, focusPosition], + ); + + useScopedHotkeys( + 'shift+tab', + () => { + const currentFocusPosition = Object.keys(inputRefs).findIndex( + (key) => key === focusPosition, + ); + + const nextFocusPosition = currentFocusPosition - 1; + + const isFocusPositionBeforeFirst = nextFocusPosition < 0; + + if (isFocusPositionBeforeFirst) { + onShiftTab?.(internalValue); + } else { + const nextFocusFieldName = Object.keys(inputRefs)[ + nextFocusPosition + ] as keyof FieldAddressDraftValue; + + setFocusPosition(nextFocusFieldName); + inputRefs[nextFocusFieldName]?.current?.focus(); + } + }, + hotkeyScope, + [onTab, internalValue, focusPosition], + ); + + useScopedHotkeys( + Key.Enter, + () => { + onEnter(internalValue); + }, + hotkeyScope, + [onEnter, internalValue], + ); + + useScopedHotkeys( + [Key.Escape], + () => { + onEscape(internalValue); + }, + hotkeyScope, + [onEscape, internalValue], + ); + + const { useListenClickOutside } = useClickOutsideListener('addressInput'); + + useListenClickOutside({ + refs: [wrapperRef], + callback: (event) => { + event.stopImmediatePropagation(); + + onClickOutside?.(event, internalValue); + }, + enabled: isDefined(onClickOutside), + }); + + useEffect(() => { + setInternalValue(value); + }, [value]); + + return ( +
+ + + + + + + + + + + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx index 1ca95288f..48b4bc259 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx @@ -13,7 +13,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { isDefined } from '~/utils/isDefined'; -import { StyledInput } from './TextInput'; +import { StyledTextInput } from './TextInput'; const StyledContainer = styled.div` align-items: center; @@ -174,7 +174,7 @@ export const DoubleTextInput = ({ return ( - setFocusPosition('left')} @@ -188,7 +188,7 @@ export const DoubleTextInput = ({ handleOnPaste(event) } /> - setFocusPosition('right')} ref={secondValueInputRef} diff --git a/packages/twenty-front/src/modules/ui/field/input/components/PhoneInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/PhoneInput.tsx index 4b232637b..36b7650c0 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/PhoneInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/PhoneInput.tsx @@ -3,7 +3,7 @@ import ReactPhoneNumberInput from 'react-phone-number-input'; import styled from '@emotion/styled'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; -import { CountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/CountryPickerDropdownButton'; +import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton'; import 'react-phone-number-input/style.css'; @@ -102,7 +102,7 @@ export const PhoneInput = ({ onChange={handleChange} international={true} withCountryCallingCode={true} - countrySelectComponent={CountryPickerDropdownButton} + countrySelectComponent={PhoneCountryPickerDropdownButton} /> ); diff --git a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx index 245d298c1..382897f96 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle'; -export const StyledInput = styled.input` +export const StyledTextInput = styled.input` margin: 0; ${TEXT_INPUT_STYLE} width: 100%; @@ -60,7 +60,7 @@ export const TextInput = ({ }); return ( - theme.spacing(0.5)}; padding: 0; width: ${({ width }) => (width ? `${width}px` : 'auto')}; diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 899b3f3c2..343a0b89c 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -10,6 +10,7 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { SelectHotkeyScope } from '../types/SelectHotkeyScope'; @@ -43,6 +44,7 @@ const StyledControlContainer = styled.div<{ disabled?: boolean }>` align-items: center; background-color: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid ${({ theme }) => theme.border.color.medium}; + box-sizing: border-box; border-radius: ${({ theme }) => theme.border.radius.sm}; color: ${({ disabled, theme }) => disabled ? theme.font.color.tertiary : theme.font.color.primary}; @@ -88,6 +90,8 @@ export const Select = ({ value, withSearchInput, }: SelectProps) => { + const selectContainerRef = useRef(null); + const theme = useTheme(); const [searchInputValue, setSearchInputValue] = useState(''); @@ -109,6 +113,15 @@ export const Select = ({ const { closeDropdown } = useDropdown(dropdownId); + const { useListenClickOutside } = useClickOutsideListener(dropdownId); + + useListenClickOutside({ + refs: [selectContainerRef], + callback: () => { + closeDropdown(); + }, + }); + const selectControl = ( @@ -133,6 +146,7 @@ export const Select = ({ fullWidth={fullWidth} tabIndex={0} onBlur={onBlur} + ref={selectContainerRef} > {!!label && {label}} {isDisabled ? ( diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx index d3bec867d..9e839c108 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx @@ -20,20 +20,6 @@ import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { InputHotkeyScope } from '../types/InputHotkeyScope'; -export type TextInputComponentProps = Omit< - InputHTMLAttributes, - 'onChange' | 'onKeyDown' -> & { - className?: string; - label?: string; - onChange?: (text: string) => void; - fullWidth?: boolean; - disableHotkeys?: boolean; - error?: string; - RightIcon?: IconComponent; - onKeyDown?: (event: React.KeyboardEvent) => void; -}; - const StyledContainer = styled.div>` display: inline-flex; flex-direction: column; @@ -110,6 +96,21 @@ const StyledTrailingIcon = styled.div` const INPUT_TYPE_PASSWORD = 'password'; +export type TextInputComponentProps = Omit< + InputHTMLAttributes, + 'onChange' | 'onKeyDown' +> & { + className?: string; + label?: string; + onChange?: (text: string) => void; + fullWidth?: boolean; + disableHotkeys?: boolean; + error?: string; + RightIcon?: IconComponent; + onKeyDown?: (event: React.KeyboardEvent) => void; + onBlur?: () => void; +}; + const TextInputComponent = ( { className, @@ -163,6 +164,7 @@ const TextInputComponent = ( inputRef.current?.blur(); }, InputHotkeyScope.TextInput, + { enabled: !disableHotkeys }, ); const [passwordVisible, setPasswordVisible] = useState(false); 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 new file mode 100644 index 000000000..38bbbd98f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { IconComponentProps } from '@/ui/display/icon/types/IconComponent'; +import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId'; +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; +import { Select, SelectOption } from '@/ui/input/components/Select'; + +export const CountrySelect = ({ + selectedCountryName, + onChange, +}: { + selectedCountryName: string; + onChange: (countryCode: string) => void; +}) => { + const countries = useCountries(); + + const options: SelectOption[] = useMemo(() => { + return countries.map>(({ countryName, Flag }) => ({ + label: countryName, + value: countryName, + Icon: (props: IconComponentProps) => + Flag({ width: props.size, height: props.size }), // TODO : improve this ? + })); + }, [countries]); + + return ( +