Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
|
|
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
|
|
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
|
|
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
|
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
|
import { findManyObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-query-factory.util';
|
|
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { FieldMetadataType } from 'twenty-shared/types';
|
|
import { capitalize } from 'twenty-shared/utils';
|
|
|
|
describe('Custom object renaming', () => {
|
|
let listingObjectId = '';
|
|
const uniqueSuffix = Date.now().toString().slice(-8);
|
|
|
|
const STANDARD_OBJECT_RELATIONS = [
|
|
'noteTarget',
|
|
'attachment',
|
|
'taskTarget',
|
|
'timelineActivity',
|
|
];
|
|
|
|
const standardObjectRelationsMap = STANDARD_OBJECT_RELATIONS.reduce(
|
|
(acc, relation) => ({
|
|
...acc,
|
|
[relation]: {
|
|
objectMetadataId: '',
|
|
foreignKeyFieldMetadataId: '',
|
|
relationFieldMetadataId: '',
|
|
relationFieldMetadataUniversalIdentifier: '',
|
|
},
|
|
}),
|
|
{},
|
|
);
|
|
|
|
const standardObjectsGraphqlOperation = findManyObjectMetadataQueryFactory({
|
|
gqlFields: `
|
|
id
|
|
nameSingular
|
|
`,
|
|
input: {
|
|
filter: {},
|
|
paging: { first: 1000 },
|
|
},
|
|
});
|
|
|
|
const fieldsGraphqlOperation = findManyFieldsMetadataQueryFactory({
|
|
gqlFields: `
|
|
id
|
|
name
|
|
label
|
|
type
|
|
universalIdentifier
|
|
object {
|
|
id
|
|
}
|
|
`,
|
|
input: {
|
|
filter: {},
|
|
paging: { first: 1000 },
|
|
},
|
|
});
|
|
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const fillStandardObjectRelationsMapObjectMetadataId = (standardObjects) => {
|
|
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
standardObjectRelationsMap[relation].objectMetadataId =
|
|
standardObjects.body.data.objects.edges.find(
|
|
// @ts-expect-error legacy noImplicitAny
|
|
(object) =>
|
|
object.node.nameSingular === relation ||
|
|
object.node.nameSingular === `target${relation}`,
|
|
).node.id;
|
|
});
|
|
};
|
|
|
|
it('1. should create one custom object with standard relations', async () => {
|
|
const standardObjects = await makeMetadataAPIRequest(
|
|
standardObjectsGraphqlOperation,
|
|
);
|
|
|
|
fillStandardObjectRelationsMapObjectMetadataId(standardObjects);
|
|
|
|
const CUSTOM_OBJECT = {
|
|
namePlural: `customObjects${uniqueSuffix}`,
|
|
nameSingular: `customObject${uniqueSuffix}`,
|
|
labelPlural: `Custom Objects ${uniqueSuffix}`,
|
|
labelSingular: `Custom Object ${uniqueSuffix}`,
|
|
description: 'Custom object description',
|
|
icon: 'IconListNumbers',
|
|
isLabelSyncedWithName: false,
|
|
};
|
|
|
|
const { data } = await createOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: CUSTOM_OBJECT,
|
|
gqlFields: `
|
|
id
|
|
nameSingular
|
|
`,
|
|
});
|
|
|
|
expect(data.createOneObject.nameSingular).toBe(CUSTOM_OBJECT.nameSingular);
|
|
|
|
listingObjectId = data.createOneObject.id;
|
|
|
|
const fields = await makeMetadataAPIRequest(fieldsGraphqlOperation);
|
|
|
|
const relationFieldsMetadataForListing = fields.body.data.fields.edges
|
|
.filter(
|
|
// @ts-expect-error legacy noImplicitAny
|
|
(field) =>
|
|
(field.node.name === `${CUSTOM_OBJECT.nameSingular}` &&
|
|
FieldMetadataType.RELATION) ||
|
|
(field.node.name ===
|
|
`target${capitalize(CUSTOM_OBJECT.nameSingular)}` &&
|
|
FieldMetadataType.MORPH_RELATION),
|
|
)
|
|
// @ts-expect-error legacy noImplicitAny
|
|
.map((field) => field.node);
|
|
|
|
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
|
|
const relationFieldMetadata = relationFieldsMetadataForListing.find(
|
|
// @ts-expect-error legacy noImplicitAny
|
|
(field) =>
|
|
field.object.id ===
|
|
// @ts-expect-error legacy noImplicitAny
|
|
standardObjectRelationsMap[relation].objectMetadataId,
|
|
);
|
|
|
|
const relationFieldMetadataId = relationFieldMetadata?.id;
|
|
|
|
expect(relationFieldMetadataId).not.toBeUndefined();
|
|
// Reverse system relation fields carry the engine-derived label
|
|
// (capitalized source object nameSingular)
|
|
expect(relationFieldMetadata?.label).toBe(
|
|
capitalize(CUSTOM_OBJECT.nameSingular),
|
|
);
|
|
|
|
// @ts-expect-error legacy noImplicitAny
|
|
standardObjectRelationsMap[relation].relationFieldMetadataId =
|
|
relationFieldMetadataId;
|
|
// @ts-expect-error legacy noImplicitAny
|
|
standardObjectRelationsMap[relation].relationFieldMetadataUniversalIdentifier =
|
|
relationFieldMetadata?.universalIdentifier;
|
|
});
|
|
});
|
|
|
|
it('2. should rename custom object', async () => {
|
|
const HOUSE_NAME_SINGULAR = `house${uniqueSuffix}`;
|
|
const HOUSE_NAME_PLURAL = `houses${uniqueSuffix}`;
|
|
const HOUSE_LABEL_SINGULAR = `House ${uniqueSuffix}`;
|
|
const HOUSE_LABEL_PLURAL = `Houses ${uniqueSuffix}`;
|
|
|
|
const { data } = await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
gqlFields: `
|
|
nameSingular
|
|
labelSingular
|
|
namePlural
|
|
labelPlural
|
|
`,
|
|
input: {
|
|
idToUpdate: listingObjectId,
|
|
updatePayload: {
|
|
nameSingular: HOUSE_NAME_SINGULAR,
|
|
namePlural: HOUSE_NAME_PLURAL,
|
|
labelSingular: HOUSE_LABEL_SINGULAR,
|
|
labelPlural: HOUSE_LABEL_PLURAL,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(data.updateOneObject.nameSingular).toBe(HOUSE_NAME_SINGULAR);
|
|
expect(data.updateOneObject.namePlural).toBe(HOUSE_NAME_PLURAL);
|
|
expect(data.updateOneObject.labelSingular).toBe(HOUSE_LABEL_SINGULAR);
|
|
expect(data.updateOneObject.labelPlural).toBe(HOUSE_LABEL_PLURAL);
|
|
|
|
// The reverse morph fields on the standard objects must be renamed in place
|
|
// (name and engine-derived label follow the new object name) while keeping
|
|
// their universal identifier stable, so the rename stays lossless.
|
|
const expectedReverseFieldName = `target${capitalize(HOUSE_NAME_SINGULAR)}`;
|
|
const expectedReverseFieldLabel = capitalize(HOUSE_NAME_SINGULAR);
|
|
const fields = await makeMetadataAPIRequest(fieldsGraphqlOperation);
|
|
|
|
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const relationEntry = standardObjectRelationsMap[relation];
|
|
const relationFieldMetadataId = relationEntry.relationFieldMetadataId;
|
|
const relationFieldMetadataUniversalIdentifier =
|
|
relationEntry.relationFieldMetadataUniversalIdentifier;
|
|
|
|
const renamedReverseField = fields.body.data.fields.edges
|
|
// @ts-expect-error legacy noImplicitAny
|
|
.map((field) => field.node)
|
|
// @ts-expect-error legacy noImplicitAny
|
|
.find((field) => field.id === relationFieldMetadataId);
|
|
|
|
expect(renamedReverseField).toBeDefined();
|
|
expect(renamedReverseField.name).toBe(expectedReverseFieldName);
|
|
expect(renamedReverseField.label).toBe(expectedReverseFieldLabel);
|
|
expect(renamedReverseField.universalIdentifier).toBe(
|
|
relationFieldMetadataUniversalIdentifier,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('3. should reject direct deletion of a system side-effect relation field', async () => {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
|
|
const relationFieldMetadataId =
|
|
timelineActivityRelation.relationFieldMetadataId;
|
|
|
|
const { errors } = await deleteOneFieldMetadata({
|
|
expectToFail: true,
|
|
input: { idToDelete: relationFieldMetadataId },
|
|
});
|
|
|
|
expect(errors).toBeDefined();
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('4. should reject direct edition of a system side-effect relation field', async () => {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
|
|
const relationFieldMetadataId =
|
|
timelineActivityRelation.relationFieldMetadataId;
|
|
|
|
const { errors } = await updateOneFieldMetadata({
|
|
expectToFail: true,
|
|
input: {
|
|
idToUpdate: relationFieldMetadataId,
|
|
updatePayload: { label: 'Should not be editable' },
|
|
},
|
|
});
|
|
|
|
expect(errors).toBeDefined();
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('5. should reject a morph relations update payload on a system side-effect relation field', async () => {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const timelineActivityRelation = standardObjectRelationsMap['timelineActivity'];
|
|
const relationFieldMetadataId =
|
|
timelineActivityRelation.relationFieldMetadataId;
|
|
|
|
// morphRelationsUpdatePayload is not an editable property, so it must be
|
|
// rejected explicitly for engine-owned fields instead of silently creating
|
|
// relation fields and indexes on them
|
|
const { errors } = await updateOneFieldMetadata({
|
|
expectToFail: true,
|
|
input: {
|
|
idToUpdate: relationFieldMetadataId,
|
|
updatePayload: {
|
|
morphRelationsUpdatePayload: [
|
|
{ targetObjectMetadataId: listingObjectId },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(errors).toBeDefined();
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('6. should delete custom object', async () => {
|
|
await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: listingObjectId,
|
|
updatePayload: {
|
|
isActive: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { data } = await deleteOneObjectMetadata({
|
|
input: {
|
|
idToDelete: listingObjectId,
|
|
},
|
|
});
|
|
|
|
expect(data.deleteOneObject.id).toBe(listingObjectId);
|
|
});
|
|
});
|