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
@ -1,11 +1,12 @@
|
|||||||
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
|
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
|
||||||
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
||||||
import {
|
import {
|
||||||
FieldActorMetadata,
|
FieldActorMetadata,
|
||||||
FieldFullNameMetadata,
|
FieldFullNameMetadata,
|
||||||
FieldRatingMetadata,
|
FieldLinksMetadata,
|
||||||
FieldSelectMetadata,
|
FieldRatingMetadata,
|
||||||
FieldTextMetadata,
|
FieldSelectMetadata,
|
||||||
|
FieldTextMetadata,
|
||||||
} from '@/object-record/record-field/types/FieldMetadata';
|
} from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||||
@ -111,3 +112,19 @@ export const actorFieldDefinition: FieldDefinition<FieldActorMetadata> = {
|
|||||||
objectMetadataNameSingular: 'person',
|
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);
|
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([]);
|
).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 { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isNonEmptyString } from '@sniptt/guards';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined, isValidUrl } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
|
export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
|
||||||
return [
|
return [
|
||||||
@ -23,5 +23,6 @@ export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => {
|
|||||||
label: link.label,
|
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 { z } from 'zod';
|
||||||
import { FieldLinksValue } from '../FieldMetadata';
|
import { FieldLinksValue } from '../FieldMetadata';
|
||||||
|
|
||||||
export const linksSchema = z.object({
|
export const linksSchema = z.object({
|
||||||
primaryLinkLabel: z.string().nullable(),
|
primaryLinkLabel: z.string().nullable(),
|
||||||
primaryLinkUrl: absoluteUrlSchema.or(z.string().length(0)).nullable(),
|
primaryLinkUrl: z.string().nullable(),
|
||||||
secondaryLinks: z
|
secondaryLinks: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
label: z.string().nullable(),
|
label: z.string().nullable(),
|
||||||
url: absoluteUrlSchema.nullable(),
|
url: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
booleanFieldDefinition,
|
booleanFieldDefinition,
|
||||||
fieldMetadataId,
|
fieldMetadataId,
|
||||||
fullNameFieldDefinition,
|
fullNameFieldDefinition,
|
||||||
|
linksFieldDefinition,
|
||||||
relationFieldDefinition,
|
relationFieldDefinition,
|
||||||
selectFieldDefinition,
|
selectFieldDefinition,
|
||||||
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
|
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
|
||||||
@ -112,4 +113,104 @@ describe('isFieldValueEmpty', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).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 { 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 { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
||||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
|
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
|
||||||
@ -116,9 +117,14 @@ export const isFieldValueEmpty = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isFieldLinks(fieldDefinition)) {
|
if (isFieldLinks(fieldDefinition)) {
|
||||||
return (
|
if (!isFieldLinksValue(fieldValue)) {
|
||||||
!isFieldLinksValue(fieldValue) || isValueEmpty(fieldValue.primaryLinkUrl)
|
return true;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const definedLinks = getFieldLinkDefinedLinks(fieldValue);
|
||||||
|
const isFieldLinksEmpty = definedLinks.length === 0;
|
||||||
|
|
||||||
|
return isFieldLinksEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFieldActor(fieldDefinition)) {
|
if (isFieldActor(fieldDefinition)) {
|
||||||
|
|||||||
@ -180,3 +180,25 @@ export const AutomaticLabelFromURL: Story = {
|
|||||||
expect(secondaryLink).toHaveAttribute('href', 'https://test.example.com');
|
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 { 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 { 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 { 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 { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||||
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
|
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
|
||||||
|
|
||||||
@ -23,6 +25,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
case error instanceof RecordTransformerException:
|
||||||
|
return recordTransformerGraphqlApiExceptionHandler(error);
|
||||||
case error instanceof PermissionsException:
|
case error instanceof PermissionsException:
|
||||||
return permissionGraphqlApiExceptionHandler(error);
|
return permissionGraphqlApiExceptionHandler(error);
|
||||||
case error instanceof WorkspaceQueryRunnerException:
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { ServerBlockNoteEditor } from '@blocknote/server-util';
|
import { ServerBlockNoteEditor } from '@blocknote/server-util';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
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 { 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 { 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 {
|
import {
|
||||||
RichTextV2Metadata,
|
RichTextV2Metadata,
|
||||||
richTextV2ValueSchema,
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private transformLinksValue(value: any): any {
|
private transformLinksValue(value: any): any {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 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 {
|
try {
|
||||||
const secondaryLinksArray = JSON.parse(secondaryLinks);
|
secondaryLinksArray = JSON.parse(secondaryLinksRaw);
|
||||||
|
|
||||||
secondaryLinks = JSON.stringify(
|
|
||||||
secondaryLinksArray.map((link: LinkMetadata) => {
|
|
||||||
return {
|
|
||||||
...link,
|
|
||||||
url: lowercaseDomain(link.url),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { primaryLinkLabel, primaryLinkUrl, secondaryLinks } =
|
||||||
|
removeEmptyLinks({
|
||||||
|
primaryLinkUrl: primaryLinkUrlRaw,
|
||||||
|
primaryLinkLabel: primaryLinkLabelRaw,
|
||||||
|
secondaryLinks: secondaryLinksArray,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
primaryLinkUrl: newPrimaryLinkUrl,
|
primaryLinkUrl: isDefined(primaryLinkUrl)
|
||||||
secondaryLinks,
|
? 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;
|
primaryLinkUrl: string;
|
||||||
secondaryLinks: LinkMetadata[] | null;
|
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('\\')).toBe(false);
|
expect(isValidUrl('\\')).toBe(false);
|
||||||
expect(isValidUrl('wwwexamplecom')).toBe(false);
|
expect(isValidUrl('wwwexamplecom')).toBe(false);
|
||||||
|
expect(isValidUrl('lydia,com')).toBe(false);
|
||||||
expect(isValidUrl('2/toto')).toBe(false);
|
expect(isValidUrl('2/toto')).toBe(false);
|
||||||
expect(isValidUrl('2')).toBe(false);
|
expect(isValidUrl('2')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user