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
@ -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.')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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