Consider null values as empty values for link fields (#12113)
This pull request introduces changes to improve handling of nullable values in link-related data structures and simplifies field value generation logic. Key updates include adjustments to type definitions, utility functions, and component logic to support `null` values for links, along with the removal of the `generateDefaultFieldValue` function in favor of `generateEmptyFieldValue`. There will be a few more follow-up Pull Requests. --- Closes https://github.com/twentyhq/twenty/issues/11844
This commit is contained in:
committed by
GitHub
parent
7461b7ac58
commit
c29ed1c0c9
@ -15,7 +15,7 @@ type FormTextFieldInputProps = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
defaultValue: string | undefined;
|
defaultValue: string | undefined | null;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
|
|||||||
@ -13,7 +13,7 @@ type UseTextVariableEditorProps = {
|
|||||||
placeholder: string | undefined;
|
placeholder: string | undefined;
|
||||||
multiline: boolean | undefined;
|
multiline: boolean | undefined;
|
||||||
readonly: boolean | undefined;
|
readonly: boolean | undefined;
|
||||||
defaultValue: string | undefined;
|
defaultValue: string | undefined | null;
|
||||||
onUpdate: (editor: Editor) => void;
|
onUpdate: (editor: Editor) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
|
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
|
||||||
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
|
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
|
||||||
|
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks';
|
||||||
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
|
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
|
||||||
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
|
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
|
||||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { absoluteUrlSchema, isDefined } from 'twenty-shared/utils';
|
import { absoluteUrlSchema } from 'twenty-shared/utils';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
import { MultiItemFieldInput } from './MultiItemFieldInput';
|
import { MultiItemFieldInput } from './MultiItemFieldInput';
|
||||||
|
|
||||||
@ -19,26 +20,13 @@ export const LinksFieldInput = ({
|
|||||||
}: LinksFieldInputProps) => {
|
}: LinksFieldInputProps) => {
|
||||||
const { persistLinksField, fieldValue, fieldDefinition } = useLinksField();
|
const { persistLinksField, fieldValue, fieldDefinition } = useLinksField();
|
||||||
|
|
||||||
const links = useMemo<{ url: string; label: string }[]>(
|
const links = useMemo<{ url: string; label: string | null }[]>(
|
||||||
() =>
|
() => getFieldLinkDefinedLinks(fieldValue),
|
||||||
[
|
[fieldValue],
|
||||||
fieldValue.primaryLinkUrl
|
|
||||||
? {
|
|
||||||
url: fieldValue.primaryLinkUrl,
|
|
||||||
label: fieldValue.primaryLinkLabel,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
...(fieldValue.secondaryLinks ?? []),
|
|
||||||
].filter(isDefined),
|
|
||||||
[
|
|
||||||
fieldValue.primaryLinkLabel,
|
|
||||||
fieldValue.primaryLinkUrl,
|
|
||||||
fieldValue.secondaryLinks,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePersistLinks = (
|
const handlePersistLinks = (
|
||||||
updatedLinks: { url: string; label: string }[],
|
updatedLinks: { url: string | null; label: string | null }[],
|
||||||
) => {
|
) => {
|
||||||
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
|
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
|
||||||
persistLinksField({
|
persistLinksField({
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
|
|||||||
type LinksFieldMenuItemProps = {
|
type LinksFieldMenuItemProps = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
label: string;
|
label: string | null;
|
||||||
|
url: string;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSetAsPrimary?: () => void;
|
onSetAsPrimary?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
url: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinksFieldMenuItem = ({
|
export const LinksFieldMenuItem = ({
|
||||||
|
|||||||
@ -0,0 +1,162 @@
|
|||||||
|
import { getFieldLinkDefinedLinks } from '../getFieldLinkDefinedLinks';
|
||||||
|
|
||||||
|
describe('getFieldLinkDefinedLinks', () => {
|
||||||
|
describe('Primary link', () => {
|
||||||
|
it('should not return primary link when primaryLinkUrl is null', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: null,
|
||||||
|
primaryLinkLabel: 'Twenty',
|
||||||
|
secondaryLinks: [],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return primary link when primaryLinkUrl is empty string', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: 'Twenty',
|
||||||
|
secondaryLinks: [],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return primary link when primaryLinkUrl is defined but primaryLinkLabel is null', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: 'https://twenty.com',
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: [],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
url: 'https://twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Secondary links', () => {
|
||||||
|
it('should handle null secondaryLinks', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: null,
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out secondary links with null url', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: null,
|
||||||
|
label: 'Twenty',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: 'Documentation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: 'Documentation',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out secondary links with empty url', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: '',
|
||||||
|
label: 'Twenty',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: 'Documentation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: 'Documentation',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep secondary links with null label if url is defined', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: 'https://twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
url: 'https://twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly combine primary and secondary links with edge cases', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: 'https://twenty.com',
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: '',
|
||||||
|
label: 'Invalid Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: null,
|
||||||
|
label: 'Another Invalid Link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
url: 'https://twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty secondaryLinks array', () => {
|
||||||
|
expect(
|
||||||
|
getFieldLinkDefinedLinks({
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
|
||||||
|
return [
|
||||||
|
isNonEmptyString(fieldValue.primaryLinkUrl)
|
||||||
|
? {
|
||||||
|
url: fieldValue.primaryLinkUrl,
|
||||||
|
label: fieldValue.primaryLinkLabel,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...(fieldValue.secondaryLinks ?? []),
|
||||||
|
]
|
||||||
|
.filter(isDefined)
|
||||||
|
.map((link) => {
|
||||||
|
if (!isNonEmptyString(link.url)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: link.url,
|
||||||
|
label: link.label,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(isDefined);
|
||||||
|
};
|
||||||
@ -201,9 +201,9 @@ export type FieldEmailsValue = {
|
|||||||
additionalEmails: string[] | null;
|
additionalEmails: string[] | null;
|
||||||
};
|
};
|
||||||
export type FieldLinksValue = {
|
export type FieldLinksValue = {
|
||||||
primaryLinkLabel: string;
|
primaryLinkLabel: string | null;
|
||||||
primaryLinkUrl: string;
|
primaryLinkUrl: string | null;
|
||||||
secondaryLinks?: { label: string; url: string }[] | null;
|
secondaryLinks?: { label: string | null; url: string | null }[] | null;
|
||||||
};
|
};
|
||||||
export type FieldCurrencyValue = {
|
export type FieldCurrencyValue = {
|
||||||
currencyCode: CurrencyCode;
|
currencyCode: CurrencyCode;
|
||||||
|
|||||||
@ -3,10 +3,15 @@ import { z } from 'zod';
|
|||||||
import { FieldLinksValue } from '../FieldMetadata';
|
import { FieldLinksValue } from '../FieldMetadata';
|
||||||
|
|
||||||
export const linksSchema = z.object({
|
export const linksSchema = z.object({
|
||||||
primaryLinkLabel: z.string(),
|
primaryLinkLabel: z.string().nullable(),
|
||||||
primaryLinkUrl: absoluteUrlSchema.or(z.string().length(0)),
|
primaryLinkUrl: absoluteUrlSchema.or(z.string().length(0)).nullable(),
|
||||||
secondaryLinks: z
|
secondaryLinks: z
|
||||||
.array(z.object({ label: z.string(), url: absoluteUrlSchema }))
|
.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string().nullable(),
|
||||||
|
url: absoluteUrlSchema.nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
}) satisfies z.ZodType<FieldLinksValue>;
|
}) satisfies z.ZodType<FieldLinksValue>;
|
||||||
|
|
||||||
|
|||||||
@ -37,11 +37,9 @@ import { isFieldText } from '@/object-record/record-field/types/guards/isFieldTe
|
|||||||
import { isFieldTsVector } from '@/object-record/record-field/types/guards/isFieldTsVectorValue';
|
import { isFieldTsVector } from '@/object-record/record-field/types/guards/isFieldTsVectorValue';
|
||||||
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
|
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
|
||||||
|
|
||||||
const isValueEmpty = (value: unknown) =>
|
const isValueEmpty = (value: unknown) =>
|
||||||
!isDefined(value) ||
|
!isDefined(value) || (isString(value) && value === '');
|
||||||
(isString(value) && stripSimpleQuotesFromString(value) === '');
|
|
||||||
|
|
||||||
export const isFieldValueEmpty = ({
|
export const isFieldValueEmpty = ({
|
||||||
fieldDefinition,
|
fieldDefinition,
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
|
||||||
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
|
||||||
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
|
|
||||||
import { v4 } from 'uuid';
|
|
||||||
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
|
||||||
|
|
||||||
type GenerateEmptyFieldValueArgs = {
|
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'type'>;
|
|
||||||
};
|
|
||||||
export const generateDefaultFieldValue = ({
|
|
||||||
fieldMetadataItem,
|
|
||||||
}: GenerateEmptyFieldValueArgs) => {
|
|
||||||
const defaultValue = isFieldValueEmpty({
|
|
||||||
fieldValue: fieldMetadataItem.defaultValue,
|
|
||||||
fieldDefinition: fieldMetadataItem,
|
|
||||||
})
|
|
||||||
? generateEmptyFieldValue({
|
|
||||||
fieldMetadataItem,
|
|
||||||
})
|
|
||||||
: stripSimpleQuotesFromString(fieldMetadataItem.defaultValue);
|
|
||||||
|
|
||||||
switch (defaultValue) {
|
|
||||||
case 'uuid':
|
|
||||||
return v4();
|
|
||||||
case 'now':
|
|
||||||
return new Date().toISOString();
|
|
||||||
default:
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -113,7 +113,7 @@ export const generateEmptyFieldValue = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case FieldMetadataType.TS_VECTOR: {
|
case FieldMetadataType.TS_VECTOR: {
|
||||||
throw new Error('TS_VECTOR not implemented yet');
|
return null;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return assertUnreachable(
|
return assertUnreachable(
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { isUndefined } from '@sniptt/guards';
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||||
import { generateDefaultFieldValue } from '@/object-record/utils/generateDefaultFieldValue';
|
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
|
||||||
import { FieldMetadataType, RelationDefinitionType } from '~/generated/graphql';
|
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { FieldMetadataType, RelationDefinitionType } from '~/generated/graphql';
|
||||||
|
|
||||||
type PrefillRecordArgs = {
|
type PrefillRecordArgs = {
|
||||||
objectMetadataItem: ObjectMetadataItem;
|
objectMetadataItem: ObjectMetadataItem;
|
||||||
@ -28,7 +28,7 @@ export const prefillRecord = <T extends ObjectRecord>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fieldValue = isUndefined(inputValue)
|
const fieldValue = isUndefined(inputValue)
|
||||||
? generateDefaultFieldValue({ fieldMetadataItem })
|
? generateEmptyFieldValue({ fieldMetadataItem })
|
||||||
: inputValue;
|
: inputValue;
|
||||||
return [fieldMetadataItem.name, fieldValue];
|
return [fieldMetadataItem.name, fieldValue];
|
||||||
})
|
})
|
||||||
|
|||||||
@ -29,7 +29,7 @@ describe('getFieldPreviewValue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a placeholder defaultValue if the field metadata does not have a defaultValue', () => {
|
it('returns a placeholder defaultValue if the field metadata does not have a defaultValue', () => {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
||||||
import { generateDefaultFieldValue } from '@/object-record/utils/generateDefaultFieldValue';
|
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
|
||||||
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
|
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
|
||||||
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
|
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { stripSimpleQuotesFromStringRecursive } from '~/utils/string/stripSimpleQuotesFromString';
|
||||||
|
|
||||||
type getFieldPreviewValueArgs = {
|
type getFieldPreviewValueArgs = {
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
|
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
|
||||||
@ -16,10 +17,12 @@ export const getFieldPreviewValue = ({
|
|||||||
if (
|
if (
|
||||||
!isFieldValueEmpty({
|
!isFieldValueEmpty({
|
||||||
fieldDefinition: { type: fieldMetadataItem.type },
|
fieldDefinition: { type: fieldMetadataItem.type },
|
||||||
fieldValue: fieldMetadataItem.defaultValue,
|
fieldValue: stripSimpleQuotesFromStringRecursive(
|
||||||
|
fieldMetadataItem.defaultValue,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return generateDefaultFieldValue({
|
return generateEmptyFieldValue({
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { isNonEmptyString } from '@sniptt/guards';
|
|||||||
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||||
|
|
||||||
type LinkDisplayProps = {
|
type LinkDisplayProps = {
|
||||||
value?: { url: string; label?: string };
|
value: { url: string; label?: string | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
|
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
|
||||||
const url = value?.url;
|
const url = value.url;
|
||||||
|
|
||||||
if (!isNonEmptyString(url)) {
|
if (!isNonEmptyString(url)) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@ -18,8 +18,8 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => {
|
|||||||
: 'https://' + url
|
: 'https://' + url
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const displayedValue = isNonEmptyString(value?.label)
|
const displayedValue = isNonEmptyString(value.label)
|
||||||
? value?.label
|
? value.label
|
||||||
: url?.replace(/^http[s]?:\/\/(?:[w]+\.)?/gm, '').replace(/^[w]+\./gm, '');
|
: url?.replace(/^http[s]?:\/\/(?:[w]+\.)?/gm, '').replace(/^[w]+\./gm, '');
|
||||||
|
|
||||||
const type = displayedValue.startsWith('linkedin.')
|
const type = displayedValue.startsWith('linkedin.')
|
||||||
|
|||||||
@ -1,50 +1,43 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks';
|
||||||
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
|
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
|
||||||
import { checkUrlType } from '~/utils/checkUrlType';
|
|
||||||
import {
|
import {
|
||||||
getAbsoluteUrlOrThrow,
|
getAbsoluteUrlOrThrow,
|
||||||
getUrlHostnameOrThrow,
|
getUrlHostnameOrThrow,
|
||||||
isDefined,
|
isDefined,
|
||||||
} from 'twenty-shared/utils';
|
} from 'twenty-shared/utils';
|
||||||
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||||
|
import { checkUrlType } from '~/utils/checkUrlType';
|
||||||
|
|
||||||
type LinksDisplayProps = {
|
type LinksDisplayProps = {
|
||||||
value?: FieldLinksValue;
|
value?: FieldLinksValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||||
const links = useMemo(
|
const links = useMemo(() => {
|
||||||
() =>
|
if (!isDefined(value)) {
|
||||||
[
|
return [];
|
||||||
value?.primaryLinkUrl
|
}
|
||||||
? {
|
|
||||||
url: value.primaryLinkUrl,
|
return getFieldLinkDefinedLinks(value).map(({ url, label }) => {
|
||||||
label: value.primaryLinkLabel,
|
let absoluteUrl = '';
|
||||||
}
|
let hostname = '';
|
||||||
: null,
|
try {
|
||||||
...(value?.secondaryLinks ?? []),
|
absoluteUrl = getAbsoluteUrlOrThrow(url);
|
||||||
]
|
hostname = getUrlHostnameOrThrow(absoluteUrl);
|
||||||
.filter(isDefined)
|
} catch {
|
||||||
.map(({ url, label }) => {
|
absoluteUrl = '';
|
||||||
let absoluteUrl = '';
|
hostname = '';
|
||||||
let hostname = '';
|
}
|
||||||
try {
|
return {
|
||||||
absoluteUrl = getAbsoluteUrlOrThrow(url);
|
url: absoluteUrl,
|
||||||
hostname = getUrlHostnameOrThrow(absoluteUrl);
|
label: label || hostname,
|
||||||
} catch {
|
type: checkUrlType(absoluteUrl),
|
||||||
absoluteUrl = '';
|
};
|
||||||
hostname = '';
|
});
|
||||||
}
|
}, [value]);
|
||||||
return {
|
|
||||||
url: absoluteUrl,
|
|
||||||
label: label || hostname,
|
|
||||||
type: checkUrlType(absoluteUrl),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[value?.primaryLinkLabel, value?.primaryLinkUrl, value?.secondaryLinks],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableList>
|
<ExpandableList>
|
||||||
|
|||||||
@ -0,0 +1,182 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { expect, waitFor, within } from '@storybook/test';
|
||||||
|
|
||||||
|
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
|
||||||
|
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||||
|
|
||||||
|
const meta: Meta<typeof LinksDisplay> = {
|
||||||
|
title: 'UI/Display/LinksDisplay',
|
||||||
|
component: LinksDisplay,
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
argTypes: {
|
||||||
|
value: {
|
||||||
|
control: 'object',
|
||||||
|
description:
|
||||||
|
'The value object containing primaryLinkUrl, primaryLinkLabel, and secondaryLinks',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof LinksDisplay>;
|
||||||
|
|
||||||
|
export const NoLinks: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: '',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvas.queryByRole('link')).toBeNull();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NullLinks: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: null,
|
||||||
|
primaryLinkLabel: 'Primary Link',
|
||||||
|
secondaryLinks: [
|
||||||
|
{ url: null, label: 'Secondary Link' },
|
||||||
|
{ url: 'https://www.twenty.com', label: 'Valid Link' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const links = canvas.queryAllByRole('link');
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validLink = await canvas.findByText('Valid Link');
|
||||||
|
expect(validLink).toBeVisible();
|
||||||
|
expect(validLink).toHaveAttribute('href', 'https://www.twenty.com');
|
||||||
|
|
||||||
|
expect(canvas.queryByText('Primary Link')).not.toBeInTheDocument();
|
||||||
|
expect(canvas.queryByText('Secondary Link')).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SingleLink: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const link = await canvas.findByRole('link');
|
||||||
|
expect(link).toBeVisible();
|
||||||
|
expect(link).toHaveAttribute('href', 'https://www.twenty.com');
|
||||||
|
expect(link).toHaveTextContent('Twenty Website');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvas.getAllByRole('link')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleLinks: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [
|
||||||
|
{ url: 'https://docs.twenty.com', label: 'Documentation' },
|
||||||
|
{ url: 'https://blog.twenty.com', label: 'Blog' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const links = canvas.queryAllByRole('link');
|
||||||
|
expect(links).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
|
expect(primaryLink).toBeVisible();
|
||||||
|
expect(primaryLink).toHaveAttribute('href', 'https://www.twenty.com');
|
||||||
|
|
||||||
|
const docsLink = await canvas.findByText('Documentation');
|
||||||
|
expect(docsLink).toBeVisible();
|
||||||
|
expect(docsLink).toHaveAttribute('href', 'https://docs.twenty.com');
|
||||||
|
|
||||||
|
const blogLink = await canvas.findByText('Blog');
|
||||||
|
expect(blogLink).toBeVisible();
|
||||||
|
expect(blogLink).toHaveAttribute('href', 'https://blog.twenty.com');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SocialMediaLinks: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.linkedin.com/company/twenty',
|
||||||
|
primaryLinkLabel: 'Twenty on LinkedIn',
|
||||||
|
secondaryLinks: [
|
||||||
|
{ url: 'https://twitter.com/twentycrm', label: 'Twenty on Twitter' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const links = canvas.queryAllByRole('link');
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkedinLink = await canvas.findByText('twenty');
|
||||||
|
expect(linkedinLink).toBeVisible();
|
||||||
|
expect(linkedinLink).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://www.linkedin.com/company/twenty',
|
||||||
|
);
|
||||||
|
|
||||||
|
const twitterLink = await canvas.findByText('@twentycrm');
|
||||||
|
expect(twitterLink).toBeVisible();
|
||||||
|
expect(twitterLink).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://twitter.com/twentycrm',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AutomaticLabelFromURL: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.example.com',
|
||||||
|
primaryLinkLabel: '',
|
||||||
|
secondaryLinks: [{ url: 'https://test.example.com', label: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const links = canvas.queryAllByRole('link');
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryLink = await canvas.findByText('www.example.com');
|
||||||
|
expect(primaryLink).toBeVisible();
|
||||||
|
expect(primaryLink).toHaveAttribute('href', 'https://www.example.com');
|
||||||
|
|
||||||
|
const secondaryLink = await canvas.findByText('test.example.com');
|
||||||
|
expect(secondaryLink).toBeVisible();
|
||||||
|
expect(secondaryLink).toHaveAttribute('href', 'https://test.example.com');
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user