Discard empty and null links in Links fields (#12188)

This PR has several objectives:

- Ignore invalid and empty links in the frontend
- Ignore empty links when creating or updating a link field in the
backend
- Throw an error when trying to create or update a link field with an
invalid link

The logic is mostly the same in the frontend and the backend: we take
the initial primaryLink and the secondaryLinks, we discard all the empty
links (with `url === '' || url === null`), and the primaryLink becomes
the first remaining link.

## Frontend

There are three parts in the frontend where we have to remove the empty
links:

- LinksDisplay
- LinksFieldInput
- isFieldValueEmpty; used in RecordInlineCell

## Backend

I put the logic in
`packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts`
as it's used by the REST API, the GraphQL API, and by Create Record and
Update Record actions in the workflows.
This commit is contained in:
Baptiste Devessier
2025-05-23 11:13:10 +02:00
committed by GitHub
parent 75e4a5d19b
commit ec9d8e4e95
17 changed files with 544 additions and 31 deletions

View File

@ -1,11 +1,12 @@
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import {
FieldActorMetadata,
FieldFullNameMetadata,
FieldRatingMetadata,
FieldSelectMetadata,
FieldTextMetadata,
FieldActorMetadata,
FieldFullNameMetadata,
FieldLinksMetadata,
FieldRatingMetadata,
FieldSelectMetadata,
FieldTextMetadata,
} from '@/object-record/record-field/types/FieldMetadata';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@ -111,3 +112,19 @@ export const actorFieldDefinition: FieldDefinition<FieldActorMetadata> = {
objectMetadataNameSingular: 'person',
},
};
export const linksFieldDefinition: FieldDefinition<FieldLinksMetadata> = {
fieldMetadataId,
label: 'Links',
iconName: 'IconLink',
type: FieldMetadataType.LINKS,
defaultValue: {
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
},
metadata: {
fieldName: 'links',
objectMetadataNameSingular: 'company',
},
};

View File

@ -416,3 +416,31 @@ export const Cancel: Story = {
expect(cancelJestFn).toHaveBeenCalledTimes(1);
},
};
export const InvalidUrls: Story = {
args: {
value: {
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [
{ url: 'wikipedia', label: 'Missing Protocol' },
{ url: '\\invalid', label: 'Invalid Characters' },
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('URL');
expect(input).toBeVisible();
expect(input).toHaveValue('');
await waitFor(() => {
expect(canvas.queryByRole('link')).toBeNull();
});
expect(canvas.queryByText('Invalid URL')).not.toBeInTheDocument();
expect(canvas.queryByText('Missing Protocol')).not.toBeInTheDocument();
expect(canvas.queryByText('Invalid Characters')).not.toBeInTheDocument();
},
};

View File

@ -158,5 +158,33 @@ describe('getFieldLinkDefinedLinks', () => {
}),
).toEqual([]);
});
it('should filter out secondary links and primary link with invalid URLs', () => {
expect(
getFieldLinkDefinedLinks({
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid Primary',
secondaryLinks: [
{
url: 'lydia,com',
label: 'Invalid URL',
},
{
url: 'wikipedia',
label: 'Missing Protocol',
},
{
url: 'https://twenty.com',
label: 'Valid URL',
},
],
}),
).toEqual([
{
url: 'https://twenty.com',
label: 'Valid URL',
},
]);
});
});
});

View File

@ -1,6 +1,6 @@
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isValidUrl } from 'twenty-shared/utils';
export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
return [
@ -23,5 +23,6 @@ export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
label: link.label,
};
})
.filter(isDefined);
.filter(isDefined)
.filter(({ url }) => isValidUrl(url));
};

View File

@ -1,15 +1,14 @@
import { absoluteUrlSchema } from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldLinksValue } from '../FieldMetadata';
export const linksSchema = z.object({
primaryLinkLabel: z.string().nullable(),
primaryLinkUrl: absoluteUrlSchema.or(z.string().length(0)).nullable(),
primaryLinkUrl: z.string().nullable(),
secondaryLinks: z
.array(
z.object({
label: z.string().nullable(),
url: absoluteUrlSchema.nullable(),
url: z.string().nullable(),
}),
)
.nullable(),

View File

@ -2,6 +2,7 @@ import {
booleanFieldDefinition,
fieldMetadataId,
fullNameFieldDefinition,
linksFieldDefinition,
relationFieldDefinition,
selectFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
@ -112,4 +113,104 @@ describe('isFieldValueEmpty', () => {
}),
).toBe(false);
});
it('should return correct value for links field', () => {
// Empty cases
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
},
}),
).toBe(true);
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: null,
}),
).toBe(true);
// Valid primary link only
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [],
},
}),
).toBe(false);
// Valid secondary link only
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [
{ url: 'https://docs.twenty.com', label: 'Documentation' },
],
},
}),
).toBe(false);
// Invalid primary link but valid secondary link
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [
{ url: 'https://docs.twenty.com', label: 'Documentation' },
],
},
}),
).toBe(false);
// Valid primary link but invalid secondary link
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [{ url: 'wikipedia', label: 'Invalid URL' }],
},
}),
).toBe(false);
// All invalid links
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [{ url: 'wikipedia', label: 'Invalid URL' }],
},
}),
).toBe(true);
// Multiple secondary links with mix of valid and invalid
expect(
isFieldValueEmpty({
fieldDefinition: linksFieldDefinition,
fieldValue: {
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [
{ url: 'wikipedia', label: 'Invalid URL' },
{ url: 'https://docs.twenty.com', label: 'Documentation' },
],
},
}),
).toBe(false);
});
});

View File

@ -1,5 +1,6 @@
import { isArray, isNonEmptyArray, isString } from '@sniptt/guards';
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
@ -116,9 +117,14 @@ export const isFieldValueEmpty = ({
}
if (isFieldLinks(fieldDefinition)) {
return (
!isFieldLinksValue(fieldValue) || isValueEmpty(fieldValue.primaryLinkUrl)
);
if (!isFieldLinksValue(fieldValue)) {
return true;
}
const definedLinks = getFieldLinkDefinedLinks(fieldValue);
const isFieldLinksEmpty = definedLinks.length === 0;
return isFieldLinksEmpty;
}
if (isFieldActor(fieldDefinition)) {

View File

@ -180,3 +180,25 @@ export const AutomaticLabelFromURL: Story = {
expect(secondaryLink).toHaveAttribute('href', 'https://test.example.com');
},
};
export const InvalidLinks: Story = {
args: {
value: {
primaryLinkUrl: 'wikipedia',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [{ url: 'lydia,com', label: 'Invalid URL with comma' }],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.queryByRole('link')).toBeNull();
});
expect(canvas.queryByText('Invalid URL')).not.toBeInTheDocument();
expect(
canvas.queryByText('Invalid URL with comma'),
).not.toBeInTheDocument();
},
};