[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:
@@ -0,0 +1,30 @@
|
||||
import { Controller, Param, Post, UseFilters, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
|
||||
import { DashboardRestApiExceptionFilter } from 'src/modules/dashboard/filters/dashboard-rest-api-exception.filter';
|
||||
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
|
||||
|
||||
@Controller('rest/dashboards')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseFilters(DashboardRestApiExceptionFilter)
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
private readonly dashboardDuplicationService: DashboardDuplicationService,
|
||||
) {}
|
||||
|
||||
@Post(':id/duplicate')
|
||||
async duplicate(
|
||||
@Param('id') id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<DuplicatedDashboardDTO> {
|
||||
return this.dashboardDuplicationService.duplicateDashboard(
|
||||
id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { DashboardController } from 'src/modules/dashboard/controllers/dashboard.controller';
|
||||
import { DashboardResolver } from 'src/modules/dashboard/resolvers/dashboard.resolver';
|
||||
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
PageLayoutModule,
|
||||
TwentyORMModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardDuplicationService, DashboardResolver],
|
||||
exports: [DashboardDuplicationService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('DuplicatedDashboard')
|
||||
export class DuplicatedDashboardDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
title: string | null;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
pageLayoutId: string | null;
|
||||
|
||||
@Field(() => Number)
|
||||
position: number;
|
||||
|
||||
@Field(() => String)
|
||||
createdAt: string;
|
||||
|
||||
@Field(() => String)
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum DashboardExceptionCode {
|
||||
DASHBOARD_NOT_FOUND = 'DASHBOARD_NOT_FOUND',
|
||||
DASHBOARD_DUPLICATION_FAILED = 'DASHBOARD_DUPLICATION_FAILED',
|
||||
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
|
||||
}
|
||||
|
||||
export enum DashboardExceptionMessageKey {
|
||||
DASHBOARD_NOT_FOUND = 'DASHBOARD_NOT_FOUND',
|
||||
DASHBOARD_DUPLICATION_FAILED = 'DASHBOARD_DUPLICATION_FAILED',
|
||||
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
|
||||
}
|
||||
|
||||
export class DashboardException extends CustomException<DashboardExceptionCode> {}
|
||||
|
||||
export const generateDashboardExceptionMessage = (
|
||||
key: DashboardExceptionMessageKey,
|
||||
value?: string,
|
||||
): string => {
|
||||
switch (key) {
|
||||
case DashboardExceptionMessageKey.DASHBOARD_NOT_FOUND:
|
||||
return `Dashboard with ID "${value}" not found`;
|
||||
case DashboardExceptionMessageKey.DASHBOARD_DUPLICATION_FAILED:
|
||||
return `Failed to duplicate dashboard: ${value}`;
|
||||
case DashboardExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND:
|
||||
return `Page layout for dashboard "${value}" not found`;
|
||||
default:
|
||||
assertUnreachable(key);
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import {
|
||||
DashboardException,
|
||||
DashboardExceptionCode,
|
||||
} from 'src/modules/dashboard/exceptions/dashboard.exception';
|
||||
import { type CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@Injectable()
|
||||
@Catch(DashboardException)
|
||||
export class DashboardRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: DashboardException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
switch (exception.code) {
|
||||
case DashboardExceptionCode.DASHBOARD_NOT_FOUND:
|
||||
case DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
|
||||
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
|
||||
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
|
||||
import { DashboardGraphqlApiExceptionFilter } from 'src/modules/dashboard/utils/dashboard-graphql-api-exception.filter';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(
|
||||
DashboardGraphqlApiExceptionFilter,
|
||||
PageLayoutGraphqlApiExceptionFilter,
|
||||
)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class DashboardResolver {
|
||||
constructor(
|
||||
private readonly dashboardDuplicationService: DashboardDuplicationService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DuplicatedDashboardDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async duplicateDashboard(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<DuplicatedDashboardDTO> {
|
||||
return this.dashboardDuplicationService.duplicateDashboard(
|
||||
id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-layout/services/page-layout-duplication.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
|
||||
import {
|
||||
DashboardException,
|
||||
DashboardExceptionCode,
|
||||
DashboardExceptionMessageKey,
|
||||
generateDashboardExceptionMessage,
|
||||
} from 'src/modules/dashboard/exceptions/dashboard.exception';
|
||||
import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardDuplicationService {
|
||||
private readonly logger = new Logger(DashboardDuplicationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageLayoutDuplicationService: PageLayoutDuplicationService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
async duplicateDashboard(
|
||||
dashboardId: string,
|
||||
workspaceId: string,
|
||||
): Promise<DuplicatedDashboardDTO> {
|
||||
const dashboardRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<DashboardWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'dashboard',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const originalDashboard = await dashboardRepository.findOne({
|
||||
where: { id: dashboardId },
|
||||
});
|
||||
|
||||
if (!isDefined(originalDashboard)) {
|
||||
throw new DashboardException(
|
||||
generateDashboardExceptionMessage(
|
||||
DashboardExceptionMessageKey.DASHBOARD_NOT_FOUND,
|
||||
dashboardId,
|
||||
),
|
||||
DashboardExceptionCode.DASHBOARD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(originalDashboard.pageLayoutId)) {
|
||||
throw new DashboardException(
|
||||
generateDashboardExceptionMessage(
|
||||
DashboardExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
|
||||
dashboardId,
|
||||
),
|
||||
DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newPageLayout = await this.pageLayoutDuplicationService.duplicate({
|
||||
pageLayoutId: originalDashboard.pageLayoutId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const newDashboard = await this.createDuplicatedDashboard(
|
||||
originalDashboard,
|
||||
newPageLayout.id,
|
||||
dashboardRepository,
|
||||
);
|
||||
|
||||
return {
|
||||
id: newDashboard.id,
|
||||
title: newDashboard.title,
|
||||
pageLayoutId: newDashboard.pageLayoutId,
|
||||
position: newDashboard.position,
|
||||
createdAt: newDashboard.createdAt,
|
||||
updatedAt: newDashboard.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to duplicate dashboard ${dashboardId}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createDuplicatedDashboard(
|
||||
originalDashboard: DashboardWorkspaceEntity,
|
||||
newPageLayoutId: string,
|
||||
dashboardRepository: Awaited<
|
||||
ReturnType<
|
||||
typeof this.twentyORMGlobalManager.getRepositoryForWorkspace<DashboardWorkspaceEntity>
|
||||
>
|
||||
>,
|
||||
): Promise<DashboardWorkspaceEntity> {
|
||||
const newTitle = appendCopySuffix(originalDashboard.title ?? '');
|
||||
|
||||
const insertResult = await dashboardRepository.insert({
|
||||
title: newTitle,
|
||||
pageLayoutId: newPageLayoutId,
|
||||
position: originalDashboard.position,
|
||||
});
|
||||
|
||||
const newDashboardId = insertResult.identifiers[0].id;
|
||||
|
||||
const newDashboard = await dashboardRepository.findOne({
|
||||
where: { id: newDashboardId },
|
||||
});
|
||||
|
||||
if (!isDefined(newDashboard)) {
|
||||
throw new DashboardException(
|
||||
generateDashboardExceptionMessage(
|
||||
DashboardExceptionMessageKey.DASHBOARD_DUPLICATION_FAILED,
|
||||
'Failed to retrieve created dashboard',
|
||||
),
|
||||
DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return newDashboard;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
DashboardException,
|
||||
DashboardExceptionCode,
|
||||
} from 'src/modules/dashboard/exceptions/dashboard.exception';
|
||||
|
||||
export const dashboardGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof DashboardException) {
|
||||
switch (error.code) {
|
||||
case DashboardExceptionCode.DASHBOARD_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED:
|
||||
throw new InternalServerError(error.message);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { ArgumentsHost, Catch } from '@nestjs/common';
|
||||
import { GqlExceptionFilter } from '@nestjs/graphql';
|
||||
|
||||
import { DashboardException } from 'src/modules/dashboard/exceptions/dashboard.exception';
|
||||
import { dashboardGraphqlApiExceptionHandler } from 'src/modules/dashboard/utils/dashboard-graphql-api-exception-handler.util';
|
||||
|
||||
@Catch(DashboardException)
|
||||
export class DashboardGraphqlApiExceptionFilter implements GqlExceptionFilter {
|
||||
catch(exception: DashboardException, _host: ArgumentsHost) {
|
||||
return dashboardGraphqlApiExceptionHandler(exception);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user