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:
Baptiste Devessier
2025-05-21 11:30:15 +02:00
committed by GitHub
parent 7461b7ac58
commit c29ed1c0c9
17 changed files with 432 additions and 104 deletions

View File

@ -2,11 +2,11 @@ import { isNonEmptyString } from '@sniptt/guards';
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
type LinkDisplayProps = {
value?: { url: string; label?: string };
value: { url: string; label?: string | null };
};
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
const url = value?.url;
const url = value.url;
if (!isNonEmptyString(url)) {
return <></>;
@ -18,8 +18,8 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => {
: 'https://' + url
: '';
const displayedValue = isNonEmptyString(value?.label)
? value?.label
const displayedValue = isNonEmptyString(value.label)
? value.label
: url?.replace(/^http[s]?:\/\/(?:[w]+\.)?/gm, '').replace(/^[w]+\./gm, '');
const type = displayedValue.startsWith('linkedin.')

View File

@ -1,50 +1,43 @@
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 { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { checkUrlType } from '~/utils/checkUrlType';
import {
getAbsoluteUrlOrThrow,
getUrlHostnameOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
import { checkUrlType } from '~/utils/checkUrlType';
type LinksDisplayProps = {
value?: FieldLinksValue;
};
export const LinksDisplay = ({ value }: LinksDisplayProps) => {
const links = useMemo(
() =>
[
value?.primaryLinkUrl
? {
url: value.primaryLinkUrl,
label: value.primaryLinkLabel,
}
: null,
...(value?.secondaryLinks ?? []),
]
.filter(isDefined)
.map(({ url, label }) => {
let absoluteUrl = '';
let hostname = '';
try {
absoluteUrl = getAbsoluteUrlOrThrow(url);
hostname = getUrlHostnameOrThrow(absoluteUrl);
} catch {
absoluteUrl = '';
hostname = '';
}
return {
url: absoluteUrl,
label: label || hostname,
type: checkUrlType(absoluteUrl),
};
}),
[value?.primaryLinkLabel, value?.primaryLinkUrl, value?.secondaryLinks],
);
const links = useMemo(() => {
if (!isDefined(value)) {
return [];
}
return getFieldLinkDefinedLinks(value).map(({ url, label }) => {
let absoluteUrl = '';
let hostname = '';
try {
absoluteUrl = getAbsoluteUrlOrThrow(url);
hostname = getUrlHostnameOrThrow(absoluteUrl);
} catch {
absoluteUrl = '';
hostname = '';
}
return {
url: absoluteUrl,
label: label || hostname,
type: checkUrlType(absoluteUrl),
};
});
}, [value]);
return (
<ExpandableList>

View File

@ -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');
},
};