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
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
export const TEST_PRIMARY_LINK_URL = 'http://test/';
|
||||
export const TEST_PRIMARY_LINK_URL = 'https://test.com/';
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user