snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
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 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Dashboard duplication should fail when dashboard does not exist 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Dashboard with ID "7f7b4ae6-ebe4-4d7b-91a9-0043dffd5837" not found",
"name": "NotFoundError",
}
`;
exports[`Dashboard duplication should fail when dashboard page layout was deleted 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Dashboard with ID "8cbbc499-5a23-473d-ad0b-eaa92d4c9831" not found",
"name": "NotFoundError",
}
`;

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Dashboard duplication should succeed should duplicate a basic dashboard with page layout 1`] = `
{
"createdAt": Any<String>,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Basic Dashboard (Copy)",
"updatedAt": Any<String>,
}
`;
exports[`Dashboard duplication should succeed should duplicate a dashboard with page layout and tabs 1`] = `
{
"createdAt": Any<String>,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Dashboard With Tabs (Copy)",
"updatedAt": Any<String>,
}
`;
exports[`Dashboard duplication should succeed should duplicate a dashboard with page layout, tabs and widgets 1`] = `
{
"createdAt": Any<String>,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Dashboard With Widgets (Copy)",
"updatedAt": Any<String>,
}
`;

View File

@@ -0,0 +1,191 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
createTestDashboardWithGraphQL,
destroyDashboardWithGraphQL,
} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util';
import { duplicateOneDashboardQueryFactory } from 'test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard-query-factory.util';
import { duplicateOneDashboard } from 'test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { upsertObjectPermissions } from 'test/integration/metadata/suites/object-permission/utils/upsert-object-permissions.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { findOneRoleByLabel } from 'test/integration/metadata/suites/role/utils/find-one-role-by-label.util';
import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
type TestContext = {
dashboardId: string | (() => Promise<string>);
cleanupDashboardId?: boolean;
};
type GlobalTestContext = {
dashboardWithDeletedPageLayoutId?: string;
};
const globalTestContext: GlobalTestContext = {};
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'when dashboard does not exist',
context: {
dashboardId: '7f7b4ae6-ebe4-4d7b-91a9-0043dffd5837',
},
},
{
title: 'when dashboard page layout was deleted',
context: {
dashboardId: async () => {
const dashboard = await createTestDashboardWithGraphQL({
id: '8cbbc499-5a23-473d-ad0b-eaa92d4c9831',
title: 'Dashboard With Deleted Page Layout',
});
globalTestContext.dashboardWithDeletedPageLayoutId = dashboard.id;
if (isDefined(dashboard.pageLayoutId)) {
await destroyOnePageLayout({
expectToFail: false,
input: { id: dashboard.pageLayoutId },
});
}
return dashboard.id;
},
cleanupDashboardId: true,
},
},
];
describe('Dashboard duplication should fail', () => {
afterEach(async () => {
if (isDefined(globalTestContext.dashboardWithDeletedPageLayoutId)) {
await destroyDashboardWithGraphQL(
globalTestContext.dashboardWithDeletedPageLayoutId,
);
globalTestContext.dashboardWithDeletedPageLayoutId = undefined;
}
});
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
const dashboardId =
typeof context.dashboardId === 'function'
? await context.dashboardId()
: context.dashboardId;
const { errors } = await duplicateOneDashboard({
expectToFail: true,
input: { id: dashboardId },
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
);
});
describe('Dashboard duplication should follow the caller role', () => {
const DASHBOARD_ID = '3d1f2a52-6c5e-4b9a-8f1e-2c4d6e8f0a1b';
const DASHBOARD_TITLE = 'Dashboard Out Of Reach';
let dashboardObjectMetadataId: string;
let memberRoleId: string;
const setMemberDashboardPermissions = ({
canReadObjectRecords,
canUpdateObjectRecords,
}: {
canReadObjectRecords: boolean;
canUpdateObjectRecords: boolean;
}) =>
upsertObjectPermissions({
expectToFail: false,
input: {
roleId: memberRoleId,
objectPermissions: [
{
objectMetadataId: dashboardObjectMetadataId,
canReadObjectRecords,
canUpdateObjectRecords,
canSoftDeleteObjectRecords: canUpdateObjectRecords,
canDestroyObjectRecords: canUpdateObjectRecords,
},
],
},
});
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: { filter: {}, paging: { first: 100 } },
gqlFields: 'id nameSingular',
});
const dashboardObject = objects.find(
(object) => object.nameSingular === 'dashboard',
);
jestExpectToBeDefined(dashboardObject);
dashboardObjectMetadataId = dashboardObject.id;
memberRoleId = (await findOneRoleByLabel({ label: 'Member' })).id;
await createTestDashboardWithGraphQL({
id: DASHBOARD_ID,
title: DASHBOARD_TITLE,
});
});
afterAll(async () => {
await setMemberDashboardPermissions({
canReadObjectRecords: true,
canUpdateObjectRecords: true,
});
await destroyDashboardWithGraphQL(DASHBOARD_ID);
});
it.each([
{
title: 'read dashboards',
permissions: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
},
},
{
title: 'create dashboards',
permissions: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
},
},
])(
'should refuse a member whose role cannot $title',
async ({ permissions }) => {
await setMemberDashboardPermissions(permissions);
const response = await makeMetadataAPIRequestWithMemberRole(
duplicateOneDashboardQueryFactory({ input: { id: DASHBOARD_ID } }),
);
expect(response.body.data?.duplicateDashboard ?? null).toBeNull();
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
const copies = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'dashboard',
objectMetadataPluralName: 'dashboards',
gqlFields: 'id',
filter: { title: { eq: `${DASHBOARD_TITLE} (Copy)` } },
}),
);
expect(copies.body.errors).toBeUndefined();
expect(copies.body.data.dashboards.edges).toEqual([]);
},
);
});

