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:
committed by
GitHub
parent
75e4a5d19b
commit
ec9d8e4e95
@ -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:
|
||||
|
||||
@ -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',
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -37,3 +37,8 @@ export type LinksMetadata = {
|
||||
primaryLinkUrl: string;
|
||||
secondaryLinks: LinkMetadata[] | null;
|
||||
};
|
||||
|
||||
export type LinkMetadataNullable = {
|
||||
label: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user