diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 30d5d83df5..ec2f046a77 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1211,6 +1211,16 @@ export type DuplicateWorkflowVersionStepInput = { workflowVersionId: Scalars['String']; }; +export type DuplicatedDashboard = { + __typename?: 'DuplicatedDashboard'; + createdAt: Scalars['String']; + id: Scalars['UUID']; + pageLayoutId?: Maybe; + position: Scalars['Float']; + title?: Maybe; + 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; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index cc49e7b40a..661a37b218 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1194,6 +1194,16 @@ export type DuplicateWorkflowVersionStepInput = { workflowVersionId: Scalars['String']; }; +export type DuplicatedDashboard = { + __typename?: 'DuplicatedDashboard'; + createdAt: Scalars['String']; + id: Scalars['UUID']; + pageLayoutId?: Maybe; + position: Scalars['Float']; + title?: Maybe; + 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; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DashboardActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DashboardActionsConfig.tsx index 229a345f2a..3bbd572698 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DashboardActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DashboardActionsConfig.tsx @@ -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: , }, + [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: , + }, }, 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, + }, }, }); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/components/DuplicateDashboardSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/components/DuplicateDashboardSingleRecordAction.tsx new file mode 100644 index 0000000000..c3b68fa2f3 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/components/DuplicateDashboardSingleRecordAction.tsx @@ -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 ; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys.ts new file mode 100644 index 0000000000..e411eabac6 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys.ts @@ -0,0 +1,3 @@ +export enum DashboardSingleRecordActionKeys { + DUPLICATE_DASHBOARD = 'duplicate-dashboard-single-record', +} diff --git a/packages/twenty-front/src/modules/dashboards/graphql/mutations/duplicateDashboard.ts b/packages/twenty-front/src/modules/dashboards/graphql/mutations/duplicateDashboard.ts new file mode 100644 index 0000000000..9778146da6 --- /dev/null +++ b/packages/twenty-front/src/modules/dashboards/graphql/mutations/duplicateDashboard.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/dashboards/hooks/useDuplicateDashboard.ts b/packages/twenty-front/src/modules/dashboards/hooks/useDuplicateDashboard.ts new file mode 100644 index 0000000000..5853e6bdb4 --- /dev/null +++ b/packages/twenty-front/src/modules/dashboards/hooks/useDuplicateDashboard.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts index 7e2357d163..3e9c79de1e 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts @@ -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(), diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts index 33900f2443..59c1269654 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts @@ -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(), }; diff --git a/packages/twenty-front/src/modules/page-layout/utils/getDuplicatedTitle.ts b/packages/twenty-front/src/modules/page-layout/utils/getDuplicatedTitle.ts deleted file mode 100644 index 1e6360a0ad..0000000000 --- a/packages/twenty-front/src/modules/page-layout/utils/getDuplicatedTitle.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const getDuplicatedTitle = (title: string): string => { - return title.endsWith('(Copy)') ? title : `${title} (Copy)`; -}; diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 2b2b88425d..7c9d23821c 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 3984f93a09..edf06aefe3 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -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[ diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/exceptions/page-layout.exception.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/exceptions/page-layout.exception.ts index 8dc9c42e53..96a5940dc1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/exceptions/page-layout.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/exceptions/page-layout.exception.ts @@ -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 {} @@ -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); } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts index a0a0366f5a..36fdf2f541 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts new file mode 100644 index 0000000000..abe63e1a6d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts @@ -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 { + 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(); + + 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; + } { + const originalTabIdToNewTabIdMap = new Map(); + + 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; + 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, + }), + ); + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts index d46fdb44b0..0fa9508932 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts @@ -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, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts index 0632211d47..4d3d5c0f65 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts @@ -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, }, diff --git a/packages/twenty-server/src/modules/dashboard/controllers/dashboard.controller.ts b/packages/twenty-server/src/modules/dashboard/controllers/dashboard.controller.ts new file mode 100644 index 0000000000..41fa57c1e4 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/controllers/dashboard.controller.ts @@ -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 { + return this.dashboardDuplicationService.duplicateDashboard( + id, + workspace.id, + ); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/dashboard.module.ts b/packages/twenty-server/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000000..64bc3b29cc --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/dashboard.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/modules/dashboard/dtos/duplicated-dashboard.dto.ts b/packages/twenty-server/src/modules/dashboard/dtos/duplicated-dashboard.dto.ts new file mode 100644 index 0000000000..47872f826d --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/dtos/duplicated-dashboard.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/exceptions/dashboard.exception.ts b/packages/twenty-server/src/modules/dashboard/exceptions/dashboard.exception.ts new file mode 100644 index 0000000000..d64f353a2f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/exceptions/dashboard.exception.ts @@ -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 {} + +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); + } +}; diff --git a/packages/twenty-server/src/modules/dashboard/filters/dashboard-rest-api-exception.filter.ts b/packages/twenty-server/src/modules/dashboard/filters/dashboard-rest-api-exception.filter.ts new file mode 100644 index 0000000000..5d6025c872 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/filters/dashboard-rest-api-exception.filter.ts @@ -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(); + + 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); + } + } +} diff --git a/packages/twenty-server/src/modules/dashboard/resolvers/dashboard.resolver.ts b/packages/twenty-server/src/modules/dashboard/resolvers/dashboard.resolver.ts new file mode 100644 index 0000000000..1255013b24 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/resolvers/dashboard.resolver.ts @@ -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 { + return this.dashboardDuplicationService.duplicateDashboard( + id, + workspace.id, + ); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/services/dashboard-duplication.service.ts b/packages/twenty-server/src/modules/dashboard/services/dashboard-duplication.service.ts new file mode 100644 index 0000000000..9bce2d515a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/services/dashboard-duplication.service.ts @@ -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 { + const dashboardRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + 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 + > + >, + ): Promise { + 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; + } +} diff --git a/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..e92783561a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception-handler.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception.filter.ts b/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..8810431e6a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/utils/dashboard-graphql-api-exception.filter.ts @@ -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); + } +} diff --git a/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts b/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts index 119d86d0ce..e4381b9450 100644 --- a/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts +++ b/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts @@ -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: { diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/failing-dashboard-duplication.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/failing-dashboard-duplication.integration-spec.ts.snap new file mode 100644 index 0000000000..97e984cd5c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/failing-dashboard-duplication.integration-spec.ts.snap @@ -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", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/successful-dashboard-duplication.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/successful-dashboard-duplication.integration-spec.ts.snap new file mode 100644 index 0000000000..ea508f195a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/__snapshots__/successful-dashboard-duplication.integration-spec.ts.snap @@ -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, + "id": Any, + "pageLayoutId": Any, + "position": 0, + "title": "Basic Dashboard (Copy)", + "updatedAt": Any, +} +`; + +exports[`Dashboard duplication should succeed should duplicate a dashboard with page layout and tabs 1`] = ` +{ + "createdAt": Any, + "id": Any, + "pageLayoutId": Any, + "position": 0, + "title": "Dashboard With Tabs (Copy)", + "updatedAt": Any, +} +`; + +exports[`Dashboard duplication should succeed should duplicate a dashboard with page layout, tabs and widgets 1`] = ` +{ + "createdAt": Any, + "id": Any, + "pageLayoutId": Any, + "position": 0, + "title": "Dashboard With Widgets (Copy)", + "updatedAt": Any, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/failing-dashboard-duplication.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/failing-dashboard-duplication.integration-spec.ts new file mode 100644 index 0000000000..d823d4efc6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/failing-dashboard-duplication.integration-spec.ts @@ -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); + cleanupDashboardId?: boolean; +}; + +type GlobalTestContext = { + dashboardWithDeletedPageLayoutId?: string; +}; + +const globalTestContext: GlobalTestContext = {}; + +const FAILING_TEST_CASES: EachTestingContext[] = [ + { + 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 }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/successful-dashboard-duplication.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/successful-dashboard-duplication.integration-spec.ts new file mode 100644 index 0000000000..aa8d59bc1e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/successful-dashboard-duplication.integration-spec.ts @@ -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[] = [ + { + 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)'); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-gql-fields.constants.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-gql-fields.constants.ts new file mode 100644 index 0000000000..b80733576b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-gql-fields.constants.ts @@ -0,0 +1,8 @@ +export const DASHBOARD_GQL_FIELDS = ` + id + title + pageLayoutId + position + createdAt + updatedAt +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util.ts new file mode 100644 index 0000000000..19ef340655 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util.ts @@ -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 { + createDashboard: DashboardWorkspaceEntity; +} + +interface FindDashboardResponse extends Record { + dashboard: DashboardWorkspaceEntity | null; +} + +export const createTestDashboardWithGraphQL = async (data: { + id?: string; + title: string; + position?: number; + pageLayoutId?: string; +}): Promise => { + 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; + + 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 => { + 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; + + if (response.body.errors) { + return null; + } + + return response.body.data?.dashboard ?? null; +}; + +export const destroyDashboardWithGraphQL = async ( + dashboardId: string, +): Promise => { + 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 => { + await global.testDataSource.query( + `DELETE FROM "${TEST_SCHEMA_NAME}"."dashboard"`, + ); +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard-query-factory.util.ts new file mode 100644 index 0000000000..b617803a46 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard-query-factory.util.ts @@ -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, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard.util.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard.util.ts new file mode 100644 index 0000000000..f7b1978226 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/utils/duplicate-one-dashboard.util.ts @@ -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 }; +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index ae9d50def6..2dfaf333a1 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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 { diff --git a/packages/twenty-shared/src/utils/strings/__tests__/appendCopySuffix.test.ts b/packages/twenty-shared/src/utils/strings/__tests__/appendCopySuffix.test.ts new file mode 100644 index 0000000000..5bcf07ad27 --- /dev/null +++ b/packages/twenty-shared/src/utils/strings/__tests__/appendCopySuffix.test.ts @@ -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)'); + }); +}); diff --git a/packages/twenty-shared/src/utils/strings/appendCopySuffix.ts b/packages/twenty-shared/src/utils/strings/appendCopySuffix.ts new file mode 100644 index 0000000000..c1273e776b --- /dev/null +++ b/packages/twenty-shared/src/utils/strings/appendCopySuffix.ts @@ -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}`; +}; diff --git a/packages/twenty-shared/src/utils/strings/index.ts b/packages/twenty-shared/src/utils/strings/index.ts index 89859f65b1..41aa8035d5 100644 --- a/packages/twenty-shared/src/utils/strings/index.ts +++ b/packages/twenty-shared/src/utils/strings/index.ts @@ -1 +1,2 @@ +export * from './appendCopySuffix'; export * from './capitalize';