feat: address composite field (#4492)

Added new Address field input type.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
rostaklein
2024-03-28 16:50:38 +01:00
committed by GitHub
parent 22d4af2e0c
commit 3171d0c87b
56 changed files with 1839 additions and 716 deletions

View File

@ -265,6 +265,7 @@ export type FieldDeleteResponse = {
/** Type of the field */ /** Type of the field */
export enum FieldMetadataType { export enum FieldMetadataType {
Address = 'ADDRESS',
Boolean = 'BOOLEAN', Boolean = 'BOOLEAN',
Currency = 'CURRENCY', Currency = 'CURRENCY',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',

View File

@ -179,6 +179,7 @@ export type FieldDeleteResponse = {
/** Type of the field */ /** Type of the field */
export enum FieldMetadataType { export enum FieldMetadataType {
Address = 'ADDRESS',
Boolean = 'BOOLEAN', Boolean = 'BOOLEAN',
Currency = 'CURRENCY', Currency = 'CURRENCY',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',

View File

@ -18,6 +18,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Link, FieldMetadataType.Link,
FieldMetadataType.FullName, FieldMetadataType.FullName,
FieldMetadataType.Address,
FieldMetadataType.Relation, FieldMetadataType.Relation,
FieldMetadataType.Select, FieldMetadataType.Select,
FieldMetadataType.Currency, FieldMetadataType.Currency,
@ -52,24 +53,32 @@ export const formatFieldMetadataItemAsFilterDefinition = ({
field.toRelationMetadata?.fromObjectMetadata.namePlural, field.toRelationMetadata?.fromObjectMetadata.namePlural,
relationObjectMetadataNameSingular: relationObjectMetadataNameSingular:
field.toRelationMetadata?.fromObjectMetadata.nameSingular, field.toRelationMetadata?.fromObjectMetadata.nameSingular,
type: type: getFilterType(field.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',
}); });
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';
}
};

View File

@ -106,6 +106,18 @@ ${mapObjectMetadataToGraphQLQuery({
{ {
firstName firstName
lastName lastName
}`;
} else if (fieldType === 'ADDRESS') {
return `${field.name}
{
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
addressLat
addressLng
}`; }`;
} }

View File

@ -8,4 +8,5 @@ export type FilterType =
| 'FULL_NAME' | 'FULL_NAME'
| 'LINK' | 'LINK'
| 'RELATION' | 'RELATION'
| 'ADDRESS'
| 'SELECT'; | 'SELECT';

View File

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyFieldDisplay'; import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyFieldDisplay';
import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay'; 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 { SelectFieldDisplay } from '../meta-types/display/components/SelectFieldDisplay';
import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay'; import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay';
import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay'; import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay';
import { isFieldAddress } from '../types/guards/isFieldAddress';
import { isFieldCurrency } from '../types/guards/isFieldCurrency'; import { isFieldCurrency } from '../types/guards/isFieldCurrency';
import { isFieldDateTime } from '../types/guards/isFieldDateTime'; import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldEmail } from '../types/guards/isFieldEmail';
@ -55,5 +57,7 @@ export const FieldDisplay = () => {
<PhoneFieldDisplay /> <PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay /> <SelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />
) : null; ) : null;
}; };

View File

@ -1,5 +1,6 @@
import { useContext } from 'react'; 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 { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; 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 { RelationFieldInput } from '../meta-types/input/components/RelationFieldInput';
import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput';
import { FieldInputEvent } from '../types/FieldInputEvent'; import { FieldInputEvent } from '../types/FieldInputEvent';
import { isFieldAddress } from '../types/guards/isFieldAddress';
import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldCurrency } from '../types/guards/isFieldCurrency'; import { isFieldCurrency } from '../types/guards/isFieldCurrency';
import { isFieldDateTime } from '../types/guards/isFieldDateTime'; import { isFieldDateTime } from '../types/guards/isFieldDateTime';
@ -127,6 +129,14 @@ export const FieldInput = ({
<RatingFieldInput onSubmit={onSubmit} /> <RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} /> <SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : ( ) : (
<></> <></>
)} )}

View File

@ -1,6 +1,8 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilCallback } from 'recoil'; 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 { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@ -82,6 +84,10 @@ export const usePersistField = () => {
const fieldIsSelect = const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
const fieldIsAddress =
isFieldAddress(fieldDefinition) &&
isFieldAddressValue(valueToPersist);
if ( if (
fieldIsRelation || fieldIsRelation ||
fieldIsText || fieldIsText ||
@ -94,7 +100,8 @@ export const usePersistField = () => {
fieldIsLink || fieldIsLink ||
fieldIsCurrency || fieldIsCurrency ||
fieldIsFullName || fieldIsFullName ||
fieldIsSelect fieldIsSelect ||
fieldIsAddress
) { ) {
const fieldName = fieldDefinition.metadata.fieldName; const fieldName = fieldDefinition.metadata.fieldName;
set( set(

View File

@ -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 <TextDisplay text={content} />;
};

View File

@ -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<FieldAddressValue>(
recordStoreFamilySelector({
recordId: entityId,
fieldName: fieldName,
}),
);
const persistField = usePersistField();
const persistAddressField = (newValue: FieldAddressValue) => {
if (!isFieldAddressValue(newValue)) {
return;
}
persistField(newValue);
};
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldAddressValue>(`${entityId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
return {
fieldDefinition,
fieldValue,
setFieldValue,
draftValue,
setDraftValue,
hotkeyScope,
persistAddressField,
};
};

View File

@ -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 (
<FieldInputOverlay>
<AddressInput
value={convertToAddress(draftValue)}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
hotkeyScope={hotkeyScope}
onChange={handleChange}
onTab={handleTab}
onShiftTab={handleShiftTab}
/>
</FieldInputOverlay>
);
};

View File

@ -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 (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'text',
label: 'Address',
type: FieldMetadataType.Address,
iconName: 'IconTag',
metadata: {
fieldName: 'Address',
placeHolder: 'Enter text',
},
}}
entityId={entityId}
>
<AddressValueSetterEffect value={value} />
<AddressInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
value={value}
hotkeyScope=""
onTab={onTab}
onShiftTab={onShiftTab}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
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 <Story />;
};
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<typeof AddressInputWithContext>;
export const Default: Story = {};
export const Enter: Story = {
play: async () => {
expect(enterJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{enter}');
expect(enterJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -1,5 +1,6 @@
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { import {
FieldAddressValue,
FieldBooleanValue, FieldBooleanValue,
FieldCurrencyValue, FieldCurrencyValue,
FieldDateTimeValue, FieldDateTimeValue,
@ -28,6 +29,16 @@ export type FieldCurrencyDraftValue = {
amount: string; amount: string;
}; };
export type FieldFullNameDraftValue = { firstName: string; lastName: 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> = FieldValue extends FieldTextValue export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldTextDraftValue ? FieldTextDraftValue
@ -55,4 +66,6 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldSelectDraftValue ? FieldSelectDraftValue
: FieldValue extends FieldRelationValue : FieldValue extends FieldRelationValue
? FieldRelationDraftValue ? FieldRelationDraftValue
: never; : FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;

View File

@ -69,6 +69,12 @@ export type FieldRatingMetadata = {
fieldName: string; fieldName: string;
}; };
export type FieldAddressMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldRawJsonMetadata = { export type FieldRawJsonMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
@ -109,7 +115,8 @@ export type FieldMetadata =
| FieldRelationMetadata | FieldRelationMetadata
| FieldSelectMetadata | FieldSelectMetadata
| FieldTextMetadata | FieldTextMetadata
| FieldUuidMetadata; | FieldUuidMetadata
| FieldAddressMetadata;
export type FieldTextValue = string; export type FieldTextValue = string;
export type FieldUUidValue = string; export type FieldUUidValue = string;
@ -125,6 +132,16 @@ export type FieldCurrencyValue = {
amountMicros: number | null; amountMicros: number | null;
}; };
export type FieldFullNameValue = { firstName: string; lastName: string }; 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 FieldRatingValue = (typeof RATING_VALUES)[number];
export type FieldSelectValue = string | null; export type FieldSelectValue = string | null;

View File

@ -2,6 +2,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition'; import { FieldDefinition } from '../FieldDefinition';
import { import {
FieldAddressMetadata,
FieldBooleanMetadata, FieldBooleanMetadata,
FieldCurrencyMetadata, FieldCurrencyMetadata,
FieldDateTimeMetadata, FieldDateTimeMetadata,
@ -49,9 +50,11 @@ type AssertFieldMetadataFunction = <
? FieldTextMetadata ? FieldTextMetadata
: E extends 'UUID' : E extends 'UUID'
? FieldUuidMetadata ? FieldUuidMetadata
: E extends 'RAW_JSON' : E extends 'ADDRESS'
? FieldRawJsonMetadata ? FieldAddressMetadata
: never, : E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
>( >(
fieldType: E, fieldType: E,
fieldTypeGuard: ( fieldTypeGuard: (

View File

@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldAddressMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldAddress = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldAddressMetadata> => field.type === 'ADDRESS';

View File

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

View File

@ -1,5 +1,7 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; 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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
@ -68,6 +70,18 @@ export const isFieldValueEmpty = ({
return !isFieldLinkValue(fieldValue) || isValueEmpty(fieldValue?.url); 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( throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`, `Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
); );

View File

@ -71,6 +71,15 @@ export type FullNameFilter = {
lastName?: StringFilter; lastName?: StringFilter;
}; };
export type AddressFilter = {
addressStreet1?: StringFilter;
addressStreet2?: StringFilter;
addressCity?: StringFilter;
addressState?: StringFilter;
addressCountry?: StringFilter;
addressPostcode?: StringFilter;
};
export type LeafFilter = export type LeafFilter =
| UUIDFilter | UUIDFilter
| StringFilter | StringFilter
@ -80,6 +89,7 @@ export type LeafFilter =
| URLFilter | URLFilter
| FullNameFilter | FullNameFilter
| BooleanFilter | BooleanFilter
| AddressFilter
| undefined; | undefined;
export type AndObjectRecordFilter = { export type AndObjectRecordFilter = {

View File

@ -2,6 +2,7 @@ import { isObject } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { import {
AddressFilter,
AndObjectRecordFilter, AndObjectRecordFilter,
BooleanFilter, BooleanFilter,
CurrencyFilter, 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: { case FieldMetadataType.DateTime: {
return isMatchingDateFilter({ return isMatchingDateFilter({
dateFilter: filterValue as DateFilter, dateFilter: filterValue as DateFilter,

View File

@ -25,6 +25,18 @@ export const generateEmptyFieldValue = (
lastName: '', lastName: '',
}; };
} }
case FieldMetadataType.Address: {
return {
addressStreet1: '',
addressStreet2: '',
addressCity: '',
addressState: '',
addressCountry: '',
addressPostcode: '',
addressLat: null,
addressLng: null,
};
}
case FieldMetadataType.DateTime: { case FieldMetadataType.DateTime: {
return null; return null;
} }

View File

@ -8,6 +8,7 @@ import {
IconKey, IconKey,
IconLink, IconLink,
IconMail, IconMail,
IconMap,
IconNumbers, IconNumbers,
IconPhone, IconPhone,
IconRelationManyToMany, IconRelationManyToMany,
@ -101,4 +102,18 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
Icon: IconUser, Icon: IconUser,
defaultValue: { firstName: 'John', lastName: 'Doe' }, 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,
},
},
}; };

View File

@ -66,6 +66,7 @@ const previewableTypes = [
FieldMetadataType.Rating, FieldMetadataType.Rating,
FieldMetadataType.Relation, FieldMetadataType.Relation,
FieldMetadataType.Text, FieldMetadataType.Text,
FieldMetadataType.Address,
]; ];
export const SettingsDataModelFieldSettingsFormCard = ({ export const SettingsDataModelFieldSettingsFormCard = ({

View File

@ -1,8 +1,10 @@
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
export type IconComponent = FunctionComponent<{ export type IconComponentProps = {
className?: string; className?: string;
color?: string; color?: string;
size?: number; size?: number;
stroke?: number; stroke?: number;
}>; };
export type IconComponent = FunctionComponent<IconComponentProps>;

View File

@ -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<HTMLInputElement>(null);
const addressStreet2InputRef = useRef<HTMLInputElement>(null);
const addressCityInputRef = useRef<HTMLInputElement>(null);
const addressStateInputRef = useRef<HTMLInputElement>(null);
const addressPostCodeInputRef = useRef<HTMLInputElement>(null);
const inputRefs: {
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
} = {
addressStreet1: addressStreet1InputRef,
addressStreet2: addressStreet2InputRef,
addressCity: addressCityInputRef,
addressState: addressStateInputRef,
addressPostcode: addressPostCodeInputRef,
};
const [focusPosition, setFocusPosition] =
useState<keyof FieldAddressDraftValue>('addressStreet1');
const wrapperRef = useRef<HTMLDivElement>(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 (
<div ref={refs.setFloating} style={floatingStyles}>
<StyledAddressContainer ref={wrapperRef}>
<TextInput
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs['addressStreet1']}
label="ADDRESS 1"
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
disableHotkeys
/>
<TextInput
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs['addressStreet2']}
label="ADDRESS 2"
fullWidth
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
disableHotkeys
/>
<StyledHalfRowContainer>
<TextInput
value={internalValue.addressCity ?? ''}
ref={inputRefs['addressCity']}
label="CITY"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
disableHotkeys
/>
<TextInput
value={internalValue.addressState ?? ''}
ref={inputRefs['addressState']}
label="STATE"
fullWidth
onChange={getChangeHandler('addressState')}
onFocus={getFocusHandler('addressState')}
disableHotkeys
/>
</StyledHalfRowContainer>
<StyledHalfRowContainer>
<TextInput
value={internalValue.addressPostcode ?? ''}
ref={inputRefs['addressPostcode']}
label="POST CODE"
fullWidth
onChange={getChangeHandler('addressPostcode')}
onFocus={getFocusHandler('addressPostcode')}
disableHotkeys
/>
<CountrySelect
onChange={getChangeHandler('addressCountry')}
selectedCountryName={internalValue.addressCountry ?? ''}
/>
</StyledHalfRowContainer>
</StyledAddressContainer>
</div>
);
};

View File

@ -13,7 +13,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { StyledInput } from './TextInput'; import { StyledTextInput } from './TextInput';
const StyledContainer = styled.div` const StyledContainer = styled.div`
align-items: center; align-items: center;
@ -174,7 +174,7 @@ export const DoubleTextInput = ({
return ( return (
<StyledContainer ref={containerRef}> <StyledContainer ref={containerRef}>
<StyledInput <StyledTextInput
autoComplete="off" autoComplete="off"
autoFocus autoFocus
onFocus={() => setFocusPosition('left')} onFocus={() => setFocusPosition('left')}
@ -188,7 +188,7 @@ export const DoubleTextInput = ({
handleOnPaste(event) handleOnPaste(event)
} }
/> />
<StyledInput <StyledTextInput
autoComplete="off" autoComplete="off"
onFocus={() => setFocusPosition('right')} onFocus={() => setFocusPosition('right')}
ref={secondValueInputRef} ref={secondValueInputRef}

View File

@ -3,7 +3,7 @@ import ReactPhoneNumberInput from 'react-phone-number-input';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; 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'; import 'react-phone-number-input/style.css';
@ -102,7 +102,7 @@ export const PhoneInput = ({
onChange={handleChange} onChange={handleChange}
international={true} international={true}
withCountryCallingCode={true} withCountryCallingCode={true}
countrySelectComponent={CountryPickerDropdownButton} countrySelectComponent={PhoneCountryPickerDropdownButton}
/> />
</StyledContainer> </StyledContainer>
); );

View File

@ -4,7 +4,7 @@ import styled from '@emotion/styled';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle'; import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
export const StyledInput = styled.input` export const StyledTextInput = styled.input`
margin: 0; margin: 0;
${TEXT_INPUT_STYLE} ${TEXT_INPUT_STYLE}
width: 100%; width: 100%;
@ -60,7 +60,7 @@ export const TextInput = ({
}); });
return ( return (
<StyledInput <StyledTextInput
autoComplete="off" autoComplete="off"
ref={wrapperRef} ref={wrapperRef}
placeholder={placeholder} placeholder={placeholder}

View File

@ -1,7 +1,7 @@
import { ChangeEvent } from 'react'; import { ChangeEvent } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { StyledInput } from '@/ui/field/input/components/TextInput'; import { StyledTextInput as UIStyledTextInput } from '@/ui/field/input/components/TextInput';
import { ComputeNodeDimensions } from '@/ui/utilities/dimensions/components/ComputeNodeDimensions'; import { ComputeNodeDimensions } from '@/ui/utilities/dimensions/components/ComputeNodeDimensions';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -23,7 +23,7 @@ const StyledDoubleTextContainer = styled.div`
text-align: center; text-align: center;
`; `;
const StyledTextInput = styled(StyledInput)` const StyledTextInput = styled(UIStyledTextInput)`
margin: 0 ${({ theme }) => theme.spacing(0.5)}; margin: 0 ${({ theme }) => theme.spacing(0.5)};
padding: 0; padding: 0;
width: ${({ width }) => (width ? `${width}px` : 'auto')}; width: ${({ width }) => (width ? `${width}px` : 'auto')};

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; 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 { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope'; import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
@ -43,6 +44,7 @@ const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center; align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ disabled, theme }) => color: ${({ disabled, theme }) =>
disabled ? theme.font.color.tertiary : theme.font.color.primary}; disabled ? theme.font.color.tertiary : theme.font.color.primary};
@ -88,6 +90,8 @@ export const Select = <Value extends string | number | null>({
value, value,
withSearchInput, withSearchInput,
}: SelectProps<Value>) => { }: SelectProps<Value>) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
const theme = useTheme(); const theme = useTheme();
const [searchInputValue, setSearchInputValue] = useState(''); const [searchInputValue, setSearchInputValue] = useState('');
@ -109,6 +113,15 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown(dropdownId); const { closeDropdown } = useDropdown(dropdownId);
const { useListenClickOutside } = useClickOutsideListener(dropdownId);
useListenClickOutside({
refs: [selectContainerRef],
callback: () => {
closeDropdown();
},
});
const selectControl = ( const selectControl = (
<StyledControlContainer disabled={isDisabled}> <StyledControlContainer disabled={isDisabled}>
<StyledControlLabel> <StyledControlLabel>
@ -133,6 +146,7 @@ export const Select = <Value extends string | number | null>({
fullWidth={fullWidth} fullWidth={fullWidth}
tabIndex={0} tabIndex={0}
onBlur={onBlur} onBlur={onBlur}
ref={selectContainerRef}
> >
{!!label && <StyledLabel>{label}</StyledLabel>} {!!label && <StyledLabel>{label}</StyledLabel>}
{isDisabled ? ( {isDisabled ? (

View File

@ -20,20 +20,6 @@ import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { InputHotkeyScope } from '../types/InputHotkeyScope'; import { InputHotkeyScope } from '../types/InputHotkeyScope';
export type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
};
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>` const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
@ -110,6 +96,21 @@ const StyledTrailingIcon = styled.div`
const INPUT_TYPE_PASSWORD = 'password'; const INPUT_TYPE_PASSWORD = 'password';
export type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: () => void;
};
const TextInputComponent = ( const TextInputComponent = (
{ {
className, className,
@ -163,6 +164,7 @@ const TextInputComponent = (
inputRef.current?.blur(); inputRef.current?.blur();
}, },
InputHotkeyScope.TextInput, InputHotkeyScope.TextInput,
{ enabled: !disableHotkeys },
); );
const [passwordVisible, setPasswordVisible] = useState(false); const [passwordVisible, setPasswordVisible] = useState(false);

View File

@ -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<string>[] = useMemo(() => {
return countries.map<SelectOption<string>>(({ countryName, Flag }) => ({
label: countryName,
value: countryName,
Icon: (props: IconComponentProps) =>
Flag({ width: props.size, height: props.size }), // TODO : improve this ?
}));
}, [countries]);
return (
<Select
fullWidth
dropdownId={SELECT_COUNTRY_DROPDOWN_ID}
options={options}
label="COUNTRY"
withSearchInput
onChange={onChange}
value={selectedCountryName}
/>
);
};

View File

@ -0,0 +1 @@
export const SELECT_COUNTRY_DROPDOWN_ID = 'select-country-picker';

View File

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { hasFlag } from 'country-flag-icons';
import * as Flags from 'country-flag-icons/react/3x2';
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
import { Country } from '@/ui/input/components/internal/types/Country';
export const useCountries = () => {
return useMemo<Country[]>(() => {
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
const countryCodes = getCountries();
return countryCodes.reduce<Country[]>((result, countryCode) => {
const countryName = regionNamesInEnglish.of(countryCode);
if (!countryName) return result;
if (!hasFlag(countryCode)) return result;
const Flag = Flags[countryCode];
const callingCode = getCountryCallingCode(countryCode);
result.push({
countryCode,
countryName,
callingCode,
Flag,
});
return result;
}, []);
}, []);
};

View File

@ -1,19 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { getCountries, getCountryCallingCode } from 'react-phone-number-input';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { hasFlag } from 'country-flag-icons';
import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js';
import { IconChevronDown, IconWorld } from '@/ui/display/icon'; import { IconChevronDown, IconWorld } from '@/ui/display/icon';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { Country } from '@/ui/input/components/internal/types/Country';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope'; import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope';
import { CountryPickerDropdownSelect } from './CountryPickerDropdownSelect'; import { PhoneCountryPickerDropdownSelect } from './PhoneCountryPickerDropdownSelect';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
@ -57,14 +55,7 @@ const StyledIconContainer = styled.div`
} }
`; `;
export type Country = { export const PhoneCountryPickerDropdownButton = ({
countryCode: string;
countryName: string;
callingCode: CountryCallingCode;
Flag: Flags.FlagComponent;
};
export const CountryPickerDropdownButton = ({
value, value,
onChange, onChange,
}: { }: {
@ -82,34 +73,7 @@ export const CountryPickerDropdownButton = ({
closeDropdown(); closeDropdown();
}; };
const countries = useMemo<Country[]>(() => { const countries = useCountries();
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
const countryCodes = getCountries();
return countryCodes.reduce<Country[]>((result, countryCode) => {
const countryName = regionNamesInEnglish.of(countryCode);
if (!countryName) return result;
if (!hasFlag(countryCode)) return result;
const Flag = Flags[countryCode];
const callingCode = getCountryCallingCode(countryCode);
result.push({
countryCode,
countryName,
callingCode,
Flag,
});
return result;
}, []);
}, []);
useEffect(() => { useEffect(() => {
const country = countries.find(({ countryCode }) => countryCode === value); const country = countries.find(({ countryCode }) => countryCode === value);
@ -132,7 +96,7 @@ export const CountryPickerDropdownButton = ({
</StyledDropdownButtonContainer> </StyledDropdownButtonContainer>
} }
dropdownComponents={ dropdownComponents={
<CountryPickerDropdownSelect <PhoneCountryPickerDropdownSelect
countries={countries} countries={countries}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onChange={handleChange} onChange={handleChange}

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Country } from '@/ui/input/components/internal/types/Country';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
@ -8,8 +9,6 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar'; import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Country } from './CountryPickerDropdownButton';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
const StyledIconContainer = styled.div` const StyledIconContainer = styled.div`
@ -27,7 +26,7 @@ const StyledIconContainer = styled.div`
} }
`; `;
export const CountryPickerDropdownSelect = ({ export const PhoneCountryPickerDropdownSelect = ({
countries, countries,
selectedCountry, selectedCountry,
onChange, onChange,

View File

@ -0,0 +1,9 @@
import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js';
export type Country = {
countryCode: string;
countryName: string;
callingCode: CountryCallingCode;
Flag: Flags.FlagComponent;
};

View File

@ -29,7 +29,6 @@ export const seedCompanies = async (
'id', 'id',
'name', 'name',
'domainName', 'domainName',
'address',
'position', 'position',
]) ])
.orIgnore() .orIgnore()
@ -38,91 +37,78 @@ export const seedCompanies = async (
id: DEV_SEED_COMPANY_IDS.LINKEDIN, id: DEV_SEED_COMPANY_IDS.LINKEDIN,
name: 'Linkedin', name: 'Linkedin',
domainName: 'linkedin.com', domainName: 'linkedin.com',
address: '',
position: 1, position: 1,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.FACEBOOK, id: DEV_SEED_COMPANY_IDS.FACEBOOK,
name: 'Facebook', name: 'Facebook',
domainName: 'facebook.com', domainName: 'facebook.com',
address: '',
position: 2, position: 2,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.QONTO, id: DEV_SEED_COMPANY_IDS.QONTO,
name: 'Qonto', name: 'Qonto',
domainName: 'qonto.com', domainName: 'qonto.com',
address: '',
position: 3, position: 3,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.MICROSOFT, id: DEV_SEED_COMPANY_IDS.MICROSOFT,
name: 'Microsoft', name: 'Microsoft',
domainName: 'microsoft.com', domainName: 'microsoft.com',
address: '',
position: 4, position: 4,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.AIRBNB, id: DEV_SEED_COMPANY_IDS.AIRBNB,
name: 'Airbnb', name: 'Airbnb',
domainName: 'airbnb.com', domainName: 'airbnb.com',
address: '',
position: 5, position: 5,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.GOOGLE, id: DEV_SEED_COMPANY_IDS.GOOGLE,
name: 'Google', name: 'Google',
domainName: 'google.com', domainName: 'google.com',
address: '',
position: 6, position: 6,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.NETFLIX, id: DEV_SEED_COMPANY_IDS.NETFLIX,
name: 'Netflix', name: 'Netflix',
domainName: 'netflix.com', domainName: 'netflix.com',
address: '',
position: 7, position: 7,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.LIBEO, id: DEV_SEED_COMPANY_IDS.LIBEO,
name: 'Libeo', name: 'Libeo',
domainName: 'libeo.io', domainName: 'libeo.io',
address: '',
position: 8, position: 8,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.CLAAP, id: DEV_SEED_COMPANY_IDS.CLAAP,
name: 'Claap', name: 'Claap',
domainName: 'claap.io', domainName: 'claap.io',
address: '',
position: 9, position: 9,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.HASURA, id: DEV_SEED_COMPANY_IDS.HASURA,
name: 'Hasura', name: 'Hasura',
domainName: 'hasura.io', domainName: 'hasura.io',
address: '',
position: 10, position: 10,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.WEWORK, id: DEV_SEED_COMPANY_IDS.WEWORK,
name: 'Wework', name: 'Wework',
domainName: 'wework.com', domainName: 'wework.com',
address: '',
position: 11, position: 11,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.SAMSUNG, id: DEV_SEED_COMPANY_IDS.SAMSUNG,
name: 'Samsung', name: 'Samsung',
domainName: 'samsung.com', domainName: 'samsung.com',
address: '',
position: 12, position: 12,
}, },
{ {
id: DEV_SEED_COMPANY_IDS.ALGOLIA, id: DEV_SEED_COMPANY_IDS.ALGOLIA,
name: 'Algolia', name: 'Algolia',
domainName: 'algolia.com', domainName: 'algolia.com',
address: '',
position: 13, position: 13,
}, },
]) ])

View File

@ -9,6 +9,7 @@ import { fullNameObjectDefinition } from 'src/engine/metadata-modules/field-meta
import { currencyObjectDefinition } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; import { currencyObjectDefinition } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { linkObjectDefinition } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; import { linkObjectDefinition } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory'; import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory';
import { addressObjectDefinition } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
import { TypeDefinitionsStorage } from './storages/type-definitions.storage'; import { TypeDefinitionsStorage } from './storages/type-definitions.storage';
import { import {
@ -68,7 +69,8 @@ export class TypeDefinitionsGenerator {
currencyObjectDefinition, currencyObjectDefinition,
linkObjectDefinition, linkObjectDefinition,
fullNameObjectDefinition, fullNameObjectDefinition,
]; addressObjectDefinition,
] satisfies ObjectMetadataInterface[];
this.logger.log( this.logger.log(
`Generating staticObjects: [${staticObjectMetadataCollection `Generating staticObjects: [${staticObjectMetadataCollection

View File

@ -104,5 +104,19 @@ export const mapFieldMetadataToGraphqlQuery = (
lastName lastName
} }
`; `;
} else if (fieldType === FieldMetadataType.ADDRESS) {
return `
${field.name}
{
addressStreet1
addressStreet2
addressCity
addressPostcode
addressState
addressCountry
addressLat
addressLng
}
`;
} }
}; };

View File

@ -57,6 +57,7 @@ const getSchemaComponentsProperties = (
case FieldMetadataType.LINK: case FieldMetadataType.LINK:
case FieldMetadataType.CURRENCY: case FieldMetadataType.CURRENCY:
case FieldMetadataType.FULL_NAME: case FieldMetadataType.FULL_NAME:
case FieldMetadataType.ADDRESS:
itemProperty = { itemProperty = {
type: 'object', type: 'object',
properties: Object.keys(field.targetColumnMap).reduce( properties: Object.keys(field.targetColumnMap).reduce(

View File

@ -0,0 +1,195 @@
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine/metadata-modules/field-metadata/utils/generate-target-column-map.util';
export const addressFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
const inferredFieldMetadata = fieldMetadata as
| FieldMetadataInterface<FieldMetadataType.ADDRESS>
| undefined;
const targetColumnMap = inferredFieldMetadata
? generateTargetColumnMap(
inferredFieldMetadata.type,
inferredFieldMetadata.isCustom ?? false,
inferredFieldMetadata.name,
)
: {
addressStreet1: 'addressStreet1',
addressStreet2: 'addressStreet2',
addressCity: 'addressCity',
addressPostcode: 'addressPostcode',
addressState: 'addressState',
addressCountry: 'addressCountry',
addressLat: 'addressLat',
addressLng: 'addressLng',
};
return [
{
id: 'addressStreet1',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressStreet1',
label: 'Address',
targetColumnMap: {
value: targetColumnMap.addressStreet1,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressStreet1 ?? undefined,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressStreet2',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressStreet2',
label: 'Address 2',
targetColumnMap: {
value: targetColumnMap.addressStreet2,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressStreet2 ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressCity',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressCity',
label: 'City',
targetColumnMap: {
value: targetColumnMap.addressCity,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressCity ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressPostcode',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressPostcode',
label: 'Postcode',
targetColumnMap: {
value: targetColumnMap.addressPostcode,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressPostcode ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressState',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressState',
label: 'State',
targetColumnMap: {
value: targetColumnMap.addressState,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressState ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressCountry',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressCountry',
label: 'Country',
targetColumnMap: {
value: targetColumnMap.addressCountry,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressCountry ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'addressLat',
type: FieldMetadataType.NUMBER,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressLat',
label: 'Latitude',
targetColumnMap: {
value: targetColumnMap.addressLat,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressLat ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.NUMBER>,
{
id: 'addressLng',
type: FieldMetadataType.NUMBER,
objectMetadataId: FieldMetadataType.ADDRESS.toString(),
name: 'addressLng',
label: 'Longitude',
targetColumnMap: {
value: targetColumnMap.addressLng,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue:
inferredFieldMetadata.defaultValue?.addressLng ?? null,
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.NUMBER>,
];
};
export const addressObjectDefinition = {
id: FieldMetadataType.ADDRESS.toString(),
nameSingular: 'address',
namePlural: 'address',
labelSingular: 'Address',
labelPlural: 'Addresses',
targetTableName: '',
fields: addressFields(),
fromRelations: [],
toRelations: [],
isActive: true,
isSystem: true,
isCustom: false,
isRemote: false,
} satisfies ObjectMetadataInterface;
export type AddressMetadata = {
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressZipCode: string;
addressCountry: string;
addressLat: number;
addressLng: number;
};

View File

@ -4,6 +4,7 @@ import { currencyFields } from 'src/engine/metadata-modules/field-metadata/compo
import { fullNameFields } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { fullNameFields } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { linkFields } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; import { linkFields } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { addressFields } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
export type CompositeFieldsDefinitionFunction = ( export type CompositeFieldsDefinitionFunction = (
fieldMetadata?: FieldMetadataInterface, fieldMetadata?: FieldMetadataInterface,
@ -16,4 +17,5 @@ export const compositeDefinitions = new Map<
[FieldMetadataType.LINK, linkFields], [FieldMetadataType.LINK, linkFields],
[FieldMetadataType.CURRENCY, currencyFields], [FieldMetadataType.CURRENCY, currencyFields],
[FieldMetadataType.FULL_NAME, fullNameFields], [FieldMetadataType.FULL_NAME, fullNameFields],
[FieldMetadataType.ADDRESS, addressFields],
]); ]);

View File

@ -6,6 +6,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsNumberString, IsNumberString,
IsString,
Matches, Matches,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
@ -98,3 +99,36 @@ export class FieldMetadataDefaultValueNowFunction {
@IsNotEmpty() @IsNotEmpty()
value: typeof fieldMetadataDefaultValueFunctionName.NOW; value: typeof fieldMetadataDefaultValueFunctionName.NOW;
} }
export class FieldMetadataDefaultValueAddress {
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet1: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet2: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCity: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressPostcode: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressState: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCountry: string | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLat: number | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLng: number | null;
}

View File

@ -36,6 +36,8 @@ export enum FieldMetadataType {
MULTI_SELECT = 'MULTI_SELECT', MULTI_SELECT = 'MULTI_SELECT',
RELATION = 'RELATION', RELATION = 'RELATION',
POSITION = 'POSITION', POSITION = 'POSITION',
ADDRESS = 'ADDRESS',
JSON = 'JSON',
RAW_JSON = 'RAW_JSON', RAW_JSON = 'RAW_JSON',
} }

View File

@ -1,4 +1,5 @@
import { import {
FieldMetadataDefaultValueAddress,
FieldMetadataDefaultValueBoolean, FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency, FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime, FieldMetadataDefaultValueDateTime,
@ -35,6 +36,7 @@ type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.LINK]: FieldMetadataDefaultValueLink; [FieldMetadataType.LINK]: FieldMetadataDefaultValueLink;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency; [FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName; [FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName;
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress;
[FieldMetadataType.RATING]: FieldMetadataDefaultValueString; [FieldMetadataType.RATING]: FieldMetadataDefaultValueString;
[FieldMetadataType.SELECT]: FieldMetadataDefaultValueString; [FieldMetadataType.SELECT]: FieldMetadataDefaultValueString;
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray; [FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray;

View File

@ -19,6 +19,17 @@ export interface FieldMetadataTargetColumnMapFullName {
lastName: string; lastName: string;
} }
export type FieldMetadataTargetColumnMapAddress = {
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressZipCode: string;
addressCountry: string;
addressLat: number;
addressLng: number;
};
type AllFieldMetadataTypes = { type AllFieldMetadataTypes = {
[key: string]: string; [key: string]: string;
}; };
@ -27,6 +38,7 @@ type FieldMetadataTypeMapping = {
[FieldMetadataType.LINK]: FieldMetadataTargetColumnMapLink; [FieldMetadataType.LINK]: FieldMetadataTargetColumnMapLink;
[FieldMetadataType.CURRENCY]: FieldMetadataTargetColumnMapCurrency; [FieldMetadataType.CURRENCY]: FieldMetadataTargetColumnMapCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataTargetColumnMapFullName; [FieldMetadataType.FULL_NAME]: FieldMetadataTargetColumnMapFullName;
[FieldMetadataType.ADDRESS]: FieldMetadataTargetColumnMapAddress;
}; };
type TypeByFieldMetadata<T extends FieldMetadataType | 'default'> = [ type TypeByFieldMetadata<T extends FieldMetadataType | 'default'> = [

View File

@ -15,6 +15,17 @@ export function generateDefaultValue(
firstName: "''", firstName: "''",
lastName: "''", lastName: "''",
}; };
case FieldMetadataType.ADDRESS:
return {
addressStreet1: "''",
addressStreet2: "''",
addressCity: "''",
addressState: "''",
addressCountry: "''",
addressPostcode: "''",
addressLat: null,
addressLng: null,
};
case FieldMetadataType.LINK: case FieldMetadataType.LINK:
return { return {
url: "''", url: "''",

View File

@ -54,6 +54,17 @@ export function generateTargetColumnMap(
firstName: `${columnName}FirstName`, firstName: `${columnName}FirstName`,
lastName: `${columnName}LastName`, lastName: `${columnName}LastName`,
}; };
case FieldMetadataType.ADDRESS:
return {
addressStreet1: `${columnName}AddressStreet1`,
addressStreet2: `${columnName}AddressStreet2`,
addressCity: `${columnName}AddressCity`,
addressPostcode: `${columnName}AddressPostcode`,
addressState: `${columnName}AddressState`,
addressCountry: `${columnName}AddressCountry`,
addressLat: `${columnName}AddressLat`,
addressLng: `${columnName}AddressLng`,
};
case FieldMetadataType.RELATION: case FieldMetadataType.RELATION:
return {}; return {};
default: default:

View File

@ -9,6 +9,7 @@ export const isCompositeFieldMetadataType = (
return ( return (
type === FieldMetadataType.LINK || type === FieldMetadataType.LINK ||
type === FieldMetadataType.CURRENCY || type === FieldMetadataType.CURRENCY ||
type === FieldMetadataType.FULL_NAME type === FieldMetadataType.FULL_NAME ||
type === FieldMetadataType.ADDRESS
); );
}; };

View File

@ -8,6 +8,7 @@ import {
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { import {
FieldMetadataDefaultValueAddress,
FieldMetadataDefaultValueBoolean, FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency, FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime, FieldMetadataDefaultValueDateTime,
@ -44,6 +45,7 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.RATING]: [FieldMetadataDefaultValueString], [FieldMetadataType.RATING]: [FieldMetadataDefaultValueString],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString], [FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray], [FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
[FieldMetadataType.ADDRESS]: [FieldMetadataDefaultValueAddress],
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson], [FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
}; };

View File

@ -12,7 +12,7 @@ export const companyPrefillDemoData = async (
.into(`${schemaName}.company`, [ .into(`${schemaName}.company`, [
'name', 'name',
'domainName', 'domainName',
'address', 'addressAddressCity',
'employees', 'employees',
'linkedinLinkUrl', 'linkedinLinkUrl',
'position', 'position',

View File

@ -51,7 +51,7 @@ export class CompanyObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({ @FieldMetadata({
standardId: companyStandardFieldIds.address, standardId: companyStandardFieldIds.address,
type: FieldMetadataType.TEXT, type: FieldMetadataType.ADDRESS,
label: 'Address', label: 'Address',
description: 'The company address', description: 'The company address',
icon: 'IconMap', icon: 'IconMap',

View File

@ -89,6 +89,57 @@ const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => {
}; };
return [amountMicros, currencyCode]; return [amountMicros, currencyCode];
} }
case FieldMetadataType.ADDRESS: {
const address1: NodeField = {
type: 'TEXT',
name: 'addressStreet1',
label: 'Address',
description: 'Address',
isNullable: true,
defaultValue: null,
};
const address2: NodeField = {
type: 'TEXT',
name: 'addressStreet2',
label: 'Address 2',
description: 'Address 2',
isNullable: true,
defaultValue: null,
};
const city: NodeField = {
type: 'TEXT',
name: 'addressCity',
label: 'City',
description: 'City',
isNullable: true,
defaultValue: null,
};
const state: NodeField = {
type: 'TEXT',
name: 'addressState',
label: 'State',
description: 'State',
isNullable: true,
defaultValue: null,
};
const postalCode: NodeField = {
type: 'TEXT',
name: 'addressPostalCode',
label: 'Postal Code',
description: 'Postal Code',
isNullable: true,
defaultValue: null,
};
const country: NodeField = {
type: 'TEXT',
name: 'addressCountry',
label: 'Country',
description: 'Country',
isNullable: true,
defaultValue: null,
};
return [address1, address2, city, state, postalCode, country];
}
default: default:
throw new Error(`Unknown nodeField type: ${nodeField.type}`); throw new Error(`Unknown nodeField type: ${nodeField.type}`);
} }
@ -109,6 +160,7 @@ export const computeInputFields = (
case FieldMetadataType.FULL_NAME: case FieldMetadataType.FULL_NAME:
case FieldMetadataType.LINK: case FieldMetadataType.LINK:
case FieldMetadataType.CURRENCY: case FieldMetadataType.CURRENCY:
case FieldMetadataType.ADDRESS:
for (const subNodeField of get_subfieldsFromField(nodeField)) { for (const subNodeField of get_subfieldsFromField(nodeField)) {
const field = { const field = {
key: `${nodeField.name}__${subNodeField.name}`, key: `${nodeField.name}__${subNodeField.name}`,

View File

@ -47,6 +47,7 @@ export enum FieldMetadataType {
SELECT = 'SELECT', SELECT = 'SELECT',
MULTI_SELECT = 'MULTI_SELECT', MULTI_SELECT = 'MULTI_SELECT',
RELATION = 'RELATION', RELATION = 'RELATION',
ADDRESS = 'ADDRESS',
} }
export type Schema = { export type Schema = {