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

@ -3,6 +3,7 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinit
import {
FieldActorMetadata,
FieldFullNameMetadata,
FieldLinksMetadata,
FieldRatingMetadata,
FieldSelectMetadata,
FieldTextMetadata,
@ -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();
},
};

View File

@ -7,6 +7,8 @@ import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/works
import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util';
import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util';
import { WorkspaceQueryRunnerException } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { RecordTransformerException } from 'src/engine/core-modules/record-transformer/record-transformer.exception';
import { recordTransformerGraphqlApiExceptionHandler } from 'src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
@ -23,6 +25,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
}
throw error;
}
case error instanceof RecordTransformerException:
return recordTransformerGraphqlApiExceptionHandler(error);
case error instanceof PermissionsException:
return permissionGraphqlApiExceptionHandler(error);
case error instanceof WorkspaceQueryRunnerException:

View File

@ -0,0 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class RecordTransformerException extends CustomException {
declare code: RecordTransformerExceptionCode;
constructor(message: string, code: RecordTransformerExceptionCode) {
super(message, code);
}
}
export enum RecordTransformerExceptionCode {
INVALID_URL = 'INVALID_URL',
}

View File

@ -1,14 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import {
RichTextV2Metadata,
richTextV2ValueSchema,
@ -129,38 +131,45 @@ export class RecordInputTransformerService {
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private transformLinksValue(value: any): any {
if (!value) {
return value;
}
const newPrimaryLinkUrl = lowercaseDomain(value?.primaryLinkUrl);
const primaryLinkUrlRaw = value.primaryLinkUrl as string | null;
const primaryLinkLabelRaw = value.primaryLinkLabel as string | null;
const secondaryLinksRaw = value.secondaryLinks as string | null;
let secondaryLinks = value?.secondaryLinks;
let secondaryLinksArray: LinkMetadataNullable[] | null = null;
if (secondaryLinks) {
if (isNonEmptyString(secondaryLinksRaw)) {
try {
const secondaryLinksArray = JSON.parse(secondaryLinks);
secondaryLinks = JSON.stringify(
secondaryLinksArray.map((link: LinkMetadata) => {
return {
...link,
url: lowercaseDomain(link.url),
};
}),
);
secondaryLinksArray = JSON.parse(secondaryLinksRaw);
} catch {
/* empty */
}
}
const { primaryLinkLabel, primaryLinkUrl, secondaryLinks } =
removeEmptyLinks({
primaryLinkUrl: primaryLinkUrlRaw,
primaryLinkLabel: primaryLinkLabelRaw,
secondaryLinks: secondaryLinksArray,
});
return {
...value,
primaryLinkUrl: newPrimaryLinkUrl,
secondaryLinks,
primaryLinkUrl: isDefined(primaryLinkUrl)
? lowercaseDomain(primaryLinkUrl)
: primaryLinkUrl,
primaryLinkLabel,
secondaryLinks: JSON.stringify(
secondaryLinks?.map((link) => ({
...link,
url: isDefined(link.url) ? lowercaseDomain(link.url) : link.url,
})),
),
};
}

View File

@ -0,0 +1,203 @@
import {
RecordTransformerException,
RecordTransformerExceptionCode,
} from 'src/engine/core-modules/record-transformer/record-transformer.exception';
import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links';
describe('removeEmptyLinks', () => {
it('should return null values when all inputs are empty', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
}),
).toEqual({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
});
expect(
removeEmptyLinks({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: null,
}),
).toEqual({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
});
});
it('should keep valid primary link and remove empty secondary links', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [],
}),
).toEqual({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [],
});
});
it('should promote first valid secondary link to primary when primary is empty', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [
{
url: 'https://docs.twenty.com',
label: 'Documentation',
},
{
url: 'https://github.com/twentyhq/twenty',
label: 'GitHub',
},
],
}),
).toEqual({
primaryLinkUrl: 'https://docs.twenty.com',
primaryLinkLabel: 'Documentation',
secondaryLinks: [
{
url: 'https://github.com/twentyhq/twenty',
label: 'GitHub',
},
],
});
});
it('should throw RecordTransformerException when primary link URL is invalid', () => {
expect(() =>
removeEmptyLinks({
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [],
}),
).toThrow(
expect.objectContaining({
constructor: RecordTransformerException,
code: RecordTransformerExceptionCode.INVALID_URL,
message: 'The URL of the link is not valid',
}),
);
});
it('should throw RecordTransformerException when any secondary link URL is invalid', () => {
expect(() =>
removeEmptyLinks({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [
{
url: 'wikipedia',
label: 'Invalid URL',
},
],
}),
).toThrow(
expect.objectContaining({
constructor: RecordTransformerException,
code: RecordTransformerExceptionCode.INVALID_URL,
message: 'The URL of the link is not valid',
}),
);
});
it('should throw RecordTransformerException when both primary and secondary URLs are invalid', () => {
expect(() =>
removeEmptyLinks({
primaryLinkUrl: 'lydia,com',
primaryLinkLabel: 'Invalid URL',
secondaryLinks: [
{
url: 'wikipedia',
label: 'Invalid URL',
},
],
}),
).toThrow(
expect.objectContaining({
constructor: RecordTransformerException,
code: RecordTransformerExceptionCode.INVALID_URL,
message: 'The URL of the link is not valid',
}),
);
});
it('should handle empty or null secondary links', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [
{
url: '',
label: 'Empty URL',
},
{
url: null,
label: 'Null URL',
},
],
}),
).toEqual({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [],
});
});
it('should return empty state when there are no valid URLs', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: '',
primaryLinkLabel: 'Empty URL',
secondaryLinks: [
{
url: null,
label: 'Null URL',
},
{
url: '',
label: 'Empty URL',
},
],
}),
).toEqual({
primaryLinkUrl: null,
primaryLinkLabel: null,
secondaryLinks: [],
});
});
it('should keep valid URLs with null labels', () => {
expect(
removeEmptyLinks({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: null,
secondaryLinks: [
{
url: 'https://docs.twenty.com',
label: null,
},
],
}),
).toEqual({
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: null,
secondaryLinks: [
{
url: 'https://docs.twenty.com',
label: null,
},
],
});
});
});

View File

@ -0,0 +1,19 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
RecordTransformerException,
RecordTransformerExceptionCode,
} from 'src/engine/core-modules/record-transformer/record-transformer.exception';
export const recordTransformerGraphqlApiExceptionHandler = (
error: RecordTransformerException,
) => {
switch (error.code) {
case RecordTransformerExceptionCode.INVALID_URL:
throw new UserInputError(error.message);
default: {
assertUnreachable(error.code);
}
}
};

View File

@ -0,0 +1,58 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isValidUrl } from 'twenty-shared/utils';
import {
RecordTransformerException,
RecordTransformerExceptionCode,
} from 'src/engine/core-modules/record-transformer/record-transformer.exception';
import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
export const removeEmptyLinks = ({
primaryLinkUrl,
secondaryLinks,
primaryLinkLabel,
}: {
secondaryLinks: LinkMetadataNullable[] | null;
primaryLinkUrl: string | null;
primaryLinkLabel: string | null;
}) => {
const filteredLinks = [
isNonEmptyString(primaryLinkUrl)
? {
url: primaryLinkUrl,
label: primaryLinkLabel,
}
: null,
...(secondaryLinks ?? []),
]
.filter(isDefined)
.map((link) => {
if (!isNonEmptyString(link.url)) {
return undefined;
}
return {
url: link.url,
label: link.label,
};
})
.filter(isDefined);
for (const link of filteredLinks) {
if (!isValidUrl(link.url)) {
throw new RecordTransformerException(
'The URL of the link is not valid',
RecordTransformerExceptionCode.INVALID_URL,
);
}
}
const firstLink = filteredLinks.at(0);
const otherLinks = filteredLinks.slice(1);
return {
primaryLinkUrl: firstLink?.url ?? null,
primaryLinkLabel: firstLink?.label ?? null,
secondaryLinks: otherLinks,
};
};

View File

@ -37,3 +37,8 @@ export type LinksMetadata = {
primaryLinkUrl: string;
secondaryLinks: LinkMetadata[] | null;
};
export type LinkMetadataNullable = {
label: string | null;
url: string | null;
};

View File

@ -1 +1 @@
export const TEST_PRIMARY_LINK_URL = 'http://test/';
export const TEST_PRIMARY_LINK_URL = 'https://test.com/';

View File

@ -23,6 +23,7 @@ describe('isValidUrl', () => {
expect(isValidUrl('')).toBe(false);
expect(isValidUrl('\\')).toBe(false);
expect(isValidUrl('wwwexamplecom')).toBe(false);
expect(isValidUrl('lydia,com')).toBe(false);
expect(isValidUrl('2/toto')).toBe(false);
expect(isValidUrl('2')).toBe(false);
});