View File

@@ -0,0 +1,285 @@
import { isNonEmptyString } from '@sniptt/guards';
import { TEST_IFRAME_CONFIG } from 'test/integration/constants/widget-configuration-test-data.constants';
import {
createTestDashboardWithGraphQL,
destroyDashboardWithGraphQL,
} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util';
import { duplicateOneDashboard } from 'test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import {
AggregateOperations,
PageLayoutTabLayoutMode,
PageLayoutType,
ViewFilterOperand,
WidgetType,
} from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
type TestContext = {
id: string;
title: string;
withTabs?: boolean;
withWidgets?: boolean;
};
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'duplicate a basic dashboard with page layout',
context: {
id: 'a69899ef-ad51-4abc-8105-45e8e6de85ac',
title: 'Basic Dashboard',
},
},
{
title: 'duplicate a dashboard with page layout and tabs',
context: {
id: '8da0a29d-b459-4b09-af3e-9490d9b644b4',
title: 'Dashboard With Tabs',
withTabs: true,
},
},
{
title: 'duplicate a dashboard with page layout, tabs and widgets',
context: {
id: 'a13e58f9-c5db-4e1d-a7fb-c282774c5053',
title: 'Dashboard With Widgets',
withTabs: true,
withWidgets: true,
},
},
];
describe('Dashboard duplication should succeed', () => {
let testPageLayoutId: string;
let testPageLayoutTabId: string;
let testDashboardId: string;
let duplicatedDashboardId: string;
let currentTestContextId: string;
const cleanup = async () => {
if (isNonEmptyString(duplicatedDashboardId)) {
await destroyDashboardWithGraphQL(duplicatedDashboardId);
duplicatedDashboardId = '';
}
if (isNonEmptyString(testDashboardId)) {
await destroyDashboardWithGraphQL(testDashboardId);
testDashboardId = '';
}
if (isNonEmptyString(currentTestContextId)) {
await destroyDashboardWithGraphQL(currentTestContextId);
currentTestContextId = '';
}
};
beforeEach(async () => {
testPageLayoutId = '';
testPageLayoutTabId = '';
testDashboardId = '';
duplicatedDashboardId = '';
currentTestContextId = '';
});
afterEach(async () => {
await cleanup();
});
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'should $title',
async ({ context: { id, title, withTabs, withWidgets } }) => {
currentTestContextId = id;
const { data: pageLayoutData } = await createOnePageLayout({
expectToFail: false,
input: {
name: `Page Layout for ${title}`,
type: PageLayoutType.DASHBOARD,
},
});
testPageLayoutId = pageLayoutData.createPageLayout.id;
if (withTabs) {
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Test Tab',
pageLayoutId: testPageLayoutId,
},
});
testPageLayoutTabId = tabData.createPageLayoutTab.id;
if (withWidgets) {
await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Test Widget',
type: WidgetType.IFRAME,
pageLayoutTabId: testPageLayoutTabId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
});
}
}
const dashboard = await createTestDashboardWithGraphQL({
id,
title,
pageLayoutId: testPageLayoutId,
});
testDashboardId = dashboard.id;
const { data } = await duplicateOneDashboard({
expectToFail: false,
input: { id: testDashboardId },
});
duplicatedDashboardId = data.duplicateDashboard.id;
expect(data.duplicateDashboard).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...data.duplicateDashboard }),
);
expect(data.duplicateDashboard.id).not.toBe(testDashboardId);
expect(data.duplicateDashboard.pageLayoutId).not.toBe(testPageLayoutId);
expect(data.duplicateDashboard.title).toContain('(Copy)');
},
);
// Regression test for #24285
it('should duplicate a dashboard with a chart widget filtering through a relation target field', async () => {
currentTestContextId = 'e7b2f7d1-4c3a-4f5e-9b8d-1a2b3c4d5e6f';
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
}
`,
});
const companyObject = objects.find(
(object) => object.nameSingular === 'company',
);
const opportunityObject = objects.find(
(object) => object.nameSingular === 'opportunity',
);
jestExpectToBeDefined(companyObject);
jestExpectToBeDefined(opportunityObject);
const companyEmployeesField = companyObject.fieldsList?.find(
(field) => field.name === 'employees',
);
const companyOpportunitiesField = companyObject.fieldsList?.find(
(field) => field.name === 'opportunities',
);
const opportunityNameField = opportunityObject.fieldsList?.find(
(field) => field.name === 'name',
);
jestExpectToBeDefined(companyEmployeesField);
jestExpectToBeDefined(companyOpportunitiesField);
jestExpectToBeDefined(opportunityNameField);
const { data: pageLayoutData } = await createOnePageLayout({
expectToFail: false,
input: {
name: 'Page Layout with relation-traversal chart filter',
type: PageLayoutType.DASHBOARD,
},
});
testPageLayoutId = pageLayoutData.createPageLayout.id;
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Test Tab',
pageLayoutId: testPageLayoutId,
},
});
testPageLayoutTabId = tabData.createPageLayoutTab.id;
await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Companies without lost opportunities',
type: WidgetType.GRAPH,
objectMetadataId: companyObject.id,
pageLayoutTabId: testPageLayoutTabId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: companyEmployeesField.id,
aggregateOperation: AggregateOperations.SUM,
filter: {
recordFilters: [
{
fieldMetadataId: companyOpportunitiesField.id,
relationTargetFieldMetadataId: opportunityNameField.id,
operand: ViewFilterOperand.DOES_NOT_CONTAIN,
value: 'lost',
},
],
},
},
},
});
const dashboard = await createTestDashboardWithGraphQL({
id: currentTestContextId,
title: 'Dashboard with relation-traversal chart filter',
pageLayoutId: testPageLayoutId,
});
testDashboardId = dashboard.id;
const { data, errors } = await duplicateOneDashboard({
expectToFail: false,
input: { id: testDashboardId },
});
expect(errors).toBeUndefined();
duplicatedDashboardId = data.duplicateDashboard.id;
expect(data.duplicateDashboard.id).not.toBe(testDashboardId);
expect(data.duplicateDashboard.title).toContain('(Copy)');
});
});

View File

@@ -0,0 +1,168 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
createManyDashboardsWithGraphQL,
createTestDashboardWithGraphQL,
destroyDashboardWithGraphQL,
destroyManyDashboardsWithGraphQL,
} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util';
import { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util';
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { findOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/find-one-page-layout.util';
import { PageLayoutType } from 'twenty-shared/types';
describe('Dashboard page layout auto-creation should succeed', () => {
describe('createOne dashboard without pageLayoutId', () => {
let createdDashboardId: string;
beforeEach(() => {
createdDashboardId = '';
});
afterEach(async () => {
if (isNonEmptyString(createdDashboardId)) {
await destroyDashboardWithGraphQL(createdDashboardId);
}
});
it('should auto-create page layout and tab for dashboard without pageLayoutId', async () => {
const title = 'Test Dashboard';
const dashboard = await createTestDashboardWithGraphQL({ title });
createdDashboardId = dashboard.id;
expect(dashboard.id).toBeDefined();
expect(dashboard.title).toBe(title);
expect(dashboard.pageLayoutId).toBeDefined();
expect(isNonEmptyString(dashboard.pageLayoutId)).toBe(true);
const { data: pageLayoutData } = await findOnePageLayout({
expectToFail: false,
input: { id: dashboard.pageLayoutId! },
});
expect(pageLayoutData.getPageLayout).toBeDefined();
expect(pageLayoutData.getPageLayout?.type).toBe(PageLayoutType.DASHBOARD);
const { data: tabsData } = await findPageLayoutTabs({
expectToFail: false,
input: { pageLayoutId: dashboard.pageLayoutId! },
});
expect(tabsData.getPageLayoutTabs).toBeDefined();
expect(tabsData.getPageLayoutTabs.length).toBeGreaterThanOrEqual(1);
expect(tabsData.getPageLayoutTabs[0].title).toBe('Tab 1');
});
});
describe('createMany dashboards without pageLayoutId', () => {
let createdDashboardIds: string[] = [];
beforeEach(() => {
createdDashboardIds = [];
});
afterEach(async () => {
if (createdDashboardIds.length > 0) {
await destroyManyDashboardsWithGraphQL({
id: { in: createdDashboardIds },
});
}
});
it('should auto-create separate page layouts for each dashboard in createMany', async () => {
const dashboardsData = [
{ title: 'Dashboard 1' },
{ title: 'Dashboard 2' },
{ title: 'Dashboard 3' },
];
const dashboards = await createManyDashboardsWithGraphQL(dashboardsData);
createdDashboardIds = dashboards.map((d) => d.id);
expect(dashboards.length).toBe(dashboardsData.length);
const pageLayoutIds = new Set(
dashboards.map((d) => d.pageLayoutId).filter(isNonEmptyString),
);
expect(pageLayoutIds.size).toBe(dashboardsData.length);
for (const dashboard of dashboards) {
expect(dashboard.pageLayoutId).toBeDefined();
expect(isNonEmptyString(dashboard.pageLayoutId)).toBe(true);
const { data: pageLayoutData } = await findOnePageLayout({
expectToFail: false,
input: { id: dashboard.pageLayoutId! },
});
expect(pageLayoutData.getPageLayout).toBeDefined();
expect(pageLayoutData.getPageLayout?.type).toBe(
PageLayoutType.DASHBOARD,
);
const { data: tabsData } = await findPageLayoutTabs({
expectToFail: false,
input: { pageLayoutId: dashboard.pageLayoutId! },
});
expect(tabsData.getPageLayoutTabs).toBeDefined();
expect(tabsData.getPageLayoutTabs.length).toBeGreaterThanOrEqual(1);
}
});
it('should use provided pageLayoutId for some dashboards and auto-create for others', async () => {
const { data: pageLayoutData } = await createOnePageLayout({
input: {
name: 'Pre-existing page layout',
type: PageLayoutType.DASHBOARD,
},
expectToFail: false,
});
const existingPageLayoutId = pageLayoutData.createPageLayout.id;
expect(existingPageLayoutId).toBeDefined();
const dashboardsData = [
{
title: 'Dashboard with provided layout',
pageLayoutId: existingPageLayoutId,
},
{ title: 'Dashboard without layout 1' },
{ title: 'Dashboard without layout 2' },
];
const dashboards = await createManyDashboardsWithGraphQL(dashboardsData);
createdDashboardIds = dashboards.map((d) => d.id);
expect(dashboards.length).toBe(3);
const dashboardWithProvidedLayout = dashboards.find(
(d) => d.title === 'Dashboard with provided layout',
);
const dashboardsWithAutoCreatedLayout = dashboards.filter(
(d) => d.title !== 'Dashboard with provided layout',
);
expect(dashboardWithProvidedLayout?.pageLayoutId).toBe(
existingPageLayoutId,
);
for (const dashboard of dashboardsWithAutoCreatedLayout) {
expect(dashboard.pageLayoutId).toBeDefined();
expect(isNonEmptyString(dashboard.pageLayoutId)).toBe(true);
expect(dashboard.pageLayoutId).not.toBe(existingPageLayoutId);
}
await destroyOnePageLayout({
input: { id: existingPageLayoutId },
expectToFail: false,
});
});
});
});

View File

@@ -0,0 +1,118 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
createManyDashboardsWithGraphQL,
createTestDashboardWithGraphQL,
destroyDashboardWithGraphQL,
destroyManyDashboardsWithGraphQL,
} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { findOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/find-one-page-layout.util';
describe('Dashboard to Page Layout sync should succeed', () => {
describe('destroyOne dashboard', () => {
let pageLayoutId: string;
beforeEach(() => {
pageLayoutId = '';
});
afterEach(async () => {
if (isNonEmptyString(pageLayoutId)) {
await destroyOnePageLayout({
expectToFail: false,
input: { id: pageLayoutId },
});
pageLayoutId = '';
}
});
it('should hard delete linked page layout when dashboard is destroyed', async () => {
const dashboard = await createTestDashboardWithGraphQL({
title: 'Dashboard for Destroy Test',
});
pageLayoutId = dashboard.pageLayoutId ?? '';
expect(isNonEmptyString(pageLayoutId)).toBe(true);
const { data: beforeData } = await findOnePageLayout({
expectToFail: false,
input: { id: pageLayoutId },
});
expect(beforeData.getPageLayout).toBeDefined();
await destroyDashboardWithGraphQL(dashboard.id);
const { errors } = await findOnePageLayout({
expectToFail: true,
input: { id: pageLayoutId },
});
expect(errors).toBeDefined();
pageLayoutId = '';
});
});
describe('destroyMany dashboards', () => {
let pageLayoutIds: string[] = [];
beforeEach(() => {
pageLayoutIds = [];
});
afterEach(async () => {
for (const pageLayoutId of pageLayoutIds) {
if (isNonEmptyString(pageLayoutId)) {
await destroyOnePageLayout({
expectToFail: false,
input: { id: pageLayoutId },
});
}
}
pageLayoutIds = [];
});
it('should hard delete all linked page layouts when multiple dashboards are destroyed', async () => {
const dashboardsData = [
{ title: 'Dashboard 1 for Destroy Many Test' },
{ title: 'Dashboard 2 for Destroy Many Test' },
];
const dashboards = await createManyDashboardsWithGraphQL(dashboardsData);
const dashboardIds = dashboards.map((d) => d.id);
pageLayoutIds = dashboards
.map((d) => d.pageLayoutId)
.filter(isNonEmptyString);
expect(pageLayoutIds.length).toBe(dashboardsData.length);
for (const pageLayoutId of pageLayoutIds) {
const { data } = await findOnePageLayout({
expectToFail: false,
input: { id: pageLayoutId },
});
expect(data.getPageLayout).toBeDefined();
}
await destroyManyDashboardsWithGraphQL({
id: { in: dashboardIds },
});
for (const pageLayoutId of pageLayoutIds) {
const { errors } = await findOnePageLayout({
expectToFail: true,
input: { id: pageLayoutId },
});
expect(errors).toBeDefined();
}
pageLayoutIds = [];
});
});
});

View File

@@ -0,0 +1,286 @@
import { TEST_IFRAME_CONFIG } from 'test/integration/constants/widget-configuration-test-data.constants';
import {
createTestDashboardWithGraphQL,
destroyDashboardWithGraphQL,
findDashboardWithGraphQL,
} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util';
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
import { updateOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/update-one-page-layout-tab.util';
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util';
import { updateOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget.util';
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { updateOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/update-one-page-layout.util';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetType,
} from 'twenty-shared/types';
type TestContext = {
pageLayoutId: string;
tabId: string;
widgetId: string;
dashboardId: string;
};
const createTestContext = async (): Promise<TestContext> => {
const { data: pageLayoutData } = await createOnePageLayout({
expectToFail: false,
input: {
name: 'Page Layout for Dashboard Sync Test',
type: PageLayoutType.DASHBOARD,
},
});
const pageLayoutId = pageLayoutData.createPageLayout.id;
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Tab for Dashboard Sync Test',
pageLayoutId,
},
});
const tabId = tabData.createPageLayoutTab.id;
const { data: widgetData } = await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Widget for Dashboard Sync Test',
type: WidgetType.IFRAME,
pageLayoutTabId: tabId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
});
const widgetId = widgetData.createPageLayoutWidget.id;
const dashboard = await createTestDashboardWithGraphQL({
title: 'Dashboard for Sync Test',
pageLayoutId,
});
return {
pageLayoutId,
tabId,
widgetId,
dashboardId: dashboard.id,
};
};
const cleanupTestContext = async (context: TestContext): Promise<void> => {
await destroyDashboardWithGraphQL(context.dashboardId);
};
const assertDashboardUpdatedAtIncreased = async (
dashboardId: string,
operation: () => Promise<void>,
): Promise<void> => {
const dashboardBefore = await findDashboardWithGraphQL(dashboardId);
expect(dashboardBefore).not.toBeNull();
const updatedAtBefore = new Date(dashboardBefore!.updatedAt);
await operation();
const dashboardAfter = await findDashboardWithGraphQL(dashboardId);
expect(dashboardAfter).not.toBeNull();
const updatedAtAfter = new Date(dashboardAfter!.updatedAt);
const isIncreased = updatedAtAfter > updatedAtBefore;
expect(isIncreased).toBe(true);
};
describe('Dashboard updatedAt should sync when linked page layout entities change', () => {
describe('Widget operations', () => {
let context: TestContext;
let additionalWidgetId: string | undefined;
beforeEach(async () => {
context = await createTestContext();
});
afterEach(async () => {
if (additionalWidgetId) {
await destroyOnePageLayoutWidget({
expectToFail: false,
input: { id: additionalWidgetId },
});
additionalWidgetId = undefined;
}
await cleanupTestContext(context);
});
it('should update dashboard updatedAt when widget is created', async () => {
await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => {
const { data: widgetData } = await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'New Widget for Dashboard Sync Test',
type: WidgetType.IFRAME,
pageLayoutTabId: context.tabId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 1,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
});
additionalWidgetId = widgetData.createPageLayoutWidget.id;
});
});
it('should update dashboard updatedAt when widget is updated', async () => {
await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => {
await updateOnePageLayoutWidget({
expectToFail: false,
input: { id: context.widgetId, title: 'Updated Widget Title' },
});
});
});
});
describe('Tab operations', () => {
let context: TestContext;
let additionalTabId: string | undefined;
beforeEach(async () => {
context = await createTestContext();
});
afterEach(async () => {
if (additionalTabId) {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: additionalTabId },
});
additionalTabId = undefined;
}
await cleanupTestContext(context);
});
it('should update dashboard updatedAt when tab is created', async () => {
await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => {
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'New Tab for Dashboard Sync Test',
pageLayoutId: context.pageLayoutId,
},
});
additionalTabId = tabData.createPageLayoutTab.id;
});
});
it('should update dashboard updatedAt when tab is updated', async () => {
await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => {
await updateOnePageLayoutTab({
expectToFail: false,
input: { id: context.tabId, title: 'Updated Tab Title' },
});
});
});
});
describe('Page layout operations', () => {
let context: TestContext;
beforeEach(async () => {
context = await createTestContext();
});
afterEach(async () => {
await cleanupTestContext(context);
});
it('should update dashboard updatedAt when page layout is updated', async () => {
await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => {
await updateOnePageLayout({
expectToFail: false,
input: {
id: context.pageLayoutId,
name: 'Updated Page Layout Name',
},
});
});
});
});
describe('Non-dashboard page layout operations should not trigger sync', () => {
let nonDashboardPageLayoutId: string;
let dashboardContext: TestContext;
beforeEach(async () => {
dashboardContext = await createTestContext();
const { data: pageLayoutData } = await createOnePageLayout({
expectToFail: false,
input: {
name: 'Non-Dashboard Page Layout',
type: PageLayoutType.RECORD_INDEX,
},
});
nonDashboardPageLayoutId = pageLayoutData.createPageLayout.id;
});
afterEach(async () => {
await destroyOnePageLayout({
expectToFail: false,
input: { id: nonDashboardPageLayoutId },
});
await cleanupTestContext(dashboardContext);
});
it('should not update existing dashboard updatedAt when non-dashboard page layout is updated', async () => {
const dashboardBefore = await findDashboardWithGraphQL(
dashboardContext.dashboardId,
);
expect(dashboardBefore).not.toBeNull();
const updatedAtBefore = new Date(dashboardBefore!.updatedAt);
await updateOnePageLayout({
expectToFail: false,
input: {
id: nonDashboardPageLayoutId,
name: 'Updated Non-Dashboard Page Layout',
},
});
const dashboardAfter = await findDashboardWithGraphQL(
dashboardContext.dashboardId,
);
expect(dashboardAfter).not.toBeNull();
const updatedAtAfter = new Date(dashboardAfter!.updatedAt);
expect(updatedAtAfter.getTime()).toBe(updatedAtBefore.getTime());
});
});
});

View File

@@ -0,0 +1,8 @@
export const DASHBOARD_GQL_FIELDS = `
id
title
pageLayoutId
position
createdAt
updatedAt
`;

View File

@@ -0,0 +1,373 @@
import gql from 'graphql-tag';
import { type GraphQLResponse } from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { DASHBOARD_GQL_FIELDS } from './dashboard-gql-fields.constants';
interface CreateDashboardResponse extends Record<string, unknown> {
createDashboard: DashboardWorkspaceEntity;
}
interface CreateManyDashboardsResponse extends Record<string, unknown> {
createDashboards: DashboardWorkspaceEntity[];
}
interface FindDashboardResponse extends Record<string, unknown> {
dashboard: DashboardWorkspaceEntity | null;
}
interface DeleteDashboardResponse extends Record<string, unknown> {
deleteDashboard: DashboardWorkspaceEntity;
}
interface DeleteManyDashboardsResponse extends Record<string, unknown> {
deleteDashboards: DashboardWorkspaceEntity[];
}
interface RestoreDashboardResponse extends Record<string, unknown> {
restoreDashboard: DashboardWorkspaceEntity;
}
interface RestoreManyDashboardsResponse extends Record<string, unknown> {
restoreDashboards: DashboardWorkspaceEntity[];
}
interface DestroyManyDashboardsResponse extends Record<string, unknown> {
destroyDashboards: DashboardWorkspaceEntity[];
}
export const createTestDashboardWithGraphQL = async (data: {
id?: string;
title: string;
position?: number;
pageLayoutId?: string;
}): Promise<DashboardWorkspaceEntity> => {
const operation = {
query: gql`
mutation CreateDashboard($input: DashboardCreateInput!) {
createDashboard(data: $input) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
input: {
id: data.id,
title: data.title,
position: data.position ?? 0,
pageLayoutId: data.pageLayoutId,
},
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<CreateDashboardResponse>;
if (response.body.errors) {
throw new Error(
`Failed to create test dashboard: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from createTestDashboardWithGraphQL');
}
return response.body.data.createDashboard;
};
export const findDashboardWithGraphQL = async (
dashboardId: string,
): Promise<DashboardWorkspaceEntity | null> => {
const operation = {
query: gql`
query FindDashboard($filter: DashboardFilterInput!) {
dashboard(filter: $filter) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
filter: { id: { eq: dashboardId } },
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<FindDashboardResponse>;
if (response.body.errors) {
return null;
}
return response.body.data?.dashboard ?? null;
};
export const destroyDashboardWithGraphQL = async (
dashboardId: string,
): Promise<void> => {
const operation = {
query: gql`
mutation DestroyDashboard($dashboardId: UUID!) {
destroyDashboard(id: $dashboardId) {
id
}
}
`,
variables: {
dashboardId,
},
};
await makeGraphqlAPIRequest(operation);
};
export const createManyDashboardsWithGraphQL = async (
data: Array<{
id?: string;
title: string;
position?: number;
pageLayoutId?: string;
}>,
): Promise<DashboardWorkspaceEntity[]> => {
const operation = {
query: gql`
mutation CreateDashboards($data: [DashboardCreateInput!]!) {
createDashboards(data: $data) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
data: data.map((item, index) => ({
id: item.id,
title: item.title,
position: item.position ?? index,
pageLayoutId: item.pageLayoutId,
})),
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<CreateManyDashboardsResponse>;
if (response.body.errors) {
throw new Error(
`Failed to create dashboards: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from createManyDashboardsWithGraphQL');
}
return response.body.data.createDashboards;
};
export const deleteDashboardWithGraphQL = async (
dashboardId: string,
): Promise<DashboardWorkspaceEntity> => {
const operation = {
query: gql`
mutation DeleteDashboard($dashboardId: UUID!) {
deleteDashboard(id: $dashboardId) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
dashboardId,
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<DeleteDashboardResponse>;
if (response.body.errors) {
throw new Error(
`Failed to delete dashboard: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from deleteDashboardWithGraphQL');
}
return response.body.data.deleteDashboard;
};
export const deleteManyDashboardsWithGraphQL = async (filter: {
id: { in: string[] };
}): Promise<DashboardWorkspaceEntity[]> => {
const operation = {
query: gql`
mutation DeleteDashboards($filter: DashboardFilterInput!) {
deleteDashboards(filter: $filter) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
filter,
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<DeleteManyDashboardsResponse>;
if (response.body.errors) {
throw new Error(
`Failed to delete dashboards: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from deleteManyDashboardsWithGraphQL');
}
return response.body.data.deleteDashboards;
};
export const restoreDashboardWithGraphQL = async (
dashboardId: string,
): Promise<DashboardWorkspaceEntity> => {
const operation = {
query: gql`
mutation RestoreDashboard($dashboardId: UUID!) {
restoreDashboard(id: $dashboardId) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
dashboardId,
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<RestoreDashboardResponse>;
if (response.body.errors) {
throw new Error(
`Failed to restore dashboard: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from restoreDashboardWithGraphQL');
}
return response.body.data.restoreDashboard;
};
export const restoreManyDashboardsWithGraphQL = async (filter: {
id: { in: string[] };
}): Promise<DashboardWorkspaceEntity[]> => {
const operation = {
query: gql`
mutation RestoreDashboards($filter: DashboardFilterInput!) {
restoreDashboards(filter: $filter) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
filter,
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<RestoreManyDashboardsResponse>;
if (response.body.errors) {
throw new Error(
`Failed to restore dashboards: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from restoreManyDashboardsWithGraphQL');
}
return response.body.data.restoreDashboards;
};
export const destroyManyDashboardsWithGraphQL = async (filter: {
id: { in: string[] };
}): Promise<DashboardWorkspaceEntity[]> => {
const operation = {
query: gql`
mutation DestroyDashboards($filter: DashboardFilterInput!) {
destroyDashboards(filter: $filter) {
${DASHBOARD_GQL_FIELDS}
}
}
`,
variables: {
filter,
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<DestroyManyDashboardsResponse>;
if (response.body.errors) {
throw new Error(
`Failed to destroy dashboards: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from destroyManyDashboardsWithGraphQL');
}
return response.body.data.destroyDashboards;
};
export const findDeletedDashboardWithGraphQL = async (
dashboardId: string,
): Promise<DashboardWorkspaceEntity | null> => {
const operation = {
query: gql`
query FindDeletedDashboard($filter: DashboardFilterInput!) {
dashboard(filter: $filter) {
${DASHBOARD_GQL_FIELDS}
deletedAt
}
}
`,
variables: {
filter: {
id: { eq: dashboardId },
deletedAt: { is: 'NOT_NULL' },
},
},
};
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<FindDashboardResponse>;
if (response.body.errors) {
return null;
}
return response.body.data?.dashboard ?? null;
};
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
export const cleanupDashboardRecords = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM "${TEST_SCHEMA_NAME}"."dashboard"`,
);
};

View File

@@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import { DASHBOARD_GQL_FIELDS } from './dashboard-gql-fields.constants';
export type DuplicateOneDashboardFactoryInput = {
id: string;
};
export const duplicateOneDashboardQueryFactory = ({
input,
gqlFields = DASHBOARD_GQL_FIELDS,
}: {
input: DuplicateOneDashboardFactoryInput;
gqlFields?: string;
}) => ({
query: gql`
mutation DuplicateDashboard($id: UUID!) {
duplicateDashboard(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: input.id,
},
});

View File

@@ -0,0 +1,48 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
import {
type DuplicateOneDashboardFactoryInput,
duplicateOneDashboardQueryFactory,
} from './duplicate-one-dashboard-query-factory.util';
export const duplicateOneDashboard = async ({
input,
gqlFields,
expectToFail = false,
token,
}: {
input: DuplicateOneDashboardFactoryInput;
gqlFields?: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
duplicateDashboard: DuplicatedDashboardDTO;
}> => {
const graphqlOperation = duplicateOneDashboardQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Dashboard duplication should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Dashboard duplication has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};