[DASHBOARDS] Dashboard duplication (#16291)

## Description

- Created a Dashboard duplication action
- Created a new duplication custom resolver
- Created the service using the v2 of the API
- Created the integration tests following the v2 methodology

## Video QA


https://github.com/user-attachments/assets/e409951a-5946-4da0-91a0-1f7d2ecadb08
This commit is contained in:
Raphaël Bosi
2025-12-11 12:15:15 +01:00
committed by GitHub
parent 6c728fff78
commit 5af2d37253
39 changed files with 1542 additions and 79 deletions
@@ -5,11 +5,13 @@ import { CustomException } from 'src/utils/custom-exception';
export enum PageLayoutExceptionCode {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
INVALID_PAGE_LAYOUT_DATA = 'INVALID_PAGE_LAYOUT_DATA',
TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION = 'TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION',
}
export enum PageLayoutExceptionMessageKey {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
NAME_REQUIRED = 'NAME_REQUIRED',
TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION = 'TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION',
}
export class PageLayoutException extends CustomException<PageLayoutExceptionCode> {}
@@ -23,6 +25,8 @@ export const generatePageLayoutExceptionMessage = (
return `Page layout with ID "${value}" not found`;
case PageLayoutExceptionMessageKey.NAME_REQUIRED:
return 'Page layout name is required';
case PageLayoutExceptionMessageKey.TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION:
return `Failed to duplicate widget: no matching tab found for original tab ID "${value}"`;
default:
assertUnreachable(key);
}
@@ -18,6 +18,7 @@ import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entiti
import { PageLayoutTabResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout-tab.resolver';
import { PageLayoutWidgetResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout-widget.resolver';
import { PageLayoutResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout.resolver';
import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-layout/services/page-layout-duplication.service';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
@@ -57,12 +58,18 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
PageLayoutService,
PageLayoutTabService,
PageLayoutWidgetService,
PageLayoutDuplicationService,
PageLayoutResolver,
PageLayoutTabResolver,
PageLayoutWidgetResolver,
PageLayoutUpdateService,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [PageLayoutService, PageLayoutTabService, PageLayoutWidgetService],
exports: [
PageLayoutService,
PageLayoutTabService,
PageLayoutWidgetService,
PageLayoutDuplicationService,
],
})
export class PageLayoutModule {}
@@ -0,0 +1,287 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/from-create-page-layout-tab-input-to-flat-page-layout-tab-to-create.util';
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-create-page-layout-widget-input-to-flat-page-layout-widget-to-create.util';
import { type FlatPageLayoutMaps } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout-maps.type';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { fromCreatePageLayoutInputToFlatPageLayoutToCreate } from 'src/engine/metadata-modules/flat-page-layout/utils/from-create-page-layout-input-to-flat-page-layout-to-create.util';
import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata-modules/flat-page-layout/utils/reconstruct-flat-page-layout-with-tabs-and-widgets.util';
import { type PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-with-tabs-and-widgets-to-page-layout-dto.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class PageLayoutDuplicationService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async duplicate({
pageLayoutId,
workspaceId,
}: {
pageLayoutId: string;
workspaceId: string;
}): Promise<PageLayoutDTO> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.getPageLayoutFlatEntityMaps(workspaceId);
const originalFlatLayout = this.findOriginalLayoutOrThrow(
pageLayoutId,
flatPageLayoutMaps,
);
const originalTabsWithWidgets = this.getOriginalTabsWithWidgets(
originalFlatLayout,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const newFlatPageLayout = fromCreatePageLayoutInputToFlatPageLayoutToCreate(
{
createPageLayoutInput: {
name: originalFlatLayout.name,
type: originalFlatLayout.type,
objectMetadataId: originalFlatLayout.objectMetadataId,
},
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
},
);
const { newFlatTabs, originalTabIdToNewTabIdMap } =
this.createDuplicatedTabs({
originalTabs: originalTabsWithWidgets.map(({ tab }) => tab),
newPageLayoutId: newFlatPageLayout.id,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const newFlatWidgets = this.createDuplicatedWidgets({
originalTabsWithWidgets,
originalTabIdToNewTabIdMap,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [newFlatPageLayout],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: newFlatTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: newFlatWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while duplicating page layout',
);
}
const {
flatPageLayoutMaps: recomputedFlatPageLayoutMaps,
flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: recomputedFlatPageLayoutWidgetMaps,
} = await this.getPageLayoutFlatEntityMaps(workspaceId);
const newFlatLayoutWithTabsAndWidgets =
reconstructFlatPageLayoutWithTabsAndWidgets({
layout: findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: newFlatPageLayout.id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
}),
flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: recomputedFlatPageLayoutWidgetMaps,
});
return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
newFlatLayoutWithTabsAndWidgets,
);
}
private async getPageLayoutFlatEntityMaps(workspaceId: string): Promise<{
flatPageLayoutMaps: FlatPageLayoutMaps;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}> {
return this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
],
},
);
}
private findOriginalLayoutOrThrow(
pageLayoutId: string,
flatPageLayoutMaps: FlatPageLayoutMaps,
): FlatPageLayout {
const flatLayout = flatPageLayoutMaps.byId[pageLayoutId];
if (!isDefined(flatLayout) || isDefined(flatLayout.deletedAt)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
pageLayoutId,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return flatLayout;
}
private getOriginalTabsWithWidgets(
originalFlatLayout: FlatPageLayout,
flatPageLayoutTabMaps: FlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps,
): { tab: FlatPageLayoutTab; widgets: FlatPageLayoutWidget[] }[] {
const widgetsByTabId = new Map<string, FlatPageLayoutWidget[]>();
for (const widget of Object.values(flatPageLayoutWidgetMaps.byId)) {
if (!isDefined(widget) || isDefined(widget.deletedAt)) {
continue;
}
const existingWidgets = widgetsByTabId.get(widget.pageLayoutTabId) ?? [];
existingWidgets.push(widget);
widgetsByTabId.set(widget.pageLayoutTabId, existingWidgets);
}
const tabs = Object.values(flatPageLayoutTabMaps.byId)
.filter(
(tab): tab is FlatPageLayoutTab =>
isDefined(tab) &&
tab.pageLayoutId === originalFlatLayout.id &&
!isDefined(tab.deletedAt),
)
.sort((tabA, tabB) => (tabA.position ?? 0) - (tabB.position ?? 0));
return tabs.map((tab) => ({
tab,
widgets: widgetsByTabId.get(tab.id) ?? [],
}));
}
private createDuplicatedTabs({
originalTabs,
newPageLayoutId,
workspaceId,
workspaceCustomApplicationId,
}: {
originalTabs: FlatPageLayoutTab[];
newPageLayoutId: string;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
newFlatTabs: FlatPageLayoutTab[];
originalTabIdToNewTabIdMap: Map<string, string>;
} {
const originalTabIdToNewTabIdMap = new Map<string, string>();
const newFlatTabs = originalTabs.map((originalTab) => {
const newFlatTab =
fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate({
createPageLayoutTabInput: {
title: originalTab.title,
position: originalTab.position,
pageLayoutId: newPageLayoutId,
},
workspaceId,
workspaceCustomApplicationId,
});
originalTabIdToNewTabIdMap.set(originalTab.id, newFlatTab.id);
return newFlatTab;
});
return { newFlatTabs, originalTabIdToNewTabIdMap };
}
private createDuplicatedWidgets({
originalTabsWithWidgets,
originalTabIdToNewTabIdMap,
workspaceId,
workspaceCustomApplicationId,
}: {
originalTabsWithWidgets: {
tab: FlatPageLayoutTab;
widgets: FlatPageLayoutWidget[];
}[];
originalTabIdToNewTabIdMap: Map<string, string>;
workspaceId: string;
workspaceCustomApplicationId: string;
}): FlatPageLayoutWidget[] {
return originalTabsWithWidgets.flatMap(({ tab, widgets }) => {
const newTabId = originalTabIdToNewTabIdMap.get(tab.id)!;
return widgets.map((originalWidget) =>
fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate({
createPageLayoutWidgetInput: {
title: originalWidget.title,
gridPosition: originalWidget.gridPosition,
type: originalWidget.type,
objectMetadataId: originalWidget.objectMetadataId,
configuration: originalWidget.configuration as Record<
string,
unknown
> | null,
pageLayoutTabId: newTabId,
},
workspaceId,
workspaceCustomApplicationId,
}),
);
});
}
}
@@ -33,6 +33,7 @@ export const pageLayoutGraphqlApiExceptionHandler = (
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
throw new NotFoundError(error.message);
case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA:
case PageLayoutExceptionCode.TAB_NOT_FOUND_FOR_WIDGET_DUPLICATION:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});