[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:
@@ -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;
|
||||
};
|
||||
|
||||
+59
-21
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+38
@@ -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} />;
|
||||
};
|
||||
+3
@@ -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[
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+8
-1
@@ -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 {}
|
||||
|
||||
+287
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
});
|
||||
|
||||
+44
-41
@@ -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);
|
||||
}
|
||||
};
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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: {
|
||||
|
||||
+23
@@ -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",
|
||||
}
|
||||
`;
|
||||
+34
@@ -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>,
|
||||
}
|
||||
`;
|
||||
+83
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
+201
@@ -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)');
|
||||
},
|
||||
);
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export const DASHBOARD_GQL_FIELDS = `
|
||||
id
|
||||
title
|
||||
pageLayoutId
|
||||
position
|
||||
createdAt
|
||||
updatedAt
|
||||
`;
|
||||
+110
@@ -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"`,
|
||||
);
|
||||
};
|
||||
+26
@@ -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,
|
||||
},
|
||||
});
|
||||
+48
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user