[DASHBOARDS] Allow dashboards to be restored (#17042)

This PR introduces a few changes:
- Add three actions: see deleted dashboards, destroy dashboard and
restore dashboard
- Remove the soft delete and restore on all the page layout entities
- Cascade the destruction of a dashboard to a page layout

Video QA:


https://github.com/user-attachments/assets/ab993b11-dd9c-4e88-880c-92691a521cc2
This commit is contained in:
Raphaël Bosi
2026-01-12 13:59:17 +01:00
committed by GitHub
parent 3ada8e5168
commit 655f1eef5f
60 changed files with 1178 additions and 2067 deletions
@@ -38,10 +38,10 @@ export class PageLayoutController {
@Query('objectMetadataId') objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (isDefined(objectMetadataId)) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
return this.pageLayoutService.findByObjectMetadataId({
workspaceId: workspace.id,
objectMetadataId,
);
});
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
@@ -53,7 +53,10 @@ export class PageLayoutController {
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
return this.pageLayoutService.findByIdOrThrow({
id,
workspaceId: workspace.id,
});
}
@Post()
@@ -62,7 +65,10 @@ export class PageLayoutController {
@Body() input: CreatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
return this.pageLayoutService.create({
createPageLayoutInput: input,
workspaceId: workspace.id,
});
}
@Patch(':id')
@@ -72,26 +78,24 @@ export class PageLayoutController {
@Body() input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
const updatedPageLayout = await this.pageLayoutService.update(
const updatedPageLayout = await this.pageLayoutService.update({
id,
workspace.id,
input,
);
workspaceId: workspace.id,
updateData: input,
});
return updatedPageLayout;
}
@Delete(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async delete(
async destroy(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
const deletedPageLayout = await this.pageLayoutService.delete(
): Promise<boolean> {
return this.pageLayoutService.destroy({
id,
workspace.id,
);
return deletedPageLayout;
workspaceId: workspace.id,
});
}
}
@@ -9,8 +9,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module';
import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module';
import { FlatPageLayoutModule } from 'src/engine/metadata-modules/flat-page-layout/flat-page-layout.module';
import { PageLayoutTabModule } from 'src/engine/metadata-modules/page-layout-tab/page-layout-tab.module';
import { PageLayoutWidgetModule } from 'src/engine/metadata-modules/page-layout-widget/page-layout-widget.module';
import { PageLayoutController } from 'src/engine/metadata-modules/page-layout/controllers/page-layout.controller';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout.resolver';
@@ -38,8 +36,6 @@ import { DashboardSyncModule } from 'src/modules/dashboard-sync/dashboard-sync.m
FlatPageLayoutTabModule,
FlatPageLayoutWidgetModule,
ApplicationModule,
PageLayoutTabModule,
PageLayoutWidgetModule,
DashboardSyncModule,
],
controllers: [PageLayoutController],
@@ -7,7 +7,6 @@ import {
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -43,10 +42,10 @@ export class PageLayoutResolver {
objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (objectMetadataId) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
return this.pageLayoutService.findByObjectMetadataId({
workspaceId: workspace.id,
objectMetadataId,
);
});
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
@@ -58,7 +57,10 @@ export class PageLayoutResolver {
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
return this.pageLayoutService.findByIdOrThrow({
id,
workspaceId: workspace.id,
});
}
@Mutation(() => PageLayoutDTO)
@@ -67,7 +69,10 @@ export class PageLayoutResolver {
@Args('input') input: CreatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
return this.pageLayoutService.create({
createPageLayoutInput: input,
workspaceId: workspace.id,
});
}
@Mutation(() => PageLayoutDTO)
@@ -77,21 +82,11 @@ export class PageLayoutResolver {
@Args('input') input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.update(id, workspace.id, input);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async deletePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
const deletedPageLayout = await this.pageLayoutService.delete(
return this.pageLayoutService.update({
id,
workspace.id,
);
return deletedPageLayout;
workspaceId: workspace.id,
updateData: input,
});
}
@Mutation(() => Boolean)
@@ -100,21 +95,10 @@ export class PageLayoutResolver {
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const deletedPageLayout = await this.pageLayoutService.destroy(
return this.pageLayoutService.destroy({
id,
workspace.id,
);
return isDefined(deletedPageLayout);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async restorePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.restore(id, workspace.id);
workspaceId: workspace.id,
});
}
@Mutation(() => PageLayoutDTO)
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
@@ -10,9 +10,7 @@ import { type FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-pag
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatPageLayoutMaps } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout-maps.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 { fromDeletePageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-delete-page-layout-input-to-flat-page-layout-or-throw.util';
import { fromDestroyPageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-destroy-page-layout-input-to-flat-page-layout-or-throw.util';
import { fromRestorePageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-restore-page-layout-input-to-flat-page-layout-or-throw.util';
import {
fromUpdatePageLayoutInputToFlatPageLayoutToUpdateOrThrow,
type UpdatePageLayoutInputWithId,
@@ -38,8 +36,6 @@ import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashbo
@Injectable()
export class PageLayoutService {
private readonly logger = new Logger(PageLayoutService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
@@ -70,10 +66,13 @@ export class PageLayoutService {
);
}
async findByObjectMetadataId(
workspaceId: string,
objectMetadataId: string,
): Promise<PageLayoutDTO[]> {
async findByObjectMetadataId({
workspaceId,
objectMetadataId,
}: {
workspaceId: string;
objectMetadataId: string;
}): Promise<PageLayoutDTO[]> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
@@ -99,10 +98,13 @@ export class PageLayoutService {
);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
): Promise<PageLayoutDTO> {
async findByIdOrThrow({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<PageLayoutDTO> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
@@ -111,7 +113,10 @@ export class PageLayoutService {
const flatLayout = flatPageLayoutMaps.byId[id];
if (!isDefined(flatLayout) || isDefined(flatLayout.deletedAt)) {
const isLayoutNotFound =
!isDefined(flatLayout) || isDefined(flatLayout.deletedAt);
if (isLayoutNotFound) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
@@ -147,10 +152,13 @@ export class PageLayoutService {
);
}
async create(
createPageLayoutInput: CreatePageLayoutInput,
workspaceId: string,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
async create({
createPageLayoutInput,
workspaceId,
}: {
createPageLayoutInput: CreatePageLayoutInput;
workspaceId: string;
}): Promise<Omit<PageLayoutDTO, 'tabs'>> {
if (!isNonEmptyString(createPageLayoutInput.name)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
@@ -210,11 +218,15 @@ export class PageLayoutService {
);
}
async update(
id: string,
workspaceId: string,
updateData: UpdatePageLayoutInput,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
async update({
id,
workspaceId,
updateData,
}: {
id: string;
workspaceId: string;
updateData: UpdatePageLayoutInput;
}): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -280,74 +292,15 @@ export class PageLayoutService {
return fromFlatPageLayoutToPageLayoutDto(updatedLayout);
}
async delete(
id: string,
workspaceId: string,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const flatPageLayoutToDelete =
fromDeletePageLayoutInputToFlatPageLayoutOrThrow({
deletePageLayoutInput: { id },
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToDelete],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting page layout',
);
}
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const deletedLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
});
await this.dashboardSyncService.softDeleteLinkedDashboardsByPageLayoutId({
pageLayoutId: id,
workspaceId,
deletedAt: isDefined(deletedLayout.deletedAt)
? new Date(deletedLayout.deletedAt)
: new Date(),
});
return fromFlatPageLayoutToPageLayoutDto(deletedLayout);
}
async destroy(
id: string,
workspaceId: string,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
async destroy({
id,
workspaceId,
isLinkedDashboardAlreadyDestroyed = false,
}: {
id: string;
workspaceId: string;
isLinkedDashboardAlreadyDestroyed?: boolean;
}): Promise<boolean> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -384,106 +337,48 @@ export class PageLayoutService {
);
}
if (flatPageLayoutToDestroy.type === PageLayoutType.DASHBOARD) {
await this.destroyAssociatedDashboards(id, workspaceId);
if (
flatPageLayoutToDestroy.type === PageLayoutType.DASHBOARD &&
!isLinkedDashboardAlreadyDestroyed
) {
await this.destroyAssociatedDashboards({
pageLayoutId: id,
workspaceId,
});
}
return fromFlatPageLayoutToPageLayoutDto(flatPageLayoutToDestroy);
return true;
}
private async destroyAssociatedDashboards(
pageLayoutId: string,
workspaceId: string,
): Promise<void> {
private async destroyAssociatedDashboards({
pageLayoutId,
workspaceId,
}: {
pageLayoutId: string;
workspaceId: string;
}): Promise<void> {
const authContext = buildSystemAuthContext(workspaceId);
try {
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const dashboardRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const dashboardRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
const dashboards = await dashboardRepository.find({
where: {
pageLayoutId,
},
});
for (const dashboard of dashboards) {
await dashboardRepository.delete(dashboard.id);
}
},
);
} catch (error) {
this.logger.error(
`Failed to destroy associated dashboards for page layout ${pageLayoutId}: ${error}`,
);
}
}
async restore(
id: string,
workspaceId: string,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const flatPageLayoutToRestore =
fromRestorePageLayoutInputToFlatPageLayoutOrThrow({
restorePageLayoutInput: { id },
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToRestore],
},
const dashboards = await dashboardRepository.find({
where: {
pageLayoutId,
},
workspaceId,
isSystemBuild: false,
},
);
});
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while restoring page layout',
);
}
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const restoredLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
});
await this.dashboardSyncService.restoreLinkedDashboardsByPageLayoutId({
pageLayoutId: id,
workspaceId,
});
return fromFlatPageLayoutToPageLayoutDto(restoredLayout);
for (const dashboard of dashboards) {
await dashboardRepository.delete(dashboard.id);
}
},
);
}
}