feat: add Links field type (#5176)

Closes #5113

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs
2024-05-01 11:56:14 +02:00
committed by GitHub
parent e0ece3c917
commit 8853226d17
42 changed files with 465 additions and 61 deletions

View File

@ -1,5 +1,8 @@
import { useContext } from 'react';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@ -62,6 +65,8 @@ export const FieldDisplay = () => {
<NumberFieldDisplay />
) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay />
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldDisplay />
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (

View File

@ -3,12 +3,14 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@ -131,6 +133,14 @@ export const FieldInput = ({
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}

View File

@ -7,6 +7,8 @@ import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDa
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
@ -69,6 +71,9 @@ export const usePersistField = () => {
const fieldIsLink =
isFieldLink(fieldDefinition) && isFieldLinkValue(valueToPersist);
const fieldIsLinks =
isFieldLinks(fieldDefinition) && isFieldLinksValue(valueToPersist);
const fieldIsBoolean =
isFieldBoolean(fieldDefinition) &&
isFieldBooleanValue(valueToPersist);
@ -116,6 +121,7 @@ export const usePersistField = () => {
fieldIsDate ||
fieldIsPhone ||
fieldIsLink ||
fieldIsLinks ||
fieldIsCurrency ||
fieldIsFullName ||
fieldIsSelect ||
@ -123,7 +129,7 @@ export const usePersistField = () => {
fieldIsAddress ||
fieldIsRawJson;
if (isValuePersistable === true) {
if (isValuePersistable) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
recordStoreFamilySelector({ recordId: entityId, fieldName }),

View File

@ -0,0 +1,8 @@
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
export const LinksFieldDisplay = () => {
const { fieldValue } = useLinksField();
return <LinksDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,53 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { linksSchema } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
export const useLinksField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.Links, isFieldLinks, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldLinksValue>(
recordStoreFamilySelector({
recordId: entityId,
fieldName: fieldName,
}),
);
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldLinksValue>(`${entityId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
const persistField = usePersistField();
const persistLinksField = (nextValue: FieldLinksValue) => {
try {
persistField(linksSchema.parse(nextValue));
} catch {
return;
}
};
return {
fieldDefinition,
fieldValue,
draftValue,
setDraftValue,
setFieldValue,
hotkeyScope,
persistLinksField,
};
};

View File

@ -0,0 +1,93 @@
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay';
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputEvent } from './DateTimeFieldInput';
export type LinksFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const LinksFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: LinksFieldInputProps) => {
const { draftValue, setDraftValue, hotkeyScope, persistLinksField } =
useLinksField();
const handleEnter = (url: string) => {
onEnter?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
}),
);
};
const handleEscape = (url: string) => {
onEscape?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
}),
);
};
const handleClickOutside = (event: MouseEvent | TouchEvent, url: string) => {
onClickOutside?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
}),
);
};
const handleTab = (url: string) => {
onTab?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
}),
);
};
const handleShiftTab = (url: string) => {
onShiftTab?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
}),
);
};
const handleChange = (url: string) => {
setDraftValue({
primaryLinkUrl: url,
primaryLinkLabel: '',
});
};
return (
<FieldInputOverlay>
<TextInput
value={draftValue?.primaryLinkUrl ?? ''}
autoFocus
placeholder="Links"
hotkeyScope={hotkeyScope}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onTab={handleTab}
onShiftTab={handleShiftTab}
onChange={handleChange}
/>
</FieldInputOverlay>
);
};

View File

@ -6,6 +6,7 @@ import {
FieldDateTimeValue,
FieldEmailValue,
FieldFullNameValue,
FieldLinksValue,
FieldLinkValue,
FieldMultiSelectValue,
FieldNumberValue,
@ -26,6 +27,11 @@ export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string;
export type FieldLinkDraftValue = { url: string; label: string };
export type FieldLinksDraftValue = {
primaryLinkLabel: string;
primaryLinkUrl: string;
secondaryLinks?: string | null;
};
export type FieldCurrencyDraftValue = {
currencyCode: CurrencyCode;
amount: string;
@ -58,18 +64,20 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldEmailDraftValue
: FieldValue extends FieldLinkValue
? FieldLinkDraftValue
: FieldValue extends FieldCurrencyValue
? FieldCurrencyDraftValue
: FieldValue extends FieldFullNameValue
? FieldFullNameDraftValue
: FieldValue extends FieldRatingValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationValue
? FieldRelationDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;
: FieldValue extends FieldLinksValue
? FieldLinksDraftValue
: FieldValue extends FieldCurrencyValue
? FieldCurrencyDraftValue
: FieldValue extends FieldFullNameValue
? FieldFullNameDraftValue
: FieldValue extends FieldRatingValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationValue
? FieldRelationDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;

View File

@ -45,6 +45,11 @@ export type FieldLinkMetadata = {
fieldName: string;
};
export type FieldLinksMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldCurrencyMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
@ -143,6 +148,11 @@ export type FieldBooleanValue = boolean;
export type FieldPhoneValue = string;
export type FieldEmailValue = string;
export type FieldLinkValue = { url: string; label: string };
export type FieldLinksValue = {
primaryLinkLabel: string;
primaryLinkUrl: string;
secondaryLinks?: string | null;
};
export type FieldCurrencyValue = {
currencyCode: CurrencyCode;
amountMicros: number | null;

View File

@ -10,6 +10,7 @@ import {
FieldEmailMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
FieldLinksMetadata,
FieldMetadata,
FieldMultiSelectMetadata,
FieldNumberMetadata,
@ -44,23 +45,25 @@ type AssertFieldMetadataFunction = <
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PROBABILITY'
? FieldRatingMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
: E extends 'LINKS'
? FieldLinksMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PROBABILITY'
? FieldRatingMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (

View File

@ -7,7 +7,6 @@ const linkSchema = z.object({
label: z.string(),
});
// TODO: add zod
export const isFieldLinkValue = (
fieldValue: unknown,
): fieldValue is FieldLinkValue => linkSchema.safeParse(fieldValue).success;

View File

@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldLinksMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldLinks = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldLinksMetadata> =>
field.type === FieldMetadataType.Links;

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
import { FieldLinksValue } from '../FieldMetadata';
export const linksSchema = z.object({
primaryLinkLabel: z.string(),
primaryLinkUrl: absoluteUrlSchema,
secondaryLinks: z.string().optional().nullable(),
}) satisfies z.ZodType<FieldLinksValue>;
export const isFieldLinksValue = (
fieldValue: unknown,
): fieldValue is FieldLinksValue => linksSchema.safeParse(fieldValue).success;

View File

@ -11,6 +11,8 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
@ -95,6 +97,12 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldLinks(fieldDefinition)) {
return (
!isFieldLinksValue(fieldValue) || isValueEmpty(fieldValue.primaryLinkUrl)
);
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);

View File

@ -1,7 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from '~/generated/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const generateEmptyFieldValue = (
fieldMetadataItem: FieldMetadataItem,
@ -18,6 +18,9 @@ export const generateEmptyFieldValue = (
url: '',
};
}
case FieldMetadataType.Links: {
return { primaryLinkUrl: '', primaryLinkLabel: '' };
}
case FieldMetadataType.FullName: {
return {
firstName: '',

View File

@ -1,13 +0,0 @@
import { z } from 'zod';
export const sanitizeLink = (url: string) =>
getUrlHostName(url) || getUrlHostName(`https://${url}`);
const getUrlHostName = (url: string) => {
const urlSchema = z.string().url();
const validation = urlSchema.safeParse(url);
return validation.success
? new URL(validation.data).hostname.replace(/^www\./i, '')
: '';
};

View File

@ -4,9 +4,9 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeLink } from '@/object-record/utils/sanitizeLinkRecordInput';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { getUrlHostName } from '~/utils/url/getUrlHostName';
export const sanitizeRecordInput = ({
objectMetadataItem,
@ -54,6 +54,6 @@ export const sanitizeRecordInput = ({
return {
...filteredResultRecord,
domainName: sanitizeLink(filteredResultRecord.domainName),
domainName: getUrlHostName(filteredResultRecord.domainName),
};
};