[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
@@ -1211,6 +1211,16 @@ export type DuplicateWorkflowVersionStepInput = {
workflowVersionId: Scalars['String'];
};
export type DuplicatedDashboard = {
__typename?: 'DuplicatedDashboard';
createdAt: Scalars['String'];
id: Scalars['UUID'];
pageLayoutId?: Maybe<Scalars['UUID']>;
position: Scalars['Float'];
title?: Maybe<Scalars['String']>;
updatedAt: Scalars['String'];
};
export type EditSsoInput = {
id: Scalars['UUID'];
status: SsoIdentityProviderStatus;
@@ -1852,6 +1862,7 @@ export type Mutation = {
destroyPageLayoutWidget: Scalars['Boolean'];
disablePostgresProxy: PostgresCredentials;
dismissReconnectAccountBanner: Scalars['Boolean'];
duplicateDashboard: DuplicatedDashboard;
duplicateWorkflow: WorkflowVersionDto;
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
editSSOIdentityProvider: EditSsoOutput;
@@ -2390,6 +2401,11 @@ export type MutationDismissReconnectAccountBannerArgs = {
};
export type MutationDuplicateDashboardArgs = {
id: Scalars['UUID'];
};
export type MutationDuplicateWorkflowArgs = {
input: DuplicateWorkflowInput;
};
@@ -1194,6 +1194,16 @@ export type DuplicateWorkflowVersionStepInput = {
workflowVersionId: Scalars['String'];
};
export type DuplicatedDashboard = {
__typename?: 'DuplicatedDashboard';
createdAt: Scalars['String'];
id: Scalars['UUID'];
pageLayoutId?: Maybe<Scalars['UUID']>;
position: Scalars['Float'];
title?: Maybe<Scalars['String']>;
updatedAt: Scalars['String'];
};
export type EditSsoInput = {
id: Scalars['UUID'];
status: SsoIdentityProviderStatus;
@@ -1825,6 +1835,7 @@ export type Mutation = {
destroyPageLayoutWidget: Scalars['Boolean'];
disablePostgresProxy: PostgresCredentials;
dismissReconnectAccountBanner: Scalars['Boolean'];
duplicateDashboard: DuplicatedDashboard;
duplicateWorkflow: WorkflowVersionDto;
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
editSSOIdentityProvider: EditSsoOutput;
@@ -2342,6 +2353,11 @@ export type MutationDismissReconnectAccountBannerArgs = {
};
export type MutationDuplicateDashboardArgs = {
id: Scalars['UUID'];
};
export type MutationDuplicateWorkflowArgs = {
input: DuplicateWorkflowInput;
};
@@ -1,7 +1,10 @@
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { NoSelectionRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKeys';
import { CancelDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/CancelDashboardSingleRecordAction';
import { DuplicateDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/DuplicateDashboardSingleRecordAction';
import { EditDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/EditDashboardSingleRecordAction';
import { SaveDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/SaveDashboardSingleRecordAction';
import { DashboardSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { inheritActionsFromDefaultConfig } from '@/action-menu/actions/record-actions/utils/inheritActionsFromDefaultConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
@@ -10,7 +13,12 @@ import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { PageLayoutSingleRecordActionKeys } from '@/page-layout/actions/PageLayoutSingleRecordActionKeys';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconDeviceFloppy, IconPencil, IconX } from 'twenty-ui/display';
import {
IconCopyPlus,
IconDeviceFloppy,
IconPencil,
IconX,
} from 'twenty-ui/display';
export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
config: {
@@ -66,6 +74,26 @@ export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
availableOn: [ActionViewType.PAGE_EDIT_MODE],
component: <CancelDashboardSingleRecordAction />,
},
[DashboardSingleRecordActionKeys.DUPLICATE_DASHBOARD]: {
key: DashboardSingleRecordActionKeys.DUPLICATE_DASHBOARD,
label: msg`Duplicate Dashboard`,
shortLabel: msg`Duplicate`,
isPinned: false,
position: 6,
Icon: IconCopyPlus,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId),
availableOn: [
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
component: <DuplicateDashboardSingleRecordAction />,
},
},
actionKeys: [
NoSelectionRecordActionKeys.CREATE_NEW_RECORD,
@@ -74,6 +102,8 @@ export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
SingleRecordActionKeys.DELETE,
SingleRecordActionKeys.DESTROY,
SingleRecordActionKeys.RESTORE,
MultipleRecordsActionKeys.DELETE,
MultipleRecordsActionKeys.DESTROY,
SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW,
SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
@@ -99,49 +129,57 @@ export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
label: msg`Create new dashboard`,
},
[SingleRecordActionKeys.DELETE]: {
position: 6,
position: 7,
label: msg`Delete dashboard`,
},
[SingleRecordActionKeys.ADD_TO_FAVORITES]: {
position: 7,
isPinned: true,
[MultipleRecordsActionKeys.DELETE]: {
position: 12,
label: msg`Delete dashboards`,
},
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
[SingleRecordActionKeys.ADD_TO_FAVORITES]: {
position: 8,
isPinned: true,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
position: 9,
isPinned: true,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 10,
label: msg`Export dashboard`,
},
[SingleRecordActionKeys.DESTROY]: {
position: 10,
position: 11,
label: msg`Permanently destroy dashboard`,
},
[MultipleRecordsActionKeys.DESTROY]: {
position: 13,
label: msg`Permanently destroy dashboards`,
},
[SingleRecordActionKeys.RESTORE]: {
position: 11,
position: 14,
label: msg`Restore dashboard`,
},
[NoSelectionRecordActionKeys.GO_TO_WORKFLOWS]: {
position: 12,
},
[NoSelectionRecordActionKeys.GO_TO_PEOPLE]: {
position: 13,
},
[NoSelectionRecordActionKeys.GO_TO_COMPANIES]: {
position: 14,
},
[NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES]: {
position: 15,
},
[NoSelectionRecordActionKeys.GO_TO_SETTINGS]: {
[NoSelectionRecordActionKeys.GO_TO_PEOPLE]: {
position: 16,
},
[NoSelectionRecordActionKeys.GO_TO_TASKS]: {
[NoSelectionRecordActionKeys.GO_TO_COMPANIES]: {
position: 17,
},
[NoSelectionRecordActionKeys.GO_TO_NOTES]: {
[NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES]: {
position: 18,
},
[NoSelectionRecordActionKeys.GO_TO_SETTINGS]: {
position: 19,
},
[NoSelectionRecordActionKeys.GO_TO_TASKS]: {
position: 20,
},
[NoSelectionRecordActionKeys.GO_TO_NOTES]: {
position: 21,
},
},
});
@@ -0,0 +1,38 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDuplicateDashboard } from '@/dashboards/hooks/useDuplicateDashboard';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DuplicateDashboardSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { duplicateDashboard } = useDuplicateDashboard();
const navigate = useNavigateApp();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const handleClick = async () => {
const result = await duplicateDashboard(recordId);
if (isDefined(result) && isNonEmptyString(result.id)) {
enqueueSuccessSnackBar({
message: t`Dashboard duplicated successfully`,
});
navigate(AppPath.RecordShowPage, {
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: result.id,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to duplicate dashboard`,
});
}
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,3 @@
export enum DashboardSingleRecordActionKeys {
DUPLICATE_DASHBOARD = 'duplicate-dashboard-single-record',
}
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const DUPLICATE_DASHBOARD = gql`
mutation DuplicateDashboard($id: String!) {
duplicateDashboard(id: $id) {
id
title
pageLayoutId
position
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,88 @@
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { DUPLICATE_DASHBOARD } from '@/dashboards/graphql/mutations/duplicateDashboard';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useMutation } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
type DuplicateDashboardResult = {
id: string;
title: string | null;
pageLayoutId: string | null;
position: number;
createdAt: string;
updatedAt: string;
};
export const useDuplicateDashboard = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Dashboard,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const createOneRecordInCache = useCreateOneRecordInCache({
objectMetadataItem,
});
const [mutate] = useMutation<
{ duplicateDashboard: DuplicateDashboardResult },
{ id: string }
>(DUPLICATE_DASHBOARD, {
client: apolloCoreClient,
});
const duplicateDashboard = async (dashboardId: string) => {
const result = await mutate({
variables: { id: dashboardId },
update: (cache, { data }) => {
const record = data?.duplicateDashboard;
if (!isDefined(record)) return;
const createdRecord: ObjectRecord = {
...record,
__typename: getObjectTypename(CoreObjectNameSingular.Dashboard),
};
createOneRecordInCache(createdRecord);
const recordNode = getRecordNodeFromRecord({
objectMetadataItem,
objectMetadataItems,
record: createdRecord,
computeReferences: false,
});
if (isDefined(recordNode)) {
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [recordNode],
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
}
},
});
return result?.data?.duplicateDashboard;
};
return {
duplicateDashboard,
};
};
@@ -3,6 +3,11 @@ import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layo
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
@@ -10,14 +15,8 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '../states/pageLayoutTabSettingsOpenTabIdComponentState';
import { type PageLayoutTab } from '../types/PageLayoutTab';
import { generateDuplicatedTimestamps } from '../utils/generateDuplicatedTimestamps';
import { getDuplicatedTitle } from '../utils/getDuplicatedTitle';
export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
@@ -94,7 +93,7 @@ export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const newTab: PageLayoutTab = {
...sourceTab,
id: newTabId,
title: getDuplicatedTitle(sourceTab.title),
title: appendCopySuffix(sourceTab.title),
position: newTabPosition,
widgets: clonedWidgets,
...generateDuplicatedTimestamps(),
@@ -4,6 +4,7 @@ import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDr
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
@@ -11,10 +12,8 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { generateDuplicatedTimestamps } from '../utils/generateDuplicatedTimestamps';
import { getDuplicatedTitle } from '../utils/getDuplicatedTitle';
export const useDuplicatePageLayoutWidget = (
pageLayoutIdFromProps?: string,
@@ -77,7 +76,7 @@ export const useDuplicatePageLayoutWidget = (
const clonedWidget: PageLayoutWidget = {
...sourceWidget,
id: newWidgetId,
title: getDuplicatedTitle(sourceWidget.title),
title: appendCopySuffix(sourceWidget.title),
...generateDuplicatedTimestamps(),
};
@@ -1,3 +0,0 @@
export const getDuplicatedTitle = (title: string): string => {
return title.endsWith('(Copy)') ? title : `${title} (Copy)`;
};
@@ -56,12 +56,13 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module';
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -142,6 +143,7 @@ import { FileModule } from './file/file.module';
FlatPageLayoutTabModule,
ImpersonationModule,
TrashCleanupModule,
DashboardModule,
],
exports: [
AuditModule,
@@ -180,6 +180,30 @@ export class OpenApiService {
return paths;
}, schema.paths as OpenAPIV3_1.PathsObject);
schema.paths['/dashboards/{id}/duplicate'] = {
post: {
tags: ['dashboards'],
summary: 'Duplicate a dashboard',
description: 'Creates a duplicate of an existing dashboard',
operationId: 'duplicateDashboard',
parameters: [{ $ref: '#/components/parameters/idPath' }],
responses: {
'201': {
description: 'Dashboard duplicated successfully',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/DashboardForResponse',
},
},
},
},
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
},
} as OpenAPIV3_1.PathItemObject;
schema.webhooks = filteredObjectMetadataItems.reduce(
(paths, item) => {
paths[
@@ -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,
});
@@ -650,6 +650,44 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
// Page layout tabs must be processed before page layout widgets because
// widgets reference tabs, and the optimistic cache needs to contain the
// newly created tabs before widget validation runs
if (isDefined(flatPageLayoutTabMaps)) {
const { from: fromFlatPageLayoutTabMaps, to: toFlatPageLayoutTabMaps } =
flatPageLayoutTabMaps;
const pageLayoutTabResult =
this.workspaceMigrationV2PageLayoutTabActionsBuilderService.validateAndBuild(
{
from: fromFlatPageLayoutTabMaps,
to: toFlatPageLayoutTabMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: {
flatPageLayoutMaps:
optimisticAllFlatEntityMaps.flatPageLayoutMaps,
},
workspaceId,
},
);
this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation(
{
allFlatEntityMaps: optimisticAllFlatEntityMaps,
flatEntityMapsAndRelatedFlatEntityMaps:
pageLayoutTabResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
},
);
if (pageLayoutTabResult.status === 'fail') {
orchestratorFailureReport.pageLayoutTab.push(
...pageLayoutTabResult.errors,
);
} else {
orchestratorActionsReport.pageLayoutTab = pageLayoutTabResult.actions;
}
}
if (isDefined(flatPageLayoutWidgetMaps)) {
const {
from: fromFlatPageLayoutWidgetMaps,
@@ -688,41 +726,6 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
if (isDefined(flatPageLayoutTabMaps)) {
const { from: fromFlatPageLayoutTabMaps, to: toFlatPageLayoutTabMaps } =
flatPageLayoutTabMaps;
const pageLayoutTabResult =
this.workspaceMigrationV2PageLayoutTabActionsBuilderService.validateAndBuild(
{
from: fromFlatPageLayoutTabMaps,
to: toFlatPageLayoutTabMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: {
flatPageLayoutMaps:
optimisticAllFlatEntityMaps.flatPageLayoutMaps,
},
workspaceId,
},
);
this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation(
{
allFlatEntityMaps: optimisticAllFlatEntityMaps,
flatEntityMapsAndRelatedFlatEntityMaps:
pageLayoutTabResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
},
);
if (pageLayoutTabResult.status === 'fail') {
orchestratorFailureReport.pageLayoutTab.push(
...pageLayoutTabResult.errors,
);
} else {
orchestratorActionsReport.pageLayoutTab = pageLayoutTabResult.actions;
}
}
const allErrors = Object.values(orchestratorFailureReport);
if (allErrors.some((report) => report.length > 0)) {
@@ -824,17 +827,17 @@ export class WorkspaceMigrationBuildOrchestratorService {
...aggregatedOrchestratorActionsReport.pageLayout.updated,
///
// Page layout widgets
...aggregatedOrchestratorActionsReport.pageLayoutWidget.deleted,
...aggregatedOrchestratorActionsReport.pageLayoutWidget.created,
...aggregatedOrchestratorActionsReport.pageLayoutWidget.updated,
///
// Page layout tabs
...aggregatedOrchestratorActionsReport.pageLayoutTab.deleted,
...aggregatedOrchestratorActionsReport.pageLayoutTab.created,
...aggregatedOrchestratorActionsReport.pageLayoutTab.updated,
///
// Page layout widgets
...aggregatedOrchestratorActionsReport.pageLayoutWidget.deleted,
...aggregatedOrchestratorActionsReport.pageLayoutWidget.created,
...aggregatedOrchestratorActionsReport.pageLayoutWidget.updated,
///
],
workspaceId,
},
@@ -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);
}
};
@@ -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,
);
}
}
@@ -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;
}
}
@@ -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;
};
@@ -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);
}
}
@@ -145,6 +145,8 @@ export const TEST_PIE_CHART_CONFIG = {
aggregateOperation: AggregateOperations.SUM,
orderBy: GraphOrderBy.VALUE_DESC,
displayDataLabel: true,
displayLegend: true,
showCenterMetric: true,
color: 'yellow',
description: 'Distribution by category',
filter: {
@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
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",
}
`;
@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
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>,
}
`;
@@ -0,0 +1,83 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
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 { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
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 });
},
);
});
@@ -0,0 +1,201 @@
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 { 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 { 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 { 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 { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
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 testPageLayoutWidgetId: string;
let testDashboardId: string;
let duplicatedDashboardId: string;
let duplicatedPageLayoutId: string;
let currentTestContextId: string;
const cleanup = async () => {
if (isNonEmptyString(duplicatedDashboardId)) {
await destroyDashboardWithGraphQL(duplicatedDashboardId);
duplicatedDashboardId = '';
}
if (isNonEmptyString(duplicatedPageLayoutId)) {
await destroyOnePageLayout({
expectToFail: false,
input: { id: duplicatedPageLayoutId },
});
duplicatedPageLayoutId = '';
}
if (isNonEmptyString(testPageLayoutWidgetId)) {
await destroyOnePageLayoutWidget({
expectToFail: false,
input: { id: testPageLayoutWidgetId },
});
testPageLayoutWidgetId = '';
}
if (isNonEmptyString(testPageLayoutTabId)) {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: testPageLayoutTabId },
});
testPageLayoutTabId = '';
}
if (isNonEmptyString(testDashboardId)) {
await destroyDashboardWithGraphQL(testDashboardId);
testDashboardId = '';
}
if (isNonEmptyString(currentTestContextId)) {
await destroyDashboardWithGraphQL(currentTestContextId);
currentTestContextId = '';
}
if (isNonEmptyString(testPageLayoutId)) {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
testPageLayoutId = '';
}
};
beforeEach(async () => {
testPageLayoutId = '';
testPageLayoutTabId = '';
testPageLayoutWidgetId = '';
testDashboardId = '';
duplicatedDashboardId = '';
duplicatedPageLayoutId = '';
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) {
const { data: widgetData } = await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Test Widget',
type: WidgetType.IFRAME,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
});
testPageLayoutWidgetId = widgetData.createPageLayoutWidget.id;
}
}
const dashboard = await createTestDashboardWithGraphQL({
id,
title,
pageLayoutId: testPageLayoutId,
});
testDashboardId = dashboard.id;
const { data } = await duplicateOneDashboard({
expectToFail: false,
input: { id: testDashboardId },
});
duplicatedDashboardId = data.duplicateDashboard.id;
duplicatedPageLayoutId = data.duplicateDashboard.pageLayoutId ?? '';
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)');
},
);
});
@@ -0,0 +1,8 @@
export const DASHBOARD_GQL_FIELDS = `
id
title
pageLayoutId
position
createdAt
updatedAt
`;
@@ -0,0 +1,110 @@
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 FindDashboardResponse extends Record<string, unknown> {
dashboard: DashboardWorkspaceEntity | null;
}
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($filter: DashboardFilterInput!) {
destroyDashboard(filter: $filter) {
id
}
}
`,
variables: {
filter: { id: { eq: dashboardId } },
},
};
await makeGraphqlAPIRequest(operation);
};
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
export const cleanupDashboardRecords = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM "${TEST_SCHEMA_NAME}"."dashboard"`,
);
};
@@ -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,
},
});
@@ -0,0 +1,48 @@
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-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 makeGraphqlAPIRequest(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 };
};
@@ -103,6 +103,7 @@ export { removeUndefinedFields } from './removeUndefinedFields';
export { safeParseRelativeDateFilterJSONStringified } from './safeParseRelativeDateFilterJSONStringified';
export { getGenericOperationName } from './sentry/getGenericOperationName';
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
export { appendCopySuffix } from './strings/appendCopySuffix';
export { capitalize } from './strings/capitalize';
export { uncapitalize } from './strings/uncapitalize';
export type {
@@ -0,0 +1,15 @@
import { appendCopySuffix } from '@/utils/strings/appendCopySuffix';
describe('appendCopySuffix', () => {
it('should add (Copy) suffix to a string', () => {
expect(appendCopySuffix('My Dashboard')).toBe('My Dashboard (Copy)');
});
it('should not add (Copy) suffix if string already ends with (Copy)', () => {
expect(appendCopySuffix('My Dashboard (Copy)')).toBe('My Dashboard (Copy)');
});
it('should not add (Copy) suffix if string ends with (copy) - case insensitive', () => {
expect(appendCopySuffix('My Dashboard (copy)')).toBe('My Dashboard (copy)');
});
});
@@ -0,0 +1,16 @@
import { isNonEmptyString } from '@sniptt/guards';
const COPY_SUFFIX = '(Copy)';
const COPY_SUFFIX_LOWERCASE = '(copy)';
export const appendCopySuffix = (value: string): string => {
if (!isNonEmptyString(value)) {
return value;
}
if (value.toLowerCase().endsWith(COPY_SUFFIX_LOWERCASE)) {
return value;
}
return `${value} ${COPY_SUFFIX}`;
};
@@ -1 +1,2 @@
export * from './appendCopySuffix';
export * from './capitalize';