feat: add Links field type (#5176)
Closes #5113 --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@ -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) ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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}}`,
|
||||
);
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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, '')
|
||||
: '';
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user