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

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

View File

@ -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 = () => {
<PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />
) : null;
};

View File

@ -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 = ({
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<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 { 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(

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 {
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> = FieldValue extends FieldTextValue
? FieldTextDraftValue
@ -55,4 +66,6 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldSelectDraftValue
: FieldValue extends FieldRelationValue
? FieldRelationDraftValue
: never;
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;

View File

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

View File

@ -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: (

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 { 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}}`,
);

View File

@ -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 = {

View File

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

View File

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