Migrate page layout services (#16443)

Last step of the page layout migration:
- Migrate services
- Write integration tests
This commit is contained in:
Raphaël Bosi
2025-12-10 14:49:44 +01:00
committed by GitHub
parent 1839f8e946
commit f48adb5f07
157 changed files with 6099 additions and 7586 deletions
@@ -5,10 +5,10 @@ import { FLAT_DATABASE_EVENT_TRIGGER_EDITABLE_PROPERTIES } from 'src/engine/meta
import { FLAT_AGENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-agent/constants/flat-agent-editable-properties.constant';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout/constants/flat-page-layout-editable-properties.constant';
import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-widget/constants/flat-page-layout-widget-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout/constants/flat-page-layout-editable-properties.constant';
import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role-target/constants/flat-role-target-editable-properties.constant';
import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
@@ -115,7 +115,7 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
],
},
pageLayout: {
propertiesToCompare: [...FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES],
propertiesToCompare: [...FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES, 'deletedAt'],
propertiesToStringify: [],
},
pageLayoutWidget: {
@@ -126,7 +126,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
propertiesToStringify: ['gridPosition', 'configuration'],
},
pageLayoutTab: {
propertiesToCompare: [...FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES],
propertiesToCompare: [
...FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
'deletedAt',
],
propertiesToStringify: [],
},
} as const satisfies {
@@ -0,0 +1,40 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
export type FromCreatePageLayoutTabInputToFlatPageLayoutTabToCreateArgs = {
createPageLayoutTabInput: CreatePageLayoutTabInput;
workspaceId: string;
workspaceCustomApplicationId: string;
};
export const fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate = ({
createPageLayoutTabInput: rawCreatePageLayoutTabInput,
workspaceId,
workspaceCustomApplicationId,
}: FromCreatePageLayoutTabInputToFlatPageLayoutTabToCreateArgs): FlatPageLayoutTab => {
const createPageLayoutTabInput =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreatePageLayoutTabInput,
['title'],
);
const createdAt = new Date().toISOString();
const pageLayoutTabId = v4();
return {
id: pageLayoutTabId,
title: createPageLayoutTabInput.title,
position: createPageLayoutTabInput.position ?? 0,
pageLayoutId: createPageLayoutTabInput.pageLayoutId,
workspaceId,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: pageLayoutTabId,
applicationId: workspaceCustomApplicationId,
widgetIds: [],
};
};
@@ -0,0 +1,44 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
export type DeletePageLayoutTabInput = {
id: string;
};
export const fromDeletePageLayoutTabInputToFlatPageLayoutTabOrThrow = ({
deletePageLayoutTabInput: rawDeletePageLayoutTabInput,
flatPageLayoutTabMaps,
}: {
deletePageLayoutTabInput: DeletePageLayoutTabInput;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
}): FlatPageLayoutTab => {
const { id: pageLayoutTabId } = extractAndSanitizeObjectStringFields(
rawDeletePageLayoutTabInput,
['id'],
);
const existingFlatPageLayoutTabToDelete =
flatPageLayoutTabMaps.byId[pageLayoutTabId];
if (!isDefined(existingFlatPageLayoutTabToDelete)) {
throw new PageLayoutTabException(
t`Page layout tab to delete not found`,
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
return {
...existingFlatPageLayoutTabToDelete,
deletedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,41 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
export type DestroyPageLayoutTabInput = {
id: string;
};
export const fromDestroyPageLayoutTabInputToFlatPageLayoutTabOrThrow = ({
destroyPageLayoutTabInput,
flatPageLayoutTabMaps,
}: {
destroyPageLayoutTabInput: DestroyPageLayoutTabInput;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
}): FlatPageLayoutTab => {
const { id: pageLayoutTabId } = extractAndSanitizeObjectStringFields(
destroyPageLayoutTabInput,
['id'],
);
const existingFlatPageLayoutTabToDestroy =
flatPageLayoutTabMaps.byId[pageLayoutTabId];
if (!isDefined(existingFlatPageLayoutTabToDestroy)) {
throw new PageLayoutTabException(
t`Page layout tab to destroy not found`,
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
return existingFlatPageLayoutTabToDestroy;
};
@@ -0,0 +1,51 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
export type RestorePageLayoutTabInput = {
id: string;
};
export const fromRestorePageLayoutTabInputToFlatPageLayoutTabOrThrow = ({
restorePageLayoutTabInput,
flatPageLayoutTabMaps,
}: {
restorePageLayoutTabInput: RestorePageLayoutTabInput;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
}): FlatPageLayoutTab => {
const { id: pageLayoutTabId } = extractAndSanitizeObjectStringFields(
restorePageLayoutTabInput,
['id'],
);
const existingFlatPageLayoutTabToRestore =
flatPageLayoutTabMaps.byId[pageLayoutTabId];
if (!isDefined(existingFlatPageLayoutTabToRestore)) {
throw new PageLayoutTabException(
t`Page layout tab to restore not found`,
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
if (!isDefined(existingFlatPageLayoutTabToRestore.deletedAt)) {
throw new PageLayoutTabException(
t`Page layout tab is not deleted and cannot be restored`,
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
return {
...existingFlatPageLayoutTabToRestore,
deletedAt: null,
};
};
@@ -0,0 +1,54 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant';
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 { type UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export type UpdatePageLayoutTabInputWithId = {
id: string;
update: UpdatePageLayoutTabInput;
};
export const fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow = ({
updatePageLayoutTabInput: rawUpdatePageLayoutTabInput,
flatPageLayoutTabMaps,
}: {
updatePageLayoutTabInput: UpdatePageLayoutTabInputWithId;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
}): FlatPageLayoutTab => {
const { id: pageLayoutTabToUpdateId } = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutTabInput,
['id'],
);
const existingFlatPageLayoutTabToUpdate =
flatPageLayoutTabMaps.byId[pageLayoutTabToUpdateId];
if (!isDefined(existingFlatPageLayoutTabToUpdate)) {
throw new PageLayoutTabException(
t`Page layout tab to update not found`,
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutTabInput.update,
FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutTabToUpdate,
properties: [...FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES],
update: updatedEditableFieldProperties,
});
};
@@ -0,0 +1,30 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
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';
export type FlatPageLayoutTabWithWidgets = FlatPageLayoutTab & {
widgets: FlatPageLayoutWidget[];
};
export const reconstructFlatPageLayoutTabWithWidgets = ({
tab,
flatPageLayoutWidgetMaps,
}: {
tab: FlatPageLayoutTab;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}): FlatPageLayoutTabWithWidgets => {
const widgets = Object.values(flatPageLayoutWidgetMaps.byId).filter(
(widget): widget is FlatPageLayoutWidget =>
isDefined(widget) &&
widget.pageLayoutTabId === tab.id &&
!isDefined(widget.deletedAt),
);
return {
...tab,
widgets,
widgetIds: widgets.map((widget) => widget.id),
};
};
@@ -0,0 +1,51 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
export type RestorePageLayoutWidgetInput = {
id: string;
};
export const fromRestorePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow = ({
restorePageLayoutWidgetInput,
flatPageLayoutWidgetMaps,
}: {
restorePageLayoutWidgetInput: RestorePageLayoutWidgetInput;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}): FlatPageLayoutWidget => {
const { id: pageLayoutWidgetId } = extractAndSanitizeObjectStringFields(
restorePageLayoutWidgetInput,
['id'],
);
const existingFlatPageLayoutWidgetToRestore =
flatPageLayoutWidgetMaps.byId[pageLayoutWidgetId];
if (!isDefined(existingFlatPageLayoutWidgetToRestore)) {
throw new PageLayoutWidgetException(
t`Page layout widget to restore not found`,
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
if (!isDefined(existingFlatPageLayoutWidgetToRestore.deletedAt)) {
throw new PageLayoutWidgetException(
t`Page layout widget is not deleted and cannot be restored`,
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
return {
...existingFlatPageLayoutWidgetToRestore,
deletedAt: null,
};
};
@@ -1,7 +1,6 @@
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
export const FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES: (keyof FlatPageLayout)[] = [
'name',
'type',
'objectMetadataId',
];
export const FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES: (keyof Pick<
FlatPageLayout,
'name' | 'type' | 'objectMetadataId'
>)[] = ['name', 'type', 'objectMetadataId'];
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceFlatPageLayoutMapCacheService } from 'src/engine/metadata-modules/flat-page-layout/services/workspace-flat-page-layout-map-cache.service';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
@Module({
imports: [TypeOrmModule.forFeature([PageLayoutEntity], 'core')],
imports: [TypeOrmModule.forFeature([PageLayoutEntity, PageLayoutTabEntity])],
providers: [WorkspaceFlatPageLayoutMapCacheService],
exports: [WorkspaceFlatPageLayoutMapCacheService],
})
@@ -0,0 +1,6 @@
export * from './types/flat-page-layout.type';
export * from './types/flat-page-layout-maps.type';
export * from './constants/flat-page-layout-editable-properties.constant';
export * from './utils/transform-page-layout-entity-to-flat-page-layout.util';
export * from './services/workspace-flat-page-layout-map-cache.service';
export * from './flat-page-layout.module';
@@ -0,0 +1,41 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { type CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
export type FromCreatePageLayoutInputToFlatPageLayoutToCreateArgs = {
createPageLayoutInput: CreatePageLayoutInput;
workspaceId: string;
workspaceCustomApplicationId: string;
};
export const fromCreatePageLayoutInputToFlatPageLayoutToCreate = ({
createPageLayoutInput: rawCreatePageLayoutInput,
workspaceId,
workspaceCustomApplicationId,
}: FromCreatePageLayoutInputToFlatPageLayoutToCreateArgs): FlatPageLayout => {
const createPageLayoutInput =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreatePageLayoutInput,
['name'],
);
const createdAt = new Date().toISOString();
const pageLayoutId = v4();
return {
id: pageLayoutId,
name: createPageLayoutInput.name,
type: createPageLayoutInput.type ?? PageLayoutType.RECORD_PAGE,
objectMetadataId: createPageLayoutInput.objectMetadataId ?? null,
workspaceId,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: pageLayoutId,
applicationId: workspaceCustomApplicationId,
tabIds: [],
};
};
@@ -0,0 +1,43 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
export type DeletePageLayoutInput = {
id: string;
};
export const fromDeletePageLayoutInputToFlatPageLayoutOrThrow = ({
deletePageLayoutInput: rawDeletePageLayoutInput,
flatPageLayoutMaps,
}: {
deletePageLayoutInput: DeletePageLayoutInput;
flatPageLayoutMaps: FlatPageLayoutMaps;
}): FlatPageLayout => {
const { id: pageLayoutId } = extractAndSanitizeObjectStringFields(
rawDeletePageLayoutInput,
['id'],
);
const existingFlatPageLayoutToDelete = flatPageLayoutMaps.byId[pageLayoutId];
if (!isDefined(existingFlatPageLayoutToDelete)) {
throw new PageLayoutException(
t`Page layout to delete not found`,
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return {
...existingFlatPageLayoutToDelete,
deletedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,40 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
export type DestroyPageLayoutInput = {
id: string;
};
export const fromDestroyPageLayoutInputToFlatPageLayoutOrThrow = ({
destroyPageLayoutInput,
flatPageLayoutMaps,
}: {
destroyPageLayoutInput: DestroyPageLayoutInput;
flatPageLayoutMaps: FlatPageLayoutMaps;
}): FlatPageLayout => {
const { id: pageLayoutId } = extractAndSanitizeObjectStringFields(
destroyPageLayoutInput,
['id'],
);
const existingFlatPageLayoutToDestroy = flatPageLayoutMaps.byId[pageLayoutId];
if (!isDefined(existingFlatPageLayoutToDestroy)) {
throw new PageLayoutException(
t`Page layout to destroy not found`,
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return existingFlatPageLayoutToDestroy;
};
@@ -0,0 +1,50 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
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 {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
export type RestorePageLayoutInput = {
id: string;
};
export const fromRestorePageLayoutInputToFlatPageLayoutOrThrow = ({
restorePageLayoutInput,
flatPageLayoutMaps,
}: {
restorePageLayoutInput: RestorePageLayoutInput;
flatPageLayoutMaps: FlatPageLayoutMaps;
}): FlatPageLayout => {
const { id: pageLayoutId } = extractAndSanitizeObjectStringFields(
restorePageLayoutInput,
['id'],
);
const existingFlatPageLayoutToRestore = flatPageLayoutMaps.byId[pageLayoutId];
if (!isDefined(existingFlatPageLayoutToRestore)) {
throw new PageLayoutException(
t`Page layout to restore not found`,
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
if (!isDefined(existingFlatPageLayoutToRestore.deletedAt)) {
throw new PageLayoutException(
t`Page layout is not deleted and cannot be restored`,
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
);
}
return {
...existingFlatPageLayoutToRestore,
deletedAt: null,
};
};
@@ -0,0 +1,54 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout/constants/flat-page-layout-editable-properties.constant';
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 { type UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export type UpdatePageLayoutInputWithId = {
id: string;
update: UpdatePageLayoutInput;
};
export const fromUpdatePageLayoutInputToFlatPageLayoutToUpdateOrThrow = ({
updatePageLayoutInput: rawUpdatePageLayoutInput,
flatPageLayoutMaps,
}: {
updatePageLayoutInput: UpdatePageLayoutInputWithId;
flatPageLayoutMaps: FlatPageLayoutMaps;
}): FlatPageLayout => {
const { id: pageLayoutToUpdateId } = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutInput,
['id'],
);
const existingFlatPageLayoutToUpdate =
flatPageLayoutMaps.byId[pageLayoutToUpdateId];
if (!isDefined(existingFlatPageLayoutToUpdate)) {
throw new PageLayoutException(
t`Page layout to update not found`,
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutInput.update,
FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutToUpdate,
properties: FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
});
};
@@ -0,0 +1,53 @@
import { isDefined } from 'twenty-shared/utils';
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 { 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 { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
export type FlatPageLayoutTabWithWidgets = FlatPageLayoutTab & {
widgets: FlatPageLayoutWidget[];
};
export type FlatPageLayoutWithTabsAndWidgets = FlatPageLayout & {
tabs: FlatPageLayoutTabWithWidgets[];
};
export const reconstructFlatPageLayoutWithTabsAndWidgets = ({
layout,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
}: {
layout: FlatPageLayout;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}): FlatPageLayoutWithTabsAndWidgets => {
const tabs = Object.values(flatPageLayoutTabMaps.byId)
.filter(isDefined)
.filter(
(tab) => tab.pageLayoutId === layout.id && !isDefined(tab.deletedAt),
)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
const tabsWithWidgets: FlatPageLayoutTabWithWidgets[] = tabs.map((tab) => {
const widgets = Object.values(flatPageLayoutWidgetMaps.byId)
.filter(isDefined)
.filter(
(widget) =>
widget.pageLayoutTabId === tab.id && !isDefined(widget.deletedAt),
);
return {
...tab,
widgets,
widgetIds: widgets.map((widget) => widget.id),
};
});
return {
...layout,
tabs: tabsWithWidgets,
tabIds: tabsWithWidgets.map((tab) => tab.id),
};
};
@@ -11,20 +11,19 @@ import {
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
import { type PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutRestApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/filters/page-layout-rest-api-exception.filter';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
import { type PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutRestApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/filters/page-layout-rest-api-exception.filter';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
@Controller('rest/metadata/pageLayouts')
@UseGuards(WorkspaceAuthGuard)
@@ -87,7 +86,7 @@ export class PageLayoutController {
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutEntity> {
): Promise<PageLayoutDTO> {
const deletedPageLayout = await this.pageLayoutService.delete(
id,
workspace.id,
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module';
import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module';
import { FlatPageLayoutModule } from 'src/engine/metadata-modules/flat-page-layout/flat-page-layout.module';
import { PageLayoutTabController } from 'src/engine/metadata-modules/page-layout/controllers/page-layout-tab.controller';
import { PageLayoutWidgetController } from 'src/engine/metadata-modules/page-layout/controllers/page-layout-widget.controller';
import { PageLayoutController } from 'src/engine/metadata-modules/page-layout/controllers/page-layout.controller';
@@ -22,6 +25,7 @@ import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/servi
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
@@ -35,11 +39,14 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
TwentyORMModule,
PermissionsModule,
FeatureFlagModule,
I18nModule,
WorkspaceCacheStorageModule,
WorkspaceMigrationV2Module,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
FlatPageLayoutModule,
FlatPageLayoutTabModule,
FlatPageLayoutWidgetModule,
ApplicationModule,
],
controllers: [
PageLayoutController,
@@ -54,6 +61,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
PageLayoutTabResolver,
PageLayoutWidgetResolver,
PageLayoutUpdateService,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [PageLayoutService, PageLayoutTabService, PageLayoutWidgetService],
})
@@ -1,22 +1,29 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
import { UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { type 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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
import { UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@Resolver(() => PageLayoutTabDTO)
@UseInterceptors(WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@@ -1,4 +1,9 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import {
Args,
Mutation,
@@ -23,8 +28,10 @@ import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout/dto
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
import { injectWidgetConfigurationDiscriminator } from 'src/engine/metadata-modules/page-layout/utils/inject-widget-configuration-discriminator.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@Resolver(() => PageLayoutWidgetDTO)
@UseInterceptors(WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@@ -1,10 +1,20 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
@@ -12,13 +22,10 @@ import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page
import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@Resolver(() => PageLayoutDTO)
@UseInterceptors(WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@@ -1,606 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { type PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import {
generatePageLayoutTabExceptionMessage,
PageLayoutTabException,
PageLayoutTabExceptionCode,
PageLayoutTabExceptionMessageKey,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
describe('PageLayoutTabService', () => {
let pageLayoutTabService: PageLayoutTabService;
let pageLayoutTabRepository: Repository<PageLayoutTabEntity>;
let pageLayoutService: PageLayoutService;
const mockPageLayoutTab = {
id: 'page-layout-tab-id',
title: 'Test Tab',
position: 0,
pageLayoutId: 'page-layout-id',
pageLayout: {} as any,
workspaceId: 'workspace-id',
workspace: {} as any,
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
application: {} as ApplicationEntity,
applicationId: 'application-id',
universalIdentifier: 'universal-identifier',
} as PageLayoutTabEntity;
const mockWidget = {
id: 'widget-1',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'page-layout-tab-id',
objectMetadataId: 'object-metadata-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as PageLayoutWidgetEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutTabService,
{
provide: getRepositoryToken(PageLayoutTabEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
provide: PageLayoutService,
useValue: {
findByIdOrThrow: jest.fn(),
},
},
],
}).compile();
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
pageLayoutTabRepository = module.get<Repository<PageLayoutTabEntity>>(
getRepositoryToken(PageLayoutTabEntity),
);
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
});
it('should be defined', () => {
expect(pageLayoutTabService).toBeDefined();
});
describe('findByPageLayoutId', () => {
it('should return page layout tabs for a page layout id', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const expectedTabs = [mockPageLayoutTab];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted: false,
});
expect(result).toEqual(expectedTabs);
});
it('should return empty array when no tabs are found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
jest.spyOn(pageLayoutTabRepository, 'find').mockResolvedValue([]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result).toEqual([]);
});
it('should order tabs by position in ascending order', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const tab1 = { ...mockPageLayoutTab, id: 'tab-1', position: 2 };
const tab2 = { ...mockPageLayoutTab, id: 'tab-2', position: 0 };
const tab3 = { ...mockPageLayoutTab, id: 'tab-3', position: 1 };
const expectedTabs = [tab2, tab3, tab1];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted: false,
});
expect(result).toEqual(expectedTabs);
});
it('should include widgets relation', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const widget1 = { ...mockWidget, id: 'widget-1', type: WidgetType.VIEW };
const widget2 = {
...mockWidget,
id: 'widget-2',
type: WidgetType.FIELDS,
};
const tabWithWidgets = {
...mockPageLayoutTab,
widgets: [widget1, widget2],
};
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue([tabWithWidgets]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets[0].id).toEqual('widget-1');
expect(result[0].widgets[1].id).toEqual('widget-2');
});
});
describe('findByIdOrThrow', () => {
it('should return page layout tab when found', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
const result = await pageLayoutTabService.findByIdOrThrow(
id,
workspaceId,
);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['widgets'],
});
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw exception when page layout tab is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('create', () => {
it('should create a new page layout tab successfully', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
id: 'page-layout-tab-id',
title: 'New Tab',
pageLayoutId: 'page-layout-id',
position: 1,
};
const mockPageLayout = {
id: 'page-layout-id',
applicationId: 'application-id',
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout as any);
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab as any);
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
const result = await pageLayoutTabService.create(
pageLayoutTabData,
workspaceId,
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
pageLayoutTabData.pageLayoutId,
workspaceId,
undefined,
);
expect(pageLayoutTabRepository.insert).toHaveBeenCalledWith({
...pageLayoutTabData,
workspaceId,
applicationId: 'application-id',
universalIdentifier: expect.any(String),
});
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when title is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
pageLayoutId: 'page-layout-id',
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when page layout does not exist', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
title: 'New Tab',
pageLayoutId: 'non-existent-page-layout-id',
};
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
'Page layout not found',
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when page layout is not found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
title: 'New Tab',
pageLayoutId: 'non-existent-page-layout-id',
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(new Error('Page layout not found'));
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow();
});
});
describe('update', () => {
it('should update a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Tab' };
const updatedTab = { ...mockPageLayoutTab, title: 'Updated Tab' };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(updatedTab);
const result = await pageLayoutTabService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutTabRepository.update).toHaveBeenCalledWith(
{ id },
updateData,
);
expect(result).toEqual(updatedTab);
});
it('should throw an exception when tab to update is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Tab' };
jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.update(id, workspaceId, updateData),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('delete', () => {
it('should soft delete a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab);
jest
.spyOn(pageLayoutTabRepository, 'softDelete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutTabService.delete(id, workspaceId);
expect(pageLayoutTabRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when tab to delete is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.delete(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('destroy', () => {
it('should permanently delete a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
jest
.spyOn(pageLayoutTabRepository, 'delete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutTabService.destroy(id, workspaceId);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutTabRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBe(true);
});
it('should throw an exception when tab to destroy is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.destroy(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.destroy(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
});
describe('restore', () => {
it('should restore a deleted page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const deletedTab = { ...mockPageLayoutTab, deletedAt: new Date() };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(deletedTab);
jest
.spyOn(pageLayoutTabRepository, 'restore')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab);
const result = await pageLayoutTabService.restore(id, workspaceId);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
select: {
id: true,
deletedAt: true,
pageLayoutId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutTabRepository.restore).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when tab to restore is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
it('should throw an exception when tab is not deleted', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const notDeletedTab = { ...mockPageLayoutTab, deletedAt: null };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(notDeletedTab);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when parent page layout is not accessible', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const deletedTab = {
...mockPageLayoutTab,
deletedAt: new Date(),
pageLayoutId: 'deleted-page-layout-id',
};
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(deletedTab);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
'Page layout not found',
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'message',
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-page-layout-id',
workspaceId,
undefined,
);
});
});
});
@@ -1,764 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { DataSource, type EntityManager } from 'typeorm';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input';
import { type UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input';
import { type PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { type PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { type PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
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';
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';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
describe('PageLayoutUpdateService', () => {
let pageLayoutUpdateService: PageLayoutUpdateService;
let pageLayoutService: PageLayoutService;
let pageLayoutTabService: PageLayoutTabService;
let pageLayoutWidgetService: PageLayoutWidgetService;
let mockTransactionManager: EntityManager;
let mockDataSource: DataSource;
const mockPageLayout = {
id: 'page-layout-id',
name: 'Test Page Layout',
workspaceId: 'workspace-id',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
tabs: [],
workspace: {} as WorkspaceEntity,
objectMetadata: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
universalIdentifier: 'universal-identifier',
applicationId: 'application-id',
application: {} as ApplicationEntity,
} as PageLayoutEntity;
const mockTab = {
id: 'tab-1',
title: 'Test Tab',
position: 0,
pageLayoutId: 'page-layout-id',
workspaceId: 'workspace-id',
workspace: {} as WorkspaceEntity,
pageLayout: {} as PageLayoutEntity,
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
application: {} as ApplicationEntity,
applicationId: 'application-id',
universalIdentifier: 'universal-identifier',
} as PageLayoutTabEntity;
const mockWidget = {
id: 'widget-1',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'tab-1',
workspaceId: 'workspace-id',
objectMetadataId: 'object-metadata-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as PageLayoutWidgetEntity;
beforeEach(async () => {
jest.clearAllMocks();
mockTransactionManager = {} as EntityManager;
mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: mockTransactionManager,
}),
} as unknown as DataSource;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutUpdateService,
{
provide: PageLayoutService,
useValue: {
findByIdOrThrow: jest.fn(),
update: jest.fn(),
},
},
{
provide: PageLayoutTabService,
useValue: {
findByPageLayoutId: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: PageLayoutWidgetService,
useValue: {
findByPageLayoutTabId: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: DataSource,
useValue: mockDataSource,
},
],
}).compile();
pageLayoutUpdateService = module.get<PageLayoutUpdateService>(
PageLayoutUpdateService,
);
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
pageLayoutWidgetService = module.get<PageLayoutWidgetService>(
PageLayoutWidgetService,
);
});
describe('updatePageLayoutWithTabs', () => {
it('should update page layout and handle tabs', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const input = {
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
tabs: [
{
id: 'tab-1',
title: 'Updated Tab',
position: 0,
widgets: [
{
id: 'widget-1',
title: 'Updated Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: null,
},
],
},
],
};
const updatedPageLayout = {
...mockPageLayout,
name: 'Updated Page Layout',
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValueOnce(mockPageLayout)
.mockResolvedValueOnce(updatedPageLayout);
jest
.spyOn(pageLayoutService, 'update')
.mockResolvedValue(updatedPageLayout);
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue([mockTab]);
jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({
...mockTab,
title: 'Updated Tab',
});
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([mockWidget]);
jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({
...mockWidget,
title: 'Updated Widget',
});
const result = await pageLayoutUpdateService.updatePageLayoutWithTabs({
id,
workspaceId,
input,
transactionManager: mockTransactionManager,
});
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
mockTransactionManager,
);
expect(pageLayoutService.update).toHaveBeenCalledWith(
id,
workspaceId,
{
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
},
mockTransactionManager,
);
expect(result).toEqual(updatedPageLayout);
});
it('should throw error when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const input = {
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
tabs: [],
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(new Error('Page layout not found'));
await expect(
pageLayoutUpdateService.updatePageLayoutWithTabs({
id,
workspaceId,
input,
transactionManager: mockTransactionManager,
}),
).rejects.toThrow('Page layout not found');
});
});
describe('updatePageLayoutTabs', () => {
it('should create new tabs', async () => {
const pageLayoutId = 'page-layout-id';
const workspaceId = 'workspace-id';
const tabs = [
{
id: 'new-tab-id',
title: 'New Tab',
position: 0,
widgets: [],
},
];
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue([]);
jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({
...mockTab,
id: 'new-tab-id',
title: 'New Tab',
position: 0,
});
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([]);
await pageLayoutUpdateService['updatePageLayoutTabs']({
pageLayoutId,
workspaceId,
tabs,
transactionManager: mockTransactionManager,
});
expect(pageLayoutTabService.create).toHaveBeenCalledWith(
{
id: 'new-tab-id',
title: 'New Tab',
position: 0,
pageLayoutId,
widgets: [],
},
workspaceId,
mockTransactionManager,
);
});
it('should update existing tabs', async () => {
const pageLayoutId = 'page-layout-id';
const workspaceId = 'workspace-id';
const tabs = [
{
id: 'tab-1',
title: 'Updated Tab',
position: 0,
widgets: [],
},
];
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue([mockTab]);
jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({
...mockTab,
title: 'Updated Tab',
});
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([]);
await pageLayoutUpdateService['updatePageLayoutTabs']({
pageLayoutId,
workspaceId,
tabs,
transactionManager: mockTransactionManager,
});
expect(pageLayoutTabService.update).toHaveBeenCalledWith(
'tab-1',
workspaceId,
{
id: 'tab-1',
title: 'Updated Tab',
position: 0,
},
mockTransactionManager,
);
});
it('should delete removed tabs', async () => {
const pageLayoutId = 'page-layout-id';
const workspaceId = 'workspace-id';
const tabs: UpdatePageLayoutTabWithWidgetsInput[] = [];
const existingTabs = [mockTab];
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue(existingTabs);
jest.spyOn(pageLayoutTabService, 'delete').mockResolvedValue(mockTab);
await pageLayoutUpdateService['updatePageLayoutTabs']({
pageLayoutId,
workspaceId,
tabs,
transactionManager: mockTransactionManager,
});
expect(pageLayoutTabService.delete).toHaveBeenCalledWith(
'tab-1',
workspaceId,
mockTransactionManager,
);
});
it('should handle tabs with mixed operations (create, update, delete)', async () => {
const pageLayoutId = 'page-layout-id';
const workspaceId = 'workspace-id';
const tabs = [
{
id: 'tab-1',
title: 'Updated Tab',
position: 0,
widgets: [],
},
{
id: 'new-tab-id',
title: 'New Tab',
position: 1,
widgets: [],
},
];
const existingTabs = [
mockTab,
{
...mockTab,
id: 'tab-to-delete',
title: 'Tab to Delete',
},
] as PageLayoutTabEntity[];
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue(existingTabs);
jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({
...mockTab,
title: 'Updated Tab',
});
jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({
...mockTab,
id: 'new-tab-id',
title: 'New Tab',
position: 1,
});
jest.spyOn(pageLayoutTabService, 'delete').mockResolvedValue({
...mockTab,
id: 'tab-to-delete',
title: 'Tab to Delete',
});
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([]);
await pageLayoutUpdateService['updatePageLayoutTabs']({
pageLayoutId,
workspaceId,
tabs,
transactionManager: mockTransactionManager,
});
expect(pageLayoutTabService.delete).toHaveBeenCalledWith(
'tab-to-delete',
workspaceId,
mockTransactionManager,
);
expect(pageLayoutTabService.update).toHaveBeenCalledWith(
'tab-1',
workspaceId,
{
id: 'tab-1',
title: 'Updated Tab',
position: 0,
},
mockTransactionManager,
);
expect(pageLayoutTabService.create).toHaveBeenCalledWith(
{
id: 'new-tab-id',
title: 'New Tab',
position: 1,
pageLayoutId,
widgets: [],
},
workspaceId,
mockTransactionManager,
);
});
});
describe('updateWidgetsForTab', () => {
it('should create new widgets', async () => {
const tabId = 'tab-1';
const workspaceId = 'workspace-id';
const widgets = [
{
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
title: 'New Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
];
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([]);
jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({
...mockWidget,
id: 'new-widget-id',
title: 'New Widget',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
});
await pageLayoutUpdateService['updateWidgetsForTab']({
tabId,
widgets,
workspaceId,
transactionManager: mockTransactionManager,
});
expect(pageLayoutWidgetService.create).toHaveBeenCalledWith(
{
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
title: 'New Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
workspaceId,
mockTransactionManager,
);
});
it('should update existing widgets', async () => {
const tabId = 'tab-1';
const workspaceId = 'workspace-id';
const widgets = [
{
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
id: 'widget-1',
title: 'Updated Widget',
type: WidgetType.VIEW,
gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 },
},
];
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue([mockWidget]);
jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({
...mockWidget,
title: 'Updated Widget',
gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 },
});
await pageLayoutUpdateService['updateWidgetsForTab']({
tabId,
widgets,
workspaceId,
transactionManager: mockTransactionManager,
});
expect(pageLayoutWidgetService.update).toHaveBeenCalledWith(
'widget-1',
workspaceId,
{
id: 'widget-1',
title: 'Updated Widget',
type: WidgetType.VIEW,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 },
},
mockTransactionManager,
);
});
it('should delete removed widgets', async () => {
const tabId = 'tab-1';
const workspaceId = 'workspace-id';
const widgets: UpdatePageLayoutWidgetWithIdInput[] = [];
const existingWidgets = [mockWidget];
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue(existingWidgets);
jest
.spyOn(pageLayoutWidgetService, 'delete')
.mockResolvedValue(mockWidget);
await pageLayoutUpdateService['updateWidgetsForTab']({
tabId,
widgets,
workspaceId,
transactionManager: mockTransactionManager,
});
expect(pageLayoutWidgetService.delete).toHaveBeenCalledWith(
'widget-1',
workspaceId,
mockTransactionManager,
);
});
it('should handle widgets with mixed operations (create, update, delete)', async () => {
const tabId = 'tab-1';
const workspaceId = 'workspace-id';
const widgets = [
{
id: 'widget-1',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
title: 'Updated Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
{
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
title: 'New Widget',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
},
];
const existingWidgets = [
mockWidget,
{
...mockWidget,
id: 'widget-to-delete',
title: 'Widget to Delete',
},
] as PageLayoutWidgetEntity[];
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue(existingWidgets);
jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({
...mockWidget,
title: 'Updated Widget',
});
jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({
...mockWidget,
id: 'new-widget-id',
title: 'New Widget',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
});
jest.spyOn(pageLayoutWidgetService, 'delete').mockResolvedValue({
...mockWidget,
id: 'widget-to-delete',
title: 'Widget to Delete',
});
await pageLayoutUpdateService['updateWidgetsForTab']({
tabId,
widgets,
workspaceId,
transactionManager: mockTransactionManager,
});
expect(pageLayoutWidgetService.delete).toHaveBeenCalledWith(
'widget-to-delete',
workspaceId,
mockTransactionManager,
);
expect(pageLayoutWidgetService.update).toHaveBeenCalledWith(
'widget-1',
workspaceId,
{
id: 'widget-1',
title: 'Updated Widget',
type: WidgetType.VIEW,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
mockTransactionManager,
);
expect(pageLayoutWidgetService.create).toHaveBeenCalledWith(
{
id: 'new-widget-id',
title: 'New Widget',
type: WidgetType.FIELDS,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
},
workspaceId,
mockTransactionManager,
);
});
});
describe('integration scenarios', () => {
it('should handle complete page layout update with nested tabs and widgets', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const input = {
name: 'Complete Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
widgets: [
{
id: 'widget-1',
title: 'Widget 1',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: null,
},
{
id: 'widget-2',
title: 'Widget 2',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: null,
},
],
},
{
id: 'tab-2',
title: 'Tab 2',
position: 1,
widgets: [],
},
],
};
const existingTabs = [mockTab];
const existingWidgets = [mockWidget];
const completeLayout = { ...mockPageLayout, name: 'Complete Layout' };
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValueOnce(mockPageLayout)
.mockResolvedValueOnce(completeLayout);
jest.spyOn(pageLayoutService, 'update').mockResolvedValue(completeLayout);
jest
.spyOn(pageLayoutTabService, 'findByPageLayoutId')
.mockResolvedValue(existingTabs);
jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({
...mockTab,
id: 'tab-1',
title: 'Tab 1',
position: 0,
});
jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({
...mockTab,
id: 'tab-2',
title: 'Tab 2',
position: 1,
});
jest
.spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId')
.mockResolvedValue(existingWidgets);
jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({
...mockWidget,
id: 'widget-1',
title: 'Widget 1',
});
jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({
...mockWidget,
id: 'widget-2',
title: 'Widget 2',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
});
const result = await pageLayoutUpdateService.updatePageLayoutWithTabs({
id,
workspaceId,
input,
transactionManager: mockTransactionManager,
});
expect(pageLayoutService.update).toHaveBeenCalledWith(
id,
workspaceId,
{
name: 'Complete Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
},
mockTransactionManager,
);
expect(pageLayoutTabService.update).toHaveBeenCalled();
expect(pageLayoutTabService.create).toHaveBeenCalled();
expect(pageLayoutWidgetService.update).toHaveBeenCalled();
expect(pageLayoutWidgetService.create).toHaveBeenCalled();
expect(result).toEqual(completeLayout);
});
});
});
@@ -1,615 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import {
generatePageLayoutWidgetExceptionMessage,
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
describe('PageLayoutWidgetService', () => {
let pageLayoutWidgetService: PageLayoutWidgetService;
let pageLayoutWidgetRepository: Repository<PageLayoutWidgetEntity>;
let pageLayoutTabService: PageLayoutTabService;
const mockPageLayoutWidget = {
id: 'page-layout-widget-id',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'page-layout-tab-id',
pageLayoutTab: {} as any,
objectMetadataId: 'object-metadata-id',
objectMetadata: null,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as PageLayoutWidgetEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutWidgetService,
{
provide: getRepositoryToken(PageLayoutWidgetEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
manager: {
getRepository: jest.fn().mockReturnValue({
findOne: jest.fn(),
}),
},
},
},
{
provide: PageLayoutTabService,
useValue: {
findByIdOrThrow: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn(),
},
},
],
}).compile();
pageLayoutWidgetService = module.get<PageLayoutWidgetService>(
PageLayoutWidgetService,
);
pageLayoutWidgetRepository = module.get<Repository<PageLayoutWidgetEntity>>(
getRepositoryToken(PageLayoutWidgetEntity),
);
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
});
it('should be defined', () => {
expect(pageLayoutWidgetService).toBeDefined();
});
describe('findByPageLayoutTabId', () => {
it('should return page layout widgets for a page layout tab id', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabId = 'page-layout-tab-id';
const expectedWidgets = [mockPageLayoutWidget];
jest
.spyOn(pageLayoutWidgetRepository, 'find')
.mockResolvedValue(expectedWidgets);
const result = await pageLayoutWidgetService.findByPageLayoutTabId(
workspaceId,
pageLayoutTabId,
);
expect(pageLayoutWidgetRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutTabId,
workspaceId,
},
order: { createdAt: 'ASC' },
withDeleted: false,
});
expect(result).toEqual(expectedWidgets);
});
it('should return empty array when no widgets are found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabId = 'page-layout-tab-id';
jest.spyOn(pageLayoutWidgetRepository, 'find').mockResolvedValue([]);
const result = await pageLayoutWidgetService.findByPageLayoutTabId(
workspaceId,
pageLayoutTabId,
);
expect(result).toEqual([]);
});
});
describe('findByIdOrThrow', () => {
it('should return page layout widget when found', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
const result = await pageLayoutWidgetService.findByIdOrThrow(
id,
workspaceId,
);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw exception when page layout widget is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.findByIdOrThrow(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('create', () => {
const validPageLayoutWidgetData = {
id: 'page-layout-widget-id',
title: 'New Widget',
pageLayoutTabId: 'page-layout-tab-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
type: WidgetType.VIEW,
};
it('should create a new page layout widget successfully', async () => {
const workspaceId = 'workspace-id';
const mockPageLayoutTab = {
id: 'page-layout-tab-id',
pageLayout: {
id: 'page-layout-id',
applicationId: 'application-id',
},
};
const tabRepository = {
findOne: jest.fn().mockResolvedValue(mockPageLayoutTab),
};
jest
.spyOn(pageLayoutWidgetRepository.manager, 'getRepository')
.mockReturnValue(tabRepository as any);
jest.spyOn(pageLayoutWidgetRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-widget-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutWidgetService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutWidget);
const result = await pageLayoutWidgetService.create(
validPageLayoutWidgetData,
workspaceId,
);
expect(tabRepository.findOne).toHaveBeenCalledWith({
where: {
id: validPageLayoutWidgetData.pageLayoutTabId,
workspaceId,
deletedAt: IsNull(),
},
relations: ['pageLayout'],
});
expect(pageLayoutWidgetRepository.insert).toHaveBeenCalledWith({
...validPageLayoutWidgetData,
workspaceId,
applicationId: 'application-id',
universalIdentifier: expect.any(String),
});
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when title is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
title: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when pageLayoutTabId is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
pageLayoutTabId: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when gridPosition is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
gridPosition: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when page layout tab does not exist', async () => {
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should rethrow other errors', async () => {
const workspaceId = 'workspace-id';
const unexpectedError = new Error('Unexpected error');
const tabRepository = {
findOne: jest.fn().mockRejectedValue(unexpectedError),
};
jest
.spyOn(pageLayoutWidgetRepository.manager, 'getRepository')
.mockReturnValue(tabRepository as any);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toThrow(unexpectedError);
});
});
describe('update', () => {
it('should update a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Widget' };
const updatedWidget = {
...mockPageLayoutWidget,
title: 'Updated Widget',
};
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValueOnce(mockPageLayoutWidget)
.mockResolvedValueOnce(updatedWidget);
jest.spyOn(pageLayoutWidgetRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
const result = await pageLayoutWidgetService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledTimes(2);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(1, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(2, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.update).toHaveBeenCalledWith(
{ id },
updateData,
);
expect(result).toEqual(updatedWidget);
});
it('should throw an exception when widget to update is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Widget' };
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.update(id, workspaceId, updateData),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.update(id, workspaceId, updateData),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('delete', () => {
it('should soft delete a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
jest
.spyOn(pageLayoutWidgetRepository, 'softDelete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.delete(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when widget to delete is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.delete(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.delete(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('destroy', () => {
it('should permanently delete a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
jest
.spyOn(pageLayoutWidgetRepository, 'delete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.destroy(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutWidgetRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBe(true);
});
it('should throw an exception when widget to destroy is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.destroy(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.destroy(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('restore', () => {
it('should restore a deleted page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const deletedWidget = { ...mockPageLayoutWidget, deletedAt: new Date() };
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValueOnce(deletedWidget) // First call in restore method to check if deleted
.mockResolvedValueOnce(mockPageLayoutWidget); // Second call in findByIdOrThrow
jest
.spyOn(pageLayoutWidgetRepository, 'restore')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.restore(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledTimes(2);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(1, {
select: {
id: true,
deletedAt: true,
pageLayoutTabId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(2, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.restore).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when widget to restore is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
it('should throw an exception when widget is not deleted', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const notDeletedWidget = { ...mockPageLayoutWidget, deletedAt: null };
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(notDeletedWidget);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when parent tab is not accessible', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const deletedWidget = {
...mockPageLayoutWidget,
deletedAt: new Date(),
pageLayoutTabId: 'deleted-tab-id',
};
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(deletedWidget);
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'message',
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-tab-id',
workspaceId,
undefined,
);
});
});
});
@@ -1,495 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
describe('PageLayoutService', () => {
let pageLayoutService: PageLayoutService;
let pageLayoutRepository: Repository<PageLayoutEntity>;
let workspaceRepository: Repository<WorkspaceEntity>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
const mockPageLayout = {
id: 'page-layout-id',
name: 'Test Page Layout',
workspaceId: 'workspace-id',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
tabs: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as unknown as PageLayoutEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutService,
{
provide: getRepositoryToken(PageLayoutEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
],
}).compile();
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
pageLayoutRepository = module.get<Repository<PageLayoutEntity>>(
getRepositoryToken(PageLayoutEntity),
);
workspaceRepository = module.get<Repository<WorkspaceEntity>>(
getRepositoryToken(WorkspaceEntity),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
});
describe('findByWorkspaceId', () => {
it('should return page layouts for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByWorkspaceId(workspaceId);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByObjectMetadataId', () => {
it('should return page layouts for an object metadata id', async () => {
const workspaceId = 'workspace-id';
const objectMetadataId = 'object-metadata-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByObjectMetadataId(
workspaceId,
objectMetadataId,
);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByIdOrThrow', () => {
it('should return a page layout by id', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.findByIdOrThrow(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('create', () => {
const validPageLayoutData = {
id: 'page-layout-id',
name: 'Test Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
};
it('should create a page layout successfully', async () => {
jest.spyOn(workspaceRepository, 'findOneOrFail').mockResolvedValue({
id: 'workspace-id',
workspaceCustomApplicationId: 'application-id',
} as WorkspaceEntity);
jest.spyOn(pageLayoutRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.create(
validPageLayoutData,
'workspace-id',
);
expect(workspaceRepository.findOneOrFail).toHaveBeenCalledWith({
where: { id: 'workspace-id' },
select: ['workspaceCustomApplicationId'],
});
expect(pageLayoutRepository.insert).toHaveBeenCalledWith({
...validPageLayoutData,
workspaceId: 'workspace-id',
universalIdentifier: expect.any(String),
applicationId: 'application-id',
});
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when name is missing', async () => {
const invalidData = { ...validPageLayoutData, name: undefined };
const workspaceId = 'workspace-id';
await expect(
pageLayoutService.create(
invalidData as unknown as CreatePageLayoutInput,
workspaceId,
),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
),
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
),
);
});
});
describe('update', () => {
it('should update a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
const updatedPageLayout = { ...mockPageLayout, ...updateData };
jest.spyOn(pageLayoutRepository, 'update').mockResolvedValue({} as any);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(updatedPageLayout);
const result = await pageLayoutService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutRepository.update).toHaveBeenCalledWith(
{ id, workspaceId },
updateData,
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(result).toEqual(updatedPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutService.update(id, workspaceId, updateData),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await pageLayoutService.delete(id, workspaceId);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.delete(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.destroy(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
it('should destroy associated dashboards when page layout is a dashboard', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const mockDashboardRepository = {
find: jest.fn(),
delete: jest.fn(),
};
const mockDashboards = [{ id: 'dashboard', pageLayoutId: id }];
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(mockDashboardRepository as any);
jest
.spyOn(mockDashboardRepository, 'find')
.mockResolvedValue(mockDashboards);
jest
.spyOn(mockDashboardRepository, 'delete')
.mockResolvedValue({} as any);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(
twentyORMGlobalManager.getRepositoryForWorkspace,
).toHaveBeenCalledWith(workspaceId, 'dashboard', {
shouldBypassPermissionChecks: true,
});
expect(mockDashboardRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId: id,
},
});
expect(mockDashboardRepository.delete).toHaveBeenCalledWith('dashboard');
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
});
});
describe('restore', () => {
it('should restore a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const deletedPageLayout = { ...mockPageLayout, deletedAt: new Date() };
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(deletedPageLayout);
jest.spyOn(pageLayoutRepository, 'restore').mockResolvedValue({} as any);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.restore(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
select: {
id: true,
deletedAt: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.restore).toHaveBeenCalledWith(id);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(pageLayoutService.restore(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
});
@@ -1,77 +1,76 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
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 { FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.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 { fromDeletePageLayoutTabInputToFlatPageLayoutTabOrThrow } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/from-delete-page-layout-tab-input-to-flat-page-layout-tab-or-throw.util';
import { fromDestroyPageLayoutTabInputToFlatPageLayoutTabOrThrow } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/from-destroy-page-layout-tab-input-to-flat-page-layout-tab-or-throw.util';
import { fromRestorePageLayoutTabInputToFlatPageLayoutTabOrThrow } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/from-restore-page-layout-tab-input-to-flat-page-layout-tab-or-throw.util';
import {
fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow,
type UpdatePageLayoutTabInputWithId,
} from 'src/engine/metadata-modules/flat-page-layout-tab/utils/from-update-page-layout-tab-input-to-flat-page-layout-tab-to-update-or-throw.util';
import { reconstructFlatPageLayoutTabWithWidgets } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/reconstruct-flat-page-layout-tab-with-widgets.util';
import { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
import { type PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
PageLayoutTabExceptionMessageKey,
generatePageLayoutTabExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { fromFlatPageLayoutTabToPageLayoutTabDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util';
import { fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-tab-with-widgets-to-page-layout-tab-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 PageLayoutTabService {
constructor(
@InjectRepository(PageLayoutTabEntity)
private readonly pageLayoutTabRepository: Repository<PageLayoutTabEntity>,
private readonly pageLayoutService: PageLayoutService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
private getPageLayoutTabRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutTabEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutTabEntity)
: this.pageLayoutTabRepository;
}
async findByPageLayoutId(
workspaceId: string,
pageLayoutId: string,
transactionManager?: EntityManager,
withDeleted = false,
): Promise<PageLayoutTabEntity[]> {
const repository = this.getPageLayoutTabRepository(transactionManager);
): Promise<PageLayoutTabDTO[]> {
const { flatPageLayoutTabMaps, flatPageLayoutWidgetMaps } =
await this.getPageLayoutTabFlatEntityMaps(workspaceId);
return repository.find({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted,
});
return Object.values(flatPageLayoutTabMaps.byId)
.filter(isDefined)
.filter(
(tab) => tab.pageLayoutId === pageLayoutId && !isDefined(tab.deletedAt),
)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((tab) =>
fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto(
reconstructFlatPageLayoutTabWithWidgets({
tab,
flatPageLayoutWidgetMaps,
}),
),
);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
): Promise<PageLayoutTabDTO> {
const { flatPageLayoutTabMaps, flatPageLayoutWidgetMaps } =
await this.getPageLayoutTabFlatEntityMaps(workspaceId);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['widgets'],
});
const flatTab = flatPageLayoutTabMaps.byId[id];
if (!isDefined(pageLayoutTab)) {
if (!isDefined(flatTab) || isDefined(flatTab.deletedAt)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
@@ -81,15 +80,31 @@ export class PageLayoutTabService {
);
}
return pageLayoutTab;
return fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto(
reconstructFlatPageLayoutTabWithWidgets({
tab: flatTab,
flatPageLayoutWidgetMaps,
}),
);
}
private async getPageLayoutTabFlatEntityMaps(workspaceId: string): Promise<{
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}> {
return this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps', 'flatPageLayoutWidgetMaps'],
},
);
}
async create(
pageLayoutTabData: CreatePageLayoutTabInput,
createPageLayoutTabInput: CreatePageLayoutTabInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
if (!isDefined(pageLayoutTabData.title)) {
): Promise<Omit<PageLayoutTabDTO, 'widgets'>> {
if (!isDefined(createPageLayoutTabInput.title)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.TITLE_REQUIRED,
@@ -98,7 +113,7 @@ export class PageLayoutTabService {
);
}
if (!isDefined(pageLayoutTabData.pageLayoutId)) {
if (!isDefined(createPageLayoutTabInput.pageLayoutId)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED,
@@ -107,118 +122,210 @@ export class PageLayoutTabService {
);
}
try {
const pageLayout = await this.pageLayoutService.findByIdOrThrow(
pageLayoutTabData.pageLayoutId,
workspaceId,
transactionManager,
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const repository = this.getPageLayoutTabRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutTabData,
const flatPageLayoutTabToCreate =
fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate({
createPageLayoutTabInput,
workspaceId,
universalIdentifier: v4(),
applicationId: pageLayout.applicationId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: [flatPageLayoutTabToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while creating page layout tab',
);
} catch (error) {
if (
error instanceof PageLayoutException &&
error.code === PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND
) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
throw error;
}
const { flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
return fromFlatPageLayoutTabToPageLayoutTabDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatPageLayoutTabToCreate.id,
flatEntityMaps: recomputedFlatPageLayoutTabMaps,
}),
);
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutTabEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
updateData: UpdatePageLayoutTabInput,
): Promise<Omit<PageLayoutTabDTO, 'widgets'>> {
const { flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
const existingTab = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
const updatePageLayoutTabInput: UpdatePageLayoutTabInputWithId = {
id,
update: updateData,
};
if (!isDefined(existingTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
const flatPageLayoutTabToUpdate =
fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow({
updatePageLayoutTabInput,
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutTabToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating page layout tab',
);
}
await repository.update({ id }, updateData);
const { flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
return fromFlatPageLayoutTabToPageLayoutTabDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutTabMaps,
}),
);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
): Promise<Omit<PageLayoutTabDTO, 'widgets'>> {
const { flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
const repository = this.getPageLayoutTabRepository(transactionManager);
const flatPageLayoutTabToDelete =
fromDeletePageLayoutTabInputToFlatPageLayoutTabOrThrow({
deletePageLayoutTabInput: { id },
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
});
await repository.softDelete(id);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutTabToDelete],
},
},
workspaceId,
isSystemBuild: false,
},
);
return pageLayoutTab;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting page layout tab',
);
}
await repository.delete(id);
const { flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
return fromFlatPageLayoutTabToPageLayoutTabDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutTabMaps,
}),
);
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const { flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
const flatPageLayoutTabToDestroy =
fromDestroyPageLayoutTabInputToFlatPageLayoutTabOrThrow({
destroyPageLayoutTabInput: { id },
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: [],
flatEntityToDelete: [flatPageLayoutTabToDestroy],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while destroying page layout tab',
);
}
return true;
}
@@ -226,71 +333,56 @@ export class PageLayoutTabService {
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
): Promise<Omit<PageLayoutTabDTO, 'widgets'>> {
const { flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
const pageLayoutTab = await repository.findOne({
select: {
id: true,
deletedAt: true,
pageLayoutId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
const flatPageLayoutTabToRestore =
fromRestorePageLayoutTabInputToFlatPageLayoutTabOrThrow({
restorePageLayoutTabInput: { id },
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutTabToRestore],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while restoring page layout tab',
);
}
if (!isDefined(pageLayoutTab.deletedAt)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_DELETED,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
const { flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutTabMaps'],
},
);
}
try {
await this.pageLayoutService.findByIdOrThrow(
pageLayoutTab.pageLayoutId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutException &&
error.code === PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND
) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
throw error;
}
await repository.restore(id);
const restoredPageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
return fromFlatPageLayoutTabToPageLayoutTabDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutTabMaps,
}),
);
return restoredPageLayoutTab;
}
}
@@ -1,137 +1,194 @@
import { Injectable } from '@nestjs/common';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
import { DataSource, EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
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 { 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 { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata-modules/flat-page-layout/utils/reconstruct-flat-page-layout-with-tabs-and-widgets.util';
import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input';
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { 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';
type UpdatePageLayoutWithTabsParams = {
id: string;
workspaceId: string;
input: UpdatePageLayoutWithTabsInput;
transactionManager?: EntityManager;
};
type UpdatePageLayoutTabsParams = {
pageLayoutId: string;
workspaceId: string;
tabs: UpdatePageLayoutTabWithWidgetsInput[];
transactionManager: EntityManager;
};
type UpdateWidgetsForTabParams = {
tabId: string;
widgets: UpdatePageLayoutWidgetWithIdInput[];
workspaceId: string;
transactionManager: EntityManager;
};
@Injectable()
export class PageLayoutUpdateService {
constructor(
private readonly pageLayoutService: PageLayoutService,
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly pageLayoutWidgetService: PageLayoutWidgetService,
private readonly dataSource: DataSource,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async updatePageLayoutWithTabs({
id,
workspaceId,
input,
transactionManager,
}: UpdatePageLayoutWithTabsParams): Promise<PageLayoutEntity> {
if (!isDefined(transactionManager)) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const result = await this.updatePageLayoutWithTabsWithinTransaction({
id,
}: UpdatePageLayoutWithTabsParams): Promise<PageLayoutDTO> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
input,
transactionManager: queryRunner.manager,
});
flatMapsKeys: [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
],
},
);
await queryRunner.commitTransaction();
const existingPageLayout = flatPageLayoutMaps.byId[id];
return result;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
if (
!isDefined(existingPageLayout) ||
isDefined(existingPageLayout.deletedAt)
) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return this.updatePageLayoutWithTabsWithinTransaction({
id,
workspaceId,
input,
transactionManager,
});
}
private async updatePageLayoutWithTabsWithinTransaction({
id,
workspaceId,
input,
transactionManager,
}: UpdatePageLayoutWithTabsParams & {
transactionManager: EntityManager;
}): Promise<PageLayoutEntity> {
await this.pageLayoutService.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { tabs, ...updateData } = input;
await this.pageLayoutService.update(
id,
workspaceId,
updateData,
transactionManager,
const flatPageLayoutToUpdate: FlatPageLayout = {
...existingPageLayout,
name: updateData.name,
type: updateData.type,
objectMetadataId: updateData.objectMetadataId,
updatedAt: new Date().toISOString(),
};
const { tabsToCreate, tabsToUpdate, tabsToDelete } =
this.computeTabOperations({
existingPageLayout,
tabs,
flatPageLayoutTabMaps,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const { widgetsToCreate, widgetsToUpdate, widgetsToDelete } =
this.computeWidgetOperationsForAllTabs({
tabs,
flatPageLayoutWidgetMaps,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToUpdate],
},
pageLayoutTab: {
flatEntityToCreate: tabsToCreate,
flatEntityToDelete: tabsToDelete,
flatEntityToUpdate: tabsToUpdate,
},
pageLayoutWidget: {
flatEntityToCreate: widgetsToCreate,
flatEntityToDelete: widgetsToDelete,
flatEntityToUpdate: widgetsToUpdate,
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating page layout with tabs',
);
}
const {
flatPageLayoutMaps: recomputedFlatPageLayoutMaps,
flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: recomputedFlatPageLayoutWidgetMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
],
},
);
await this.updatePageLayoutTabs({
pageLayoutId: id,
workspaceId,
tabs,
transactionManager,
const flatLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
});
return this.pageLayoutService.findByIdOrThrow(
id,
workspaceId,
transactionManager,
return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout: flatLayout,
flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: recomputedFlatPageLayoutWidgetMaps,
}),
);
}
private async updatePageLayoutTabs({
pageLayoutId,
workspaceId,
private computeTabOperations({
existingPageLayout,
tabs,
transactionManager,
}: UpdatePageLayoutTabsParams): Promise<void> {
const existingTabs = await this.pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
transactionManager,
true,
);
flatPageLayoutTabMaps,
workspaceId,
workspaceCustomApplicationId,
}: {
existingPageLayout: FlatPageLayout;
tabs: UpdatePageLayoutTabWithWidgetsInput[];
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
tabsToCreate: FlatPageLayoutTab[];
tabsToUpdate: FlatPageLayoutTab[];
tabsToDelete: FlatPageLayoutTab[];
} {
const existingTabs = Object.values(flatPageLayoutTabMaps.byId)
.filter(isDefined)
.filter((tab) => tab.pageLayoutId === existingPageLayout.id);
const {
toCreate: entitiesToCreate,
@@ -139,7 +196,7 @@ export class PageLayoutUpdateService {
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
} = computeDiffBetweenObjects<
PageLayoutTabEntity,
FlatPageLayoutTab,
UpdatePageLayoutTabWithWidgetsInput
>({
existingObjects: existingTabs,
@@ -147,76 +204,139 @@ export class PageLayoutUpdateService {
propertiesToCompare: ['title', 'position'],
});
for (const tabId of idsToDelete) {
await this.pageLayoutTabService.delete(
tabId,
workspaceId,
transactionManager,
);
}
const now = new Date();
for (const tabToUpdate of entitiesToUpdate) {
const { widgets: _widgets, ...updateData } = tabToUpdate;
const tabsToCreate: FlatPageLayoutTab[] = entitiesToCreate.map(
(tabInput) => {
const tabId = tabInput.id ?? v4();
await this.pageLayoutTabService.update(
tabToUpdate.id,
workspaceId,
updateData,
transactionManager,
);
}
return {
id: tabId,
title: tabInput.title,
position: tabInput.position,
pageLayoutId: existingPageLayout.id,
workspaceId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
deletedAt: null,
universalIdentifier: tabId,
applicationId: workspaceCustomApplicationId,
widgetIds: [],
};
},
);
for (const tabToRestoreAndUpdate of entitiesToRestoreAndUpdate) {
await this.pageLayoutTabService.restore(
tabToRestoreAndUpdate.id,
workspaceId,
transactionManager,
);
const tabsToUpdate: FlatPageLayoutTab[] = entitiesToUpdate.map(
(tabInput) => {
const existingTab = flatPageLayoutTabMaps.byId[tabInput.id];
const { widgets: _widgets, ...updateData } = tabToRestoreAndUpdate;
return {
...existingTab!,
title: tabInput.title,
position: tabInput.position,
updatedAt: now.toISOString(),
};
},
);
await this.pageLayoutTabService.update(
tabToRestoreAndUpdate.id,
workspaceId,
updateData,
transactionManager,
);
}
const tabsToRestoreAndUpdate: FlatPageLayoutTab[] =
entitiesToRestoreAndUpdate.map((tabInput) => {
const existingTab = flatPageLayoutTabMaps.byId[tabInput.id];
for (const tabToCreate of entitiesToCreate) {
await this.pageLayoutTabService.create(
{
...tabToCreate,
pageLayoutId,
},
workspaceId,
transactionManager,
);
}
for (const tabInput of tabs) {
await this.updateWidgetsForTab({
tabId: tabInput.id,
widgets: tabInput.widgets,
workspaceId,
transactionManager,
return {
...existingTab!,
title: tabInput.title,
position: tabInput.position,
deletedAt: null,
updatedAt: now.toISOString(),
};
});
}
const tabsToDelete: FlatPageLayoutTab[] = idsToDelete
.map((tabId) => {
const existingTab = flatPageLayoutTabMaps.byId[tabId];
if (!isDefined(existingTab)) {
return null;
}
return {
...existingTab,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
.filter(isDefined);
return {
tabsToCreate,
tabsToUpdate: [
...tabsToUpdate,
...tabsToRestoreAndUpdate,
...tabsToDelete,
],
tabsToDelete: [],
};
}
private async updateWidgetsForTab({
private computeWidgetOperationsForAllTabs({
tabs,
flatPageLayoutWidgetMaps,
workspaceId,
workspaceCustomApplicationId,
}: {
tabs: UpdatePageLayoutTabWithWidgetsInput[];
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
widgetsToCreate: FlatPageLayoutWidget[];
widgetsToUpdate: FlatPageLayoutWidget[];
widgetsToDelete: FlatPageLayoutWidget[];
} {
const allWidgetsToCreate: FlatPageLayoutWidget[] = [];
const allWidgetsToUpdate: FlatPageLayoutWidget[] = [];
for (const tabInput of tabs) {
const { widgetsToCreate, widgetsToUpdate } =
this.computeWidgetOperationsForTab({
tabId: tabInput.id,
widgets: tabInput.widgets,
flatPageLayoutWidgetMaps,
workspaceId,
workspaceCustomApplicationId,
});
allWidgetsToCreate.push(...widgetsToCreate);
allWidgetsToUpdate.push(...widgetsToUpdate);
}
return {
widgetsToCreate: allWidgetsToCreate,
widgetsToUpdate: allWidgetsToUpdate,
widgetsToDelete: [],
};
}
private computeWidgetOperationsForTab({
tabId,
widgets,
flatPageLayoutWidgetMaps,
workspaceId,
transactionManager,
}: UpdateWidgetsForTabParams): Promise<void> {
const existingWidgets =
await this.pageLayoutWidgetService.findByPageLayoutTabId(
workspaceId,
tabId,
transactionManager,
true,
);
workspaceCustomApplicationId,
}: {
tabId: string;
widgets: UpdatePageLayoutWidgetWithIdInput[];
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
widgetsToCreate: FlatPageLayoutWidget[];
widgetsToUpdate: FlatPageLayoutWidget[];
} {
const existingWidgets = Object.values(flatPageLayoutWidgetMaps.byId)
.filter(isDefined)
.filter((widget) => widget.pageLayoutTabId === tabId);
const {
toCreate: entitiesToCreate,
@@ -224,7 +344,7 @@ export class PageLayoutUpdateService {
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
} = computeDiffBetweenObjects<
PageLayoutWidgetEntity,
FlatPageLayoutWidget,
UpdatePageLayoutWidgetWithIdInput
>({
existingObjects: existingWidgets,
@@ -239,44 +359,87 @@ export class PageLayoutUpdateService {
],
});
for (const widgetId of idsToDelete) {
await this.pageLayoutWidgetService.delete(
widgetId,
workspaceId,
transactionManager,
);
}
const now = new Date();
for (const widgetUpdate of entitiesToUpdate) {
await this.pageLayoutWidgetService.update(
widgetUpdate.id,
workspaceId,
widgetUpdate,
transactionManager,
);
}
const widgetsToCreate: FlatPageLayoutWidget[] = entitiesToCreate.map(
(widgetInput) => {
const widgetId = widgetInput.id ?? v4();
for (const widgetToRestoreAndUpdate of entitiesToRestoreAndUpdate) {
await this.pageLayoutWidgetService.restore(
widgetToRestoreAndUpdate.id,
workspaceId,
transactionManager,
);
return {
id: widgetId,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
configuration: widgetInput.configuration ?? null,
workspaceId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
deletedAt: null,
universalIdentifier: widgetId,
applicationId: workspaceCustomApplicationId,
};
},
);
await this.pageLayoutWidgetService.update(
widgetToRestoreAndUpdate.id,
workspaceId,
widgetToRestoreAndUpdate,
transactionManager,
);
}
const widgetsToUpdate: FlatPageLayoutWidget[] = entitiesToUpdate.map(
(widgetInput) => {
const existingWidget = flatPageLayoutWidgetMaps.byId[widgetInput.id];
for (const widgetToCreate of entitiesToCreate) {
await this.pageLayoutWidgetService.create(
widgetToCreate as CreatePageLayoutWidgetInput,
workspaceId,
transactionManager,
);
}
return {
...existingWidget!,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
configuration: widgetInput.configuration ?? null,
updatedAt: now.toISOString(),
};
},
);
const widgetsToRestoreAndUpdate: FlatPageLayoutWidget[] =
entitiesToRestoreAndUpdate.map((widgetInput) => {
const existingWidget = flatPageLayoutWidgetMaps.byId[widgetInput.id];
return {
...existingWidget!,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
configuration: widgetInput.configuration ?? null,
deletedAt: null,
updatedAt: now.toISOString(),
};
});
const widgetsToDelete: FlatPageLayoutWidget[] = idsToDelete
.map((widgetId) => {
const existingWidget = flatPageLayoutWidgetMaps.byId[widgetId];
if (!isDefined(existingWidget)) {
return null;
}
return {
...existingWidget,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
.filter(isDefined);
return {
widgetsToCreate,
widgetsToUpdate: [
...widgetsToUpdate,
...widgetsToRestoreAndUpdate,
...widgetsToDelete,
],
};
}
}
@@ -1,83 +1,178 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { 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 { fromDeletePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-delete-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import { fromDestroyPageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-destroy-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import { fromRestorePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-restore-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import {
fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThrow,
type UpdatePageLayoutWidgetInputWithId,
} from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util';
import { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-widget.input';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-widget.dto';
import { WidgetConfigurationInterface } from 'src/engine/metadata-modules/page-layout/dtos/widget-configuration.interface';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
import { validateAndTransformWidgetConfiguration } from 'src/engine/metadata-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout/utils/validate-widget-grid-position.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';
type WidgetMigrationOperations = {
flatEntityToCreate: FlatPageLayoutWidget[];
flatEntityToUpdate: FlatPageLayoutWidget[];
flatEntityToDelete: FlatPageLayoutWidget[];
};
@Injectable()
export class PageLayoutWidgetService {
constructor(
@InjectRepository(PageLayoutWidgetEntity)
private readonly pageLayoutWidgetRepository: Repository<PageLayoutWidgetEntity>,
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
private getPageLayoutWidgetRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutWidgetEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutWidgetEntity)
: this.pageLayoutWidgetRepository;
private async getFlatPageLayoutWidgetMaps(
workspaceId: string,
): Promise<FlatPageLayoutWidgetMaps> {
const { flatPageLayoutWidgetMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutWidgetMaps'],
},
);
return flatPageLayoutWidgetMaps;
}
private async validateWidgetConfigurationOrThrow({
type,
configuration,
workspaceId,
titleForError,
}: {
type: WidgetType;
configuration: Record<string, unknown>;
workspaceId: string;
titleForError: string;
}): Promise<WidgetConfigurationInterface> {
const isDashboardV2Enabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
let validatedConfig: WidgetConfigurationInterface | null = null;
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type,
configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
type,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(validatedConfig)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
type,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
return validatedConfig;
}
private async validateAndRunWidgetMigration({
workspaceId,
operations,
errorMessage,
}: {
workspaceId: string;
operations: WidgetMigrationOperations;
errorMessage: string;
}): Promise<void> {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: operations,
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
errorMessage,
);
}
}
async findByPageLayoutTabId(
workspaceId: string,
pageLayoutTabId: string,
transactionManager?: EntityManager,
withDeleted = false,
): Promise<PageLayoutWidgetEntity[]> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
): Promise<PageLayoutWidgetDTO[]> {
const flatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
return repository.find({
where: {
pageLayoutTabId,
workspaceId,
},
order: { createdAt: 'ASC' },
withDeleted,
});
return Object.values(flatPageLayoutWidgetMaps.byId)
.filter(isDefined)
.filter(
(widget) =>
widget.pageLayoutTabId === pageLayoutTabId &&
!isDefined(widget.deletedAt),
)
.sort(
(widgetA, widgetB) =>
new Date(widgetA.createdAt).getTime() -
new Date(widgetB.createdAt).getTime(),
)
.map(fromFlatPageLayoutWidgetToPageLayoutWidgetDto);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
): Promise<PageLayoutWidgetDTO> {
const flatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
const flatWidget = flatPageLayoutWidgetMaps.byId[id];
if (!isDefined(pageLayoutWidget)) {
if (!isDefined(flatWidget) || isDefined(flatWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
@@ -87,15 +182,65 @@ export class PageLayoutWidgetService {
);
}
return pageLayoutWidget;
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(flatWidget);
}
async create(
pageLayoutWidgetData: CreatePageLayoutWidgetInput,
createPageLayoutWidgetInput: CreatePageLayoutWidgetInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
if (!isDefined(pageLayoutWidgetData.title)) {
): Promise<PageLayoutWidgetDTO> {
this.validateCreateInput(createPageLayoutWidgetInput);
validateWidgetGridPosition(
createPageLayoutWidgetInput.gridPosition,
createPageLayoutWidgetInput.title,
);
const validatedConfig = await this.getValidatedConfigurationForCreate(
createPageLayoutWidgetInput,
workspaceId,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatPageLayoutWidgetToCreate =
fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate({
createPageLayoutWidgetInput: {
...createPageLayoutWidgetInput,
...(validatedConfig && {
configuration: validatedConfig as Record<string, unknown>,
}),
},
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [flatPageLayoutWidgetToCreate],
flatEntityToUpdate: [],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while creating page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatPageLayoutWidgetToCreate.id,
flatEntityMaps: recomputedMaps,
}),
);
}
private validateCreateInput(input: CreatePageLayoutWidgetInput): void {
if (!isDefined(input.title)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.TITLE_REQUIRED,
@@ -104,7 +249,7 @@ export class PageLayoutWidgetService {
);
}
if (!isDefined(pageLayoutWidgetData.pageLayoutTabId)) {
if (!isDefined(input.pageLayoutTabId)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_ID_REQUIRED,
@@ -113,7 +258,7 @@ export class PageLayoutWidgetService {
);
}
if (!isDefined(pageLayoutWidgetData.gridPosition)) {
if (!isDefined(input.gridPosition)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.GRID_POSITION_REQUIRED,
@@ -121,131 +266,36 @@ export class PageLayoutWidgetService {
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
validateWidgetGridPosition(
pageLayoutWidgetData.gridPosition,
pageLayoutWidgetData.title,
);
try {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutTab = await (
transactionManager
? transactionManager.getRepository(PageLayoutTabEntity)
: repository.manager.getRepository(PageLayoutTabEntity)
).findOne({
where: {
id: pageLayoutWidgetData.pageLayoutTabId,
workspaceId,
deletedAt: IsNull(),
},
relations: ['pageLayout'],
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
pageLayoutWidgetData.pageLayoutTabId,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
let validatedConfig: WidgetConfigurationInterface | null = null;
if (pageLayoutWidgetData.configuration && pageLayoutWidgetData.type) {
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type: pageLayoutWidgetData.type,
configuration: pageLayoutWidgetData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
pageLayoutWidgetData.title,
pageLayoutWidgetData.type,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!validatedConfig) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
pageLayoutWidgetData.title,
pageLayoutWidgetData.type,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
const insertResult = await repository.insert({
...pageLayoutWidgetData,
workspaceId,
universalIdentifier: v4(),
applicationId: pageLayoutTab.pageLayout.applicationId,
...(validatedConfig && { configuration: validatedConfig }),
} as QueryDeepPartialEntity<PageLayoutWidgetEntity>);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutTabException &&
error.code === PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND
) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
throw error;
private async getValidatedConfigurationForCreate(
input: CreatePageLayoutWidgetInput,
workspaceId: string,
): Promise<WidgetConfigurationInterface | null> {
if (!input.configuration || !input.type) {
return null;
}
return this.validateWidgetConfigurationOrThrow({
type: input.type,
configuration: input.configuration,
workspaceId,
titleForError: input.title,
});
}
async update(
id: string,
workspaceId: string,
updateData: UpdatePageLayoutWidgetInput,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const existingWidget = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(existingWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
const existingWidget = this.getExistingWidgetOrThrow(
id,
existingFlatPageLayoutWidgetMaps,
);
if (updateData.gridPosition) {
const titleForValidation = updateData.title ?? existingWidget.title;
@@ -253,92 +303,56 @@ export class PageLayoutWidgetService {
validateWidgetGridPosition(updateData.gridPosition, titleForValidation);
}
let validatedConfig: WidgetConfigurationInterface | null = null;
if (updateData.configuration) {
const typeForValidation = updateData.type ?? existingWidget.type;
const titleForError = updateData.title ?? existingWidget.title;
if (typeForValidation) {
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type: typeForValidation,
configuration: updateData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
typeForValidation,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!validatedConfig) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
typeForValidation,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
}
await repository.update({ id }, {
...updateData,
...(validatedConfig && { configuration: validatedConfig }),
} as QueryDeepPartialEntity<PageLayoutWidgetEntity>);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const pageLayoutWidget = await this.findByIdOrThrow(
id,
const validatedConfig = await this.getValidatedConfigurationForUpdate(
updateData,
existingWidget,
workspaceId,
transactionManager,
);
const repository = this.getPageLayoutWidgetRepository(transactionManager);
await repository.softDelete(id);
return pageLayoutWidget;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
const updatePageLayoutWidgetInput: UpdatePageLayoutWidgetInputWithId = {
id,
update: {
...updateData,
...(validatedConfig && {
configuration: validatedConfig as Record<string, unknown>,
}),
},
withDeleted: true,
};
const flatPageLayoutWidgetToUpdate =
fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThrow({
updatePageLayoutWidgetInput,
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToUpdate],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while updating page layout widget',
});
if (!isDefined(pageLayoutWidget)) {
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
}
private getExistingWidgetOrThrow(
id: string,
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps,
): FlatPageLayoutWidget {
const existingWidget = flatPageLayoutWidgetMaps.byId[id];
if (!isDefined(existingWidget) || isDefined(existingWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
@@ -348,79 +362,117 @@ export class PageLayoutWidgetService {
);
}
await repository.delete(id);
return existingWidget;
}
private async getValidatedConfigurationForUpdate(
updateData: UpdatePageLayoutWidgetInput,
existingWidget: FlatPageLayoutWidget,
workspaceId: string,
): Promise<WidgetConfigurationInterface | null> {
if (!updateData.configuration) {
return null;
}
const typeForValidation = updateData.type ?? existingWidget.type;
if (!typeForValidation) {
return null;
}
const titleForError = updateData.title ?? existingWidget.title;
return this.validateWidgetConfigurationOrThrow({
type: typeForValidation,
configuration: updateData.configuration,
workspaceId,
titleForError,
});
}
async delete(id: string, workspaceId: string): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatPageLayoutWidgetToDelete =
fromDeletePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
deletePageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToDelete],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while deleting page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatPageLayoutWidgetToDestroy =
fromDestroyPageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
destroyPageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [],
flatEntityToDelete: [flatPageLayoutWidgetToDestroy],
},
errorMessage:
'Multiple validation errors occurred while destroying page layout widget',
});
return true;
}
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
async restore(id: string, workspaceId: string): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const pageLayoutWidget = await repository.findOne({
select: {
id: true,
deletedAt: true,
pageLayoutTabId: true,
const flatPageLayoutWidgetToRestore =
fromRestorePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
restorePageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToRestore],
flatEntityToDelete: [],
},
where: {
id,
workspaceId,
},
withDeleted: true,
errorMessage:
'Multiple validation errors occurred while restoring page layout widget',
});
if (!isDefined(pageLayoutWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
if (!isDefined(pageLayoutWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_DELETED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
try {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidget.pageLayoutTabId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutTabException &&
error.code === PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND
) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
throw error;
}
await repository.restore(id);
const restoredPageLayoutWidget = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
return restoredPageLayoutWidget;
}
}
@@ -1,14 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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 FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatPageLayoutMaps } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout-maps.type';
import { fromCreatePageLayoutInputToFlatPageLayoutToCreate } from 'src/engine/metadata-modules/flat-page-layout/utils/from-create-page-layout-input-to-flat-page-layout-to-create.util';
import { fromDeletePageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-delete-page-layout-input-to-flat-page-layout-or-throw.util';
import { fromDestroyPageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-destroy-page-layout-input-to-flat-page-layout-or-throw.util';
import { fromRestorePageLayoutInputToFlatPageLayoutOrThrow } from 'src/engine/metadata-modules/flat-page-layout/utils/from-restore-page-layout-input-to-flat-page-layout-or-throw.util';
import {
fromUpdatePageLayoutInputToFlatPageLayoutToUpdateOrThrow,
type UpdatePageLayoutInputWithId,
} from 'src/engine/metadata-modules/flat-page-layout/utils/from-update-page-layout-input-to-flat-page-layout-to-update-or-throw.util';
import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata-modules/flat-page-layout/utils/reconstruct-flat-page-layout-with-tabs-and-widgets.util';
import { CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
import { type PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
@@ -16,77 +28,87 @@ import {
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { fromFlatPageLayoutToPageLayoutDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-to-page-layout-dto.util';
import { fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-with-tabs-and-widgets-to-page-layout-dto.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
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 PageLayoutService {
private readonly logger = new Logger(PageLayoutService.name);
constructor(
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
private getPageLayoutRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutEntity)
: this.pageLayoutRepository;
}
async findByWorkspaceId(workspaceId: string): Promise<PageLayoutDTO[]> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.getPageLayoutFlatEntityMaps(workspaceId);
async findByWorkspaceId(
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
const repository = this.getPageLayoutRepository(transactionManager);
const activeLayouts = Object.values(flatPageLayoutMaps.byId)
.filter(isDefined)
.filter((layout) => !isDefined(layout.deletedAt));
return repository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
return activeLayouts.map((layout) =>
fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
}),
),
);
}
async findByObjectMetadataId(
workspaceId: string,
objectMetadataId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
const repository = this.getPageLayoutRepository(transactionManager);
): Promise<PageLayoutDTO[]> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.getPageLayoutFlatEntityMaps(workspaceId);
return repository.find({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
const activeLayouts = Object.values(flatPageLayoutMaps.byId)
.filter(isDefined)
.filter(
(layout) =>
layout.objectMetadataId === objectMetadataId &&
!isDefined(layout.deletedAt),
);
return activeLayouts.map((layout) =>
fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
}),
),
);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
): Promise<PageLayoutDTO> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.getPageLayoutFlatEntityMaps(workspaceId);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
const flatLayout = flatPageLayoutMaps.byId[id];
if (!isDefined(pageLayout)) {
if (!isDefined(flatLayout) || isDefined(flatLayout.deletedAt)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
@@ -96,15 +118,37 @@ export class PageLayoutService {
);
}
return pageLayout;
return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout: flatLayout,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
}),
);
}
private async getPageLayoutFlatEntityMaps(workspaceId: string): Promise<{
flatPageLayoutMaps: FlatPageLayoutMaps;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}> {
return this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
],
},
);
}
async create(
pageLayoutData: CreatePageLayoutInput,
createPageLayoutInput: CreatePageLayoutInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
if (!isDefined(pageLayoutData.name)) {
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
if (!isNonEmptyString(createPageLayoutInput.name)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
@@ -113,96 +157,219 @@ export class PageLayoutService {
);
}
const workspace = await this.workspaceRepository.findOneOrFail({
where: { id: workspaceId },
select: ['workspaceCustomApplicationId'],
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const repository = this.getPageLayoutRepository(transactionManager);
const flatPageLayoutToCreate =
fromCreatePageLayoutInputToFlatPageLayoutToCreate({
createPageLayoutInput,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const insertResult = await repository.insert({
...pageLayoutData,
workspaceId,
universalIdentifier: v4(),
applicationId: workspace.workspaceCustomApplicationId,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [flatPageLayoutToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while creating page layout',
);
}
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
return fromFlatPageLayoutToPageLayoutDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatPageLayoutToCreate.id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
}),
);
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
updateData: UpdatePageLayoutInput,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
await repository.update({ id, workspaceId }, updateData);
const updatedPageLayout = await this.findByIdOrThrow(
const updatePageLayoutInput: UpdatePageLayoutInputWithId = {
id,
workspaceId,
transactionManager,
);
update: updateData,
};
return updatedPageLayout;
const flatPageLayoutToUpdate =
fromUpdatePageLayoutInputToFlatPageLayoutToUpdateOrThrow({
updatePageLayoutInput,
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating page layout',
);
}
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
return fromFlatPageLayoutToPageLayoutDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
}),
);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const pageLayout = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const flatPageLayoutToDelete =
fromDeletePageLayoutInputToFlatPageLayoutOrThrow({
deletePageLayoutInput: { id },
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToDelete],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting page layout',
);
}
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
return fromFlatPageLayoutToPageLayoutDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
}),
);
const repository = this.getPageLayoutRepository(transactionManager);
await repository.softDelete(id);
return pageLayout;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
const flatPageLayoutToDestroy =
fromDestroyPageLayoutInputToFlatPageLayoutOrThrow({
destroyPageLayoutInput: { id },
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [flatPageLayoutToDestroy],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while destroying page layout',
);
}
if (pageLayout.type === PageLayoutType.DASHBOARD) {
if (flatPageLayoutToDestroy.type === PageLayoutType.DASHBOARD) {
await this.destroyAssociatedDashboards(id, workspaceId);
}
await repository.delete(id);
return pageLayout;
return fromFlatPageLayoutToPageLayoutDto(flatPageLayoutToDestroy);
}
private async destroyAssociatedDashboards(
@@ -233,40 +400,59 @@ export class PageLayoutService {
}
}
async restore(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
select: {
id: true,
deletedAt: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
async restore(
id: string,
workspaceId: string,
): Promise<Omit<PageLayoutDTO, 'tabs'>> {
const { flatPageLayoutMaps: existingFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
const flatPageLayoutToRestore =
fromRestorePageLayoutInputToFlatPageLayoutOrThrow({
restorePageLayoutInput: { id },
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatPageLayoutToRestore],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while restoring page layout',
);
}
if (!isDefined(pageLayout.deletedAt)) {
throw new PageLayoutException(
'Page layout is not deleted and cannot be restored',
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
const { flatPageLayoutMaps: recomputedFlatPageLayoutMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutMaps'],
},
);
}
await this.pageLayoutRepository.restore(id);
const restoredPageLayout = await this.findByIdOrThrow(id, workspaceId);
return restoredPageLayout;
return fromFlatPageLayoutToPageLayoutDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
}),
);
}
}
@@ -0,0 +1,21 @@
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const fromFlatPageLayoutTabToPageLayoutTabDto = (
flatPageLayoutTab: FlatPageLayoutTab,
): Omit<PageLayoutTabDTO, 'widgets'> => {
const {
createdAt,
updatedAt,
deletedAt,
widgetIds: _widgetIds,
...rest
} = flatPageLayoutTab;
return {
...rest,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
};
};
@@ -0,0 +1,15 @@
import { type FlatPageLayoutTabWithWidgets } from 'src/engine/metadata-modules/flat-page-layout-tab/utils/reconstruct-flat-page-layout-tab-with-widgets.util';
import { type PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
import { fromFlatPageLayoutTabToPageLayoutTabDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
export const fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto = (
flatPageLayoutTabWithWidgets: FlatPageLayoutTabWithWidgets,
): PageLayoutTabDTO => {
const { widgets, ...flatPageLayoutTab } = flatPageLayoutTabWithWidgets;
return {
...fromFlatPageLayoutTabToPageLayoutTabDto(flatPageLayoutTab),
widgets: widgets.map(fromFlatPageLayoutWidgetToPageLayoutWidgetDto),
};
};
@@ -0,0 +1,21 @@
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { type PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
export const fromFlatPageLayoutToPageLayoutDto = (
flatPageLayout: FlatPageLayout,
): Omit<PageLayoutDTO, 'tabs'> => {
const {
createdAt,
updatedAt,
deletedAt,
tabIds: _tabIds,
...rest
} = flatPageLayout;
return {
...rest,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
};
};
@@ -0,0 +1,15 @@
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-widget.dto';
export const fromFlatPageLayoutWidgetToPageLayoutWidgetDto = (
flatPageLayoutWidget: FlatPageLayoutWidget,
): PageLayoutWidgetDTO => {
const { createdAt, updatedAt, deletedAt, ...rest } = flatPageLayoutWidget;
return {
...rest,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
};
};
@@ -0,0 +1,15 @@
import { type FlatPageLayoutWithTabsAndWidgets } 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 { fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-tab-with-widgets-to-page-layout-tab-dto.util';
import { fromFlatPageLayoutToPageLayoutDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-to-page-layout-dto.util';
export const fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto = (
flatPageLayoutWithTabsAndWidgets: FlatPageLayoutWithTabsAndWidgets,
): PageLayoutDTO => {
const { tabs, ...flatPageLayout } = flatPageLayoutWithTabsAndWidgets;
return {
...fromFlatPageLayoutToPageLayoutDto(flatPageLayout),
tabs: tabs.map(fromFlatPageLayoutTabWithWidgetsToPageLayoutTabDto),
};
};
@@ -1,3 +1,4 @@
import { type I18n } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import {
@@ -16,8 +17,17 @@ import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { workspaceMigrationBuilderExceptionV2Formatter } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-exception-v2-formatter';
export const pageLayoutGraphqlApiExceptionHandler = (
error: Error,
i18n: I18n,
) => {
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
return workspaceMigrationBuilderExceptionV2Formatter(error, i18n);
}
export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof PageLayoutException) {
switch (error.code) {
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
@@ -1,20 +1,43 @@
import { ArgumentsHost, Catch } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import {
Catch,
type ExceptionFilter,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { PageLayoutTabException } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import { PageLayoutWidgetException } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutException } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { pageLayoutGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
@Catch(
PageLayoutException,
PageLayoutTabException,
PageLayoutWidgetException,
WorkspaceMigrationBuilderExceptionV2,
)
@Injectable()
export class PageLayoutGraphqlApiExceptionFilter implements ExceptionFilter {
constructor(private readonly i18nService: I18nService) {}
@Catch(PageLayoutException, PageLayoutTabException, PageLayoutWidgetException)
export class PageLayoutGraphqlApiExceptionFilter implements GqlExceptionFilter {
catch(
exception:
| PageLayoutException
| PageLayoutTabException
| PageLayoutWidgetException,
_host: ArgumentsHost,
| PageLayoutWidgetException
| WorkspaceMigrationBuilderExceptionV2,
host: ExecutionContext,
) {
return pageLayoutGraphqlApiExceptionHandler(exception);
const gqlContext = GqlExecutionContext.create(host);
const ctx = gqlContext.getContext();
const userLocale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
return pageLayoutGraphqlApiExceptionHandler(exception, i18n);
}
}
@@ -4,11 +4,12 @@ import { msg, t } from '@lingui/core/macro';
import { type ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { PageLayoutExceptionCode } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
const PAGE_LAYOUT_TAB_EXCEPTION_CODE = {
PAGE_LAYOUT_TAB_NOT_FOUND: 'PAGE_LAYOUT_TAB_NOT_FOUND',
@@ -18,6 +19,7 @@ const PAGE_LAYOUT_TAB_EXCEPTION_CODE = {
export class FlatPageLayoutTabValidatorService {
public validateFlatPageLayoutTabCreation({
flatEntityToValidate: flatPageLayoutTab,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { flatPageLayoutMaps },
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.pageLayoutTab
>): FailedFlatEntityValidation<FlatPageLayoutTab> {
@@ -30,6 +32,19 @@ export class FlatPageLayoutTabValidatorService {
},
};
const referencedPageLayout = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatPageLayoutTab.pageLayoutId,
flatEntityMaps: flatPageLayoutMaps,
});
if (!isDefined(referencedPageLayout)) {
validationResult.errors.push({
code: PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
message: t`Page layout not found`,
userFriendlyMessage: msg`Page layout not found`,
});
}
return validationResult;
}
@@ -4,7 +4,9 @@ import { msg, t } from '@lingui/core/macro';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { PageLayoutTabExceptionCode } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
import { PageLayoutWidgetExceptionCode } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
@@ -95,6 +97,7 @@ export class FlatPageLayoutWidgetValidatorService {
public validateFlatPageLayoutWidgetCreation({
flatEntityToValidate: flatPageLayoutWidgetToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: optimisticFlatPageLayoutWidgetMaps,
},
}: FlatEntityValidationArgs<
@@ -124,6 +127,19 @@ export class FlatPageLayoutWidgetValidatorService {
});
}
const referencedPageLayoutTab = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatPageLayoutWidgetToValidate.pageLayoutTabId,
flatEntityMaps: flatPageLayoutTabMaps,
});
if (!isDefined(referencedPageLayoutTab)) {
validationResult.errors.push({
code: PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
message: t`Page layout tab not found`,
userFriendlyMessage: msg`Page layout tab not found`,
});
}
return validationResult;
}
}
@@ -1,18 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
@Injectable()
@@ -23,8 +21,6 @@ export class DashboardCreateOnePreQueryHook
constructor(
private readonly pageLayoutService: PageLayoutService,
private readonly pageLayoutTabService: PageLayoutTabService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {}
async execute(
@@ -36,29 +32,25 @@ export class DashboardCreateOnePreQueryHook
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
return await this.coreDataSource.transaction(async (manager) => {
const pageLayout = await this.pageLayoutService.create(
{
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
name: 'Dashboard Layout',
},
workspace.id,
manager,
);
const pageLayout = await this.pageLayoutService.create(
{
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
name: 'Dashboard Layout',
},
workspace.id,
);
await this.pageLayoutTabService.create(
{
title: 'Tab 1',
pageLayoutId: pageLayout.id,
},
workspace.id,
manager,
);
await this.pageLayoutTabService.create(
{
title: 'Tab 1',
pageLayoutId: pageLayout.id,
},
workspace.id,
);
payload.data.pageLayoutId = pageLayout.id;
payload.data.pageLayoutId = pageLayout.id;
return payload;
});
return payload;
}
}
@@ -1,542 +0,0 @@
import gql from 'graphql-tag';
import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants';
import { createPageLayoutOperationFactory } from 'test/integration/graphql/utils/create-page-layout-operation-factory.util';
import { deletePageLayoutOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-operation-factory.util';
import { destroyPageLayoutOperationFactory } from 'test/integration/graphql/utils/destroy-page-layout-operation-factory.util';
import { findPageLayoutOperationFactory } from 'test/integration/graphql/utils/find-page-layout-operation-factory.util';
import { findPageLayoutsOperationFactory } from 'test/integration/graphql/utils/find-page-layouts-operation-factory.util';
import {
assertGraphQLErrorResponse,
assertGraphQLSuccessfulResponse,
} from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { createTestPageLayoutWithGraphQL } from 'test/integration/graphql/utils/page-layout-graphql.util';
import { restorePageLayoutOperationFactory } from 'test/integration/graphql/utils/restore-page-layout-operation-factory.util';
import { updatePageLayoutOperationFactory } from 'test/integration/graphql/utils/update-page-layout-operation-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import {
assertPageLayoutStructure,
cleanupPageLayoutRecords,
} from 'test/integration/utils/page-layout-test.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
describe('Page Layout Resolver', () => {
let testObjectMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestPageLayoutObject',
namePlural: 'myTestPageLayoutObjects',
labelSingular: 'My Test Page Layout Object',
labelPlural: 'My Test Page Layout Objects',
icon: 'IconLayout',
},
});
testObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
await cleanupPageLayoutRecords();
});
beforeEach(async () => {
await cleanupPageLayoutRecords();
});
describe('getPageLayouts', () => {
it('should return all page layouts for workspace when no objectMetadataId provided', async () => {
const pageLayoutName = 'Test Page Layout for Workspace';
await createTestPageLayoutWithGraphQL({
name: pageLayoutName,
objectMetadataId: testObjectMetadataId,
});
const operation = findPageLayoutsOperationFactory();
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayouts).toHaveLength(1);
assertPageLayoutStructure(response.body.data.getPageLayouts[0], {
name: pageLayoutName,
objectMetadataId: testObjectMetadataId,
type: PageLayoutType.RECORD_PAGE,
});
});
it('should filter page layouts by objectMetadataId when provided', async () => {
const object1PageLayoutName = 'Page Layout for Object 1';
const object2PageLayoutName = 'Page Layout for Object 2';
const {
data: {
createOneObject: { id: objectMetadata2Id },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestPageLayoutObject2',
namePlural: 'myTestPageLayoutObjects2',
labelSingular: 'My Test Page Layout Object 2',
labelPlural: 'My Test Page Layout Objects 2',
icon: 'IconLayout2',
isLabelSyncedWithName: false,
},
});
await Promise.all([
createTestPageLayoutWithGraphQL({
name: object1PageLayoutName,
objectMetadataId: testObjectMetadataId,
}),
createTestPageLayoutWithGraphQL({
name: object2PageLayoutName,
objectMetadataId: objectMetadata2Id,
}),
]);
const operation = findPageLayoutsOperationFactory({
objectMetadataId: testObjectMetadataId,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayouts).toHaveLength(1);
assertPageLayoutStructure(response.body.data.getPageLayouts[0], {
name: object1PageLayoutName,
objectMetadataId: testObjectMetadataId,
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadata2Id,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: objectMetadata2Id },
});
});
});
describe('getPageLayout', () => {
it('should throw when page layout does not exist', async () => {
const operation = findPageLayoutOperationFactory({
pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
),
);
});
it('should return page layout when it exists', async () => {
const pageLayoutName = 'Test Page Layout for Get';
const pageLayout = await createTestPageLayoutWithGraphQL({
name: pageLayoutName,
objectMetadataId: testObjectMetadataId,
});
const operation = findPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutStructure(response.body.data.getPageLayout, {
id: pageLayout.id,
name: pageLayoutName,
objectMetadataId: testObjectMetadataId,
type: PageLayoutType.RECORD_PAGE,
});
});
});
describe('createPageLayout', () => {
it('should create a new page layout with all properties', async () => {
const input = {
name: 'Dashboard Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
};
const operation = createPageLayoutOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdPageLayout = response.body.data.createPageLayout;
assertPageLayoutStructure(createdPageLayout, {
name: input.name,
type: input.type,
objectMetadataId: input.objectMetadataId,
deletedAt: null,
});
});
it('should create a page layout with minimum required fields', async () => {
const input = {
name: 'Minimal Page Layout',
};
const operation = createPageLayoutOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdPageLayout = response.body.data.createPageLayout;
assertPageLayoutStructure(createdPageLayout, {
name: input.name,
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
deletedAt: null,
});
});
});
describe('updatePageLayout', () => {
it('should update an existing page layout', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Original Page Layout',
type: PageLayoutType.RECORD_PAGE,
});
const updateInput = {
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
};
const operation = updatePageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutStructure(response.body.data.updatePageLayout, {
id: pageLayout.id,
name: updateInput.name,
type: updateInput.type,
deletedAt: null,
});
});
it('should update only provided fields', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Original Page Layout',
type: PageLayoutType.RECORD_PAGE,
});
const updateInput = {
name: 'Updated Name Only',
};
const operation = updatePageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutStructure(response.body.data.updatePageLayout, {
id: pageLayout.id,
name: updateInput.name,
type: PageLayoutType.RECORD_PAGE,
deletedAt: null,
});
});
it('should throw error when updating non-existent page layout', async () => {
const operation = updatePageLayoutOperationFactory({
pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
data: { name: 'Non-existent Page Layout' },
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
),
);
});
});
describe('deletePageLayout', () => {
it('should delete an existing page layout (soft delete)', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout to Delete',
});
const deleteOperation = deletePageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
assertPageLayoutStructure(
deleteResponse.body.data.deletePageLayout,
pageLayout,
);
const getOperation = findPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLErrorResponse(
getResponse,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
pageLayout.id,
),
);
});
it('should throw an error when deleting non-existent page layout', async () => {
const operation = deletePageLayoutOperationFactory({
pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
),
);
});
});
describe('destroyPageLayout', () => {
it('should destroy an existing page layout (hard delete)', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout to Destroy',
});
const destroyOperation = destroyPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyOperation);
assertGraphQLSuccessfulResponse(destroyResponse);
expect(destroyResponse.body.data.destroyPageLayout).toBe(true);
});
it('should throw an error when destroying non-existent page layout', async () => {
const operation = destroyPageLayoutOperationFactory({
pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
),
);
});
it('should destroy all associated dashboards when page layout is of type dashboard', async () => {
const dashboardId = '20202020-304c-44f2-ba7b-070762ff0e8a';
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout to Destroy',
type: PageLayoutType.DASHBOARD,
});
const findOneDashboardOperation = {
query: gql`
query Dashboard($filter: DashboardFilterInput!) {
dashboard(filter: $filter) {
id
}
}
`,
variables: {
filter: { id: { eq: dashboardId } },
},
};
await makeGraphqlAPIRequest({
query: gql`
mutation CreateDashboard($input: CreateDashboardInput!) {
createDashboard(input: $input) {
pageLayoutId
id
title
}
}
`,
variables: {
input: {
id: dashboardId,
name: 'Dashboard to Destroy',
pageLayoutId: pageLayout.id,
},
},
});
const findOneDashboardResponseBeforeDestroy = await makeGraphqlAPIRequest(
findOneDashboardOperation,
);
expect(
findOneDashboardResponseBeforeDestroy.body.data.dashboard,
).toBeDefined();
const destroyOperation = destroyPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyOperation);
const findOneDashboardResponseAfterDestroy = await makeGraphqlAPIRequest(
findOneDashboardOperation,
);
assertGraphQLSuccessfulResponse(destroyResponse);
expect(destroyResponse.body.data.destroyPageLayout).toBe(true);
assertGraphQLErrorResponse(
findOneDashboardResponseAfterDestroy,
ErrorCode.NOT_FOUND,
);
});
});
describe('restorePageLayout', () => {
it('should restore a soft deleted page layout', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout to Restore',
type: PageLayoutType.RECORD_INDEX,
});
const deleteOperation = deletePageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
const restoreOperation = restorePageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreOperation);
assertGraphQLSuccessfulResponse(restoreResponse);
assertPageLayoutStructure(restoreResponse.body.data.restorePageLayout, {
id: pageLayout.id,
name: 'Page Layout to Restore',
type: PageLayoutType.RECORD_INDEX,
deletedAt: null,
});
const getOperation = findPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLSuccessfulResponse(getResponse);
assertPageLayoutStructure(getResponse.body.data.getPageLayout, {
id: pageLayout.id,
name: 'Page Layout to Restore',
type: PageLayoutType.RECORD_INDEX,
deletedAt: null,
});
});
it('should throw an error when restoring non-existent page layout', async () => {
const operation = restorePageLayoutOperationFactory({
pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_ID,
),
);
});
});
describe('tabs resolver field', () => {
it('should resolve tabs field for page layout', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout with Tabs',
});
const operation = findPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
gqlFields: `
id
name
type
createdAt
updatedAt
deletedAt
tabs {
id
title
position
pageLayoutId
}
`,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutStructure(response.body.data.getPageLayout, {
id: pageLayout.id,
name: 'Page Layout with Tabs',
tabs: expect.any(Array),
});
});
});
});
@@ -1,458 +0,0 @@
import { TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID } from 'test/integration/constants/test-page-layout-tab-ids.constants';
import { createPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/create-page-layout-tab-operation-factory.util';
import { deletePageLayoutOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-operation-factory.util';
import { deletePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util';
import { destroyPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util';
import { findPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/find-page-layout-tab-operation-factory.util';
import { findPageLayoutTabsOperationFactory } from 'test/integration/graphql/utils/find-page-layout-tabs-operation-factory.util';
import {
assertGraphQLErrorResponse,
assertGraphQLSuccessfulResponse,
} from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
cleanupPageLayoutRecordsWithGraphQL,
createTestPageLayoutWithGraphQL,
} from 'test/integration/graphql/utils/page-layout-graphql.util';
import {
cleanupPageLayoutTabRecordsWithGraphQL,
createTestPageLayoutTabWithGraphQL,
} from 'test/integration/graphql/utils/page-layout-tab-graphql.util';
import { restorePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util';
import { updatePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/update-page-layout-tab-operation-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { assertPageLayoutTabStructure } from 'test/integration/utils/page-layout-tab-test.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutTabExceptionMessageKey,
generatePageLayoutTabExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-tab.exception';
describe('Page Layout Tab Resolver', () => {
let testObjectMetadataId: string;
let testPageLayoutId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestPageLayoutTabObject',
namePlural: 'myTestPageLayoutTabObjects',
labelSingular: 'My Test Page Layout Tab Object',
labelPlural: 'My Test Page Layout Tab Objects',
icon: 'IconTab',
},
});
testObjectMetadataId = objectMetadataId;
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Page Layout for Tabs',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
testPageLayoutId = pageLayout.id;
});
afterAll(async () => {
await cleanupPageLayoutRecordsWithGraphQL();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
await cleanupPageLayoutTabRecordsWithGraphQL(testPageLayoutId);
});
describe('getPageLayoutTabs', () => {
it('should return empty array when no page layout tabs exist', async () => {
const operation = findPageLayoutTabsOperationFactory({
pageLayoutId: testPageLayoutId,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayoutTabs).toEqual([]);
});
it('should return all page layout tabs for a specific page layout', async () => {
const input1 = {
title: 'Tab 1',
position: 0,
pageLayoutId: testPageLayoutId,
};
const input2 = {
title: 'Tab 2',
position: 1,
pageLayoutId: testPageLayoutId,
};
await Promise.all([
createTestPageLayoutTabWithGraphQL(input1),
createTestPageLayoutTabWithGraphQL(input2),
]);
const operation = findPageLayoutTabsOperationFactory({
pageLayoutId: testPageLayoutId,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayoutTabs).toHaveLength(2);
const tabs = response.body.data.getPageLayoutTabs.sort(
(a: { position: number }, b: { position: number }) =>
a.position - b.position,
);
assertPageLayoutTabStructure(tabs[0], input1);
assertPageLayoutTabStructure(tabs[1], input2);
});
});
describe('getPageLayoutTab', () => {
it('should throw when page layout tab does not exist', async () => {
const operation = findPageLayoutTabOperationFactory({
pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
),
);
});
it('should return page layout tab when it exists', async () => {
const tabTitle = 'Tab';
const input = {
title: tabTitle,
position: 2,
pageLayoutId: testPageLayoutId,
};
const tab = await createTestPageLayoutTabWithGraphQL(input);
const operation = findPageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutTabStructure(response.body.data.getPageLayoutTab, input);
});
});
describe('createPageLayoutTab', () => {
it('should create a new page layout tab with all properties', async () => {
const input = {
title: 'New Tab',
position: 5,
pageLayoutId: testPageLayoutId,
};
const operation = createPageLayoutTabOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdTab = response.body.data.createPageLayoutTab;
assertPageLayoutTabStructure(createdTab, input);
});
it('should create a page layout tab with minimum required fields', async () => {
const input = {
title: 'Minimal Tab',
pageLayoutId: testPageLayoutId,
};
const operation = createPageLayoutTabOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdTab = response.body.data.createPageLayoutTab;
assertPageLayoutTabStructure(createdTab, {
title: input.title,
position: 0,
pageLayoutId: input.pageLayoutId,
deletedAt: null,
});
});
});
describe('updatePageLayoutTab', () => {
it('should update an existing page layout tab', async () => {
const input = {
title: 'Original Tab',
position: 1,
pageLayoutId: testPageLayoutId,
};
const tab = await createTestPageLayoutTabWithGraphQL(input);
const updateInput = {
title: 'Updated Tab',
position: 3,
};
const operation = updatePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutTabStructure(
response.body.data.updatePageLayoutTab,
updateInput,
);
});
it('should update only provided fields', async () => {
const input = {
title: 'Original Tab',
position: 1,
pageLayoutId: testPageLayoutId,
};
const tab = await createTestPageLayoutTabWithGraphQL(input);
const updateInput = {
title: 'Updated Title Only',
};
const operation = updatePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutTabStructure(response.body.data.updatePageLayoutTab, {
...input,
...updateInput,
});
});
it('should throw error when updating non-existent page layout tab', async () => {
const operation = updatePageLayoutTabOperationFactory({
pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
data: { title: 'Non-existent Tab' },
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
),
);
});
});
describe('deletePageLayoutTab', () => {
it('should delete an existing page layout tab (soft delete)', async () => {
const tab = await createTestPageLayoutTabWithGraphQL({
title: 'Tab to Delete',
pageLayoutId: testPageLayoutId,
});
const deleteOperation = deletePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
expect(deleteResponse.body.data.deletePageLayoutTab).toBe(true);
const getOperation = findPageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLErrorResponse(
getResponse,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
tab.id,
),
);
});
it('should throw an error when deleting non-existent page layout tab', async () => {
const operation = deletePageLayoutTabOperationFactory({
pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
),
);
});
});
describe('destroyPageLayoutTab', () => {
it('should destroy an existing page layout tab (hard delete)', async () => {
const tab = await createTestPageLayoutTabWithGraphQL({
title: 'Tab to Destroy',
pageLayoutId: testPageLayoutId,
});
const destroyOperation = destroyPageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyOperation);
assertGraphQLSuccessfulResponse(destroyResponse);
expect(destroyResponse.body.data.destroyPageLayoutTab).toBe(true);
});
it('should throw an error when destroying non-existent page layout tab', async () => {
const operation = destroyPageLayoutTabOperationFactory({
pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
),
);
});
});
describe('restorePageLayoutTab', () => {
it('should restore a soft deleted page layout tab', async () => {
const input = {
title: 'Tab to Restore',
position: 2,
pageLayoutId: testPageLayoutId,
};
const tab = await createTestPageLayoutTabWithGraphQL(input);
const deleteOperation = deletePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
const restoreOperation = restorePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreOperation);
assertGraphQLSuccessfulResponse(restoreResponse);
assertPageLayoutTabStructure(
restoreResponse.body.data.restorePageLayoutTab,
input,
);
const getOperation = findPageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLSuccessfulResponse(getResponse);
assertPageLayoutTabStructure(
getResponse.body.data.getPageLayoutTab,
input,
);
});
it('should throw an error when restoring non-existent page layout tab', async () => {
const operation = restorePageLayoutTabOperationFactory({
pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID,
),
);
});
it('should throw an error when restoring tab with deleted parent page layout', async () => {
const separatePageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout for Tab Restore Test',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
const input = {
title: 'Tab with Deleted Page Layout',
pageLayoutId: separatePageLayout.id,
position: 0,
};
const tab = await createTestPageLayoutTabWithGraphQL(input);
const deleteTabOperation = deletePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
await makeGraphqlAPIRequest(deleteTabOperation);
const deletePageLayoutOperation = deletePageLayoutOperationFactory({
pageLayoutId: separatePageLayout.id,
});
await makeGraphqlAPIRequest(deletePageLayoutOperation);
const restoreOperation = restorePageLayoutTabOperationFactory({
pageLayoutTabId: tab.id,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreOperation);
assertGraphQLErrorResponse(
restoreResponse,
ErrorCode.BAD_USER_INPUT,
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
});
@@ -1,576 +0,0 @@
import {
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
TEST_IFRAME_CONFIG,
TEST_IFRAME_CONFIG_ALTERNATIVE,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
} from 'test/integration/constants/widget-configuration-test-data.constants';
import { findPageLayoutOperationFactory } from 'test/integration/graphql/utils/find-page-layout-operation-factory.util';
import {
assertGraphQLErrorResponse,
assertGraphQLSuccessfulResponse,
} from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { createTestPageLayoutWithGraphQL } from 'test/integration/graphql/utils/page-layout-graphql.util';
import { updatePageLayoutWithTabsOperationFactory } from 'test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { cleanupPageLayoutTabRecords } from 'test/integration/utils/page-layout-tab-test.util';
import {
assertPageLayoutStructure,
cleanupPageLayoutRecords,
} from 'test/integration/utils/page-layout-test.util';
import { cleanupPageLayoutWidgetRecords } from 'test/integration/utils/page-layout-widget-test.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
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';
const existingTabId = '20202020-e02c-4292-9994-42695c4e41e8';
const tabToUpdateId = '20202020-974a-4480-b814-1fca24937132';
const tabToDeleteId = '20202020-a510-49e6-a9d9-0ff3e5c6a1ae';
const newTabId = '20202020-1db1-4c18-8804-6c700a8106a6';
const existingWidgetId = '20202020-afbe-46a3-a759-9a10ea7f5888';
const widgetToUpdateId = '20202020-c312-4342-93c0-f02c92c4a609';
const widgetToDeleteId = '20202020-8a0b-4ec2-8784-af4adb27c18a';
const newWidgetId = '20202020-da21-4321-b313-699eeff00798';
const anotherNewWidgetId = '20202020-3937-4617-bc88-8a811f769c90';
describe('Page Layout Update With Tabs And Widgets Integration', () => {
let testObjectMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestUpdatePageLayoutObject',
namePlural: 'myTestUpdatePageLayoutObjects',
labelSingular: 'My Test Update Page Layout Object',
labelPlural: 'My Test Update Page Layout Objects',
icon: 'IconUpdate',
},
});
testObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
await cleanupPageLayoutRecords();
await cleanupPageLayoutTabRecords();
await cleanupPageLayoutWidgetRecords();
});
describe('updatePageLayoutWithTabsAndWidgets', () => {
it('should handle complex page layout update with tab and widget CRUD operations', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Initial Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
const initialUpdateInput = {
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
tabs: [
{
id: existingTabId,
title: 'Existing Tab',
position: 1,
widgets: [
{
id: existingWidgetId,
pageLayoutTabId: existingTabId,
title: 'Existing Widget',
type: WidgetType.VIEW,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
configuration: null,
},
{
id: widgetToUpdateId,
pageLayoutTabId: existingTabId,
title: 'Widget To Update',
type: WidgetType.GRAPH,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 0,
column: 2,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_NUMBER_CHART_CONFIG_MINIMAL,
},
{
id: widgetToDeleteId,
pageLayoutTabId: existingTabId,
title: 'Widget To Delete',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 1,
column: 2,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
],
},
{
id: tabToUpdateId,
title: 'Tab To Update',
position: 2,
widgets: [],
},
{
id: tabToDeleteId,
title: 'Tab To Delete',
position: 3,
widgets: [],
},
],
};
const initialOperation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: initialUpdateInput,
});
const initialResponse = await makeGraphqlAPIRequest(initialOperation);
assertGraphQLSuccessfulResponse(initialResponse);
const initialResult =
initialResponse.body.data.updatePageLayoutWithTabsAndWidgets;
assertPageLayoutStructure(initialResult, {
id: pageLayout.id,
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
});
expect(initialResult.tabs).toHaveLength(3);
expect(initialResult.tabs[0].widgets).toHaveLength(3);
const complexUpdateInput = {
name: 'Final Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
tabs: [
{
id: existingTabId,
title: 'Updated Existing Tab',
position: 1,
widgets: [
{
id: existingWidgetId,
pageLayoutTabId: existingTabId,
title: 'Existing Widget',
type: WidgetType.VIEW,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
configuration: null,
},
{
id: widgetToUpdateId,
pageLayoutTabId: existingTabId,
title: 'Updated Widget Title',
type: WidgetType.GRAPH,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 0,
column: 2,
rowSpan: 2,
columnSpan: 3,
},
configuration: TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
},
{
id: newWidgetId,
pageLayoutTabId: existingTabId,
title: 'New Widget',
type: WidgetType.FIELDS,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 2,
column: 0,
rowSpan: 1,
columnSpan: 2,
},
configuration: null,
},
],
},
{
id: tabToUpdateId,
title: 'Completely Updated Tab',
position: 3,
widgets: [
{
id: anotherNewWidgetId,
pageLayoutTabId: tabToUpdateId,
title: 'Another New Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG_ALTERNATIVE,
},
],
},
{
id: newTabId,
title: 'Brand New Tab',
position: 2,
widgets: [],
},
],
};
const complexOperation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: complexUpdateInput,
});
const complexResponse = await makeGraphqlAPIRequest(complexOperation);
assertGraphQLSuccessfulResponse(complexResponse);
const finalResult: PageLayoutEntity =
complexResponse.body.data.updatePageLayoutWithTabsAndWidgets;
assertPageLayoutStructure(finalResult, {
id: pageLayout.id,
name: 'Final Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
expect(finalResult.tabs).toHaveLength(3);
const updatedExistingTab = finalResult.tabs.find(
(tab) => tab.id === existingTabId,
);
const updatedTab = finalResult.tabs.find(
(tab) => tab.id === tabToUpdateId,
);
const newTab = finalResult.tabs.find((tab) => tab.id === newTabId);
const deletedTab = finalResult.tabs.find(
(tab) => tab.id === tabToDeleteId,
);
expect(updatedExistingTab).toBeDefined();
expect(updatedExistingTab?.title).toBe('Updated Existing Tab');
expect(updatedExistingTab?.position).toBe(1);
expect(updatedTab).toBeDefined();
expect(updatedTab?.title).toBe('Completely Updated Tab');
expect(updatedTab?.position).toBe(3);
expect(newTab).toBeDefined();
expect(newTab?.title).toBe('Brand New Tab');
expect(newTab?.position).toBe(2);
expect(deletedTab).toBeUndefined();
const firstTabWidgets = updatedExistingTab?.widgets;
expect(firstTabWidgets).toHaveLength(3);
const existingWidget = firstTabWidgets?.find(
(widget) => widget.id === existingWidgetId,
);
const updatedWidget = firstTabWidgets?.find(
(widget) => widget.id === widgetToUpdateId,
);
const newWidget = firstTabWidgets?.find(
(widget) => widget.id === newWidgetId,
);
const deletedWidget = firstTabWidgets?.find(
(widget) => widget.id === widgetToDeleteId,
);
expect(existingWidget).toBeDefined();
expect(existingWidget?.title).toBe('Existing Widget');
expect(existingWidget?.type).toBe(WidgetType.VIEW);
expect(updatedWidget).toBeDefined();
expect(updatedWidget?.title).toBe('Updated Widget Title');
expect(updatedWidget?.type).toBe(WidgetType.GRAPH);
expect(updatedWidget?.gridPosition.rowSpan).toBe(2);
expect(updatedWidget?.gridPosition.columnSpan).toBe(3);
expect(newWidget).toBeDefined();
expect(newWidget?.title).toBe('New Widget');
expect(newWidget?.type).toBe(WidgetType.FIELDS);
expect(deletedWidget).toBeUndefined();
const secondTabWidgets = updatedTab?.widgets;
expect(secondTabWidgets).toHaveLength(1);
const anotherNewWidget = secondTabWidgets?.find(
(widget) => widget.id === anotherNewWidgetId,
);
expect(anotherNewWidget).toBeDefined();
expect(anotherNewWidget?.title).toBe('Another New Widget');
expect(anotherNewWidget?.type).toBe(WidgetType.IFRAME);
expect(newTab?.widgets).toHaveLength(0);
});
it('should handle transaction rollback on error during complex update', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Transaction Rollback',
type: PageLayoutType.RECORD_PAGE,
});
const invalidUpdateInput = {
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'invalid-uuid',
tabs: [
{
id: '20202020-9a4c-4f97-bd63-a5f5843ee610',
title: 'Test Tab',
position: 1,
widgets: [],
},
],
};
const operation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: invalidUpdateInput,
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBeGreaterThan(0);
const finalPageLayout = findPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const finalResponse = await makeGraphqlAPIRequest(finalPageLayout);
assertGraphQLSuccessfulResponse(finalResponse);
const finalResult = finalResponse.body.data.getPageLayout;
assertPageLayoutStructure(finalResult, {
id: pageLayout.id,
name: 'Test Transaction Rollback',
type: PageLayoutType.RECORD_PAGE,
});
});
it('should throw when empty tabs array is provided', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Empty Tabs',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
const updateInput = {
name: 'Updated Layout With No Tabs',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
tabs: [],
};
const operation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(response, ErrorCode.BAD_USER_INPUT);
});
it('should reject invalid widget configurations', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Invalid Config',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
const invalidConfigInput = {
name: 'Layout with Invalid Widget Config',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
tabs: [
{
id: '20202020-1001-4001-a001-000000000001',
title: 'Tab with Invalid Widget',
position: 1,
widgets: [
{
id: '20202020-1002-4002-a002-000000000002',
pageLayoutTabId: '20202020-1001-4001-a001-000000000001',
title: 'Invalid Iframe Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: { url: 'not-a-valid-url' },
},
],
},
],
};
const operation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: invalidConfigInput,
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain(
'Invalid configuration for widget "Invalid Iframe Widget" of type IFRAME',
);
expect(response.body.errors[0].message).toContain('url must be');
});
it('should accept valid widget configurations for each type', async () => {
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Valid Configs',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
const validConfigInput = {
name: 'Layout with Valid Widget Configs',
type: PageLayoutType.DASHBOARD,
objectMetadataId: testObjectMetadataId,
tabs: [
{
id: '20202020-1010-4010-a010-101010101010',
title: 'Tab with Valid Widgets',
position: 1,
widgets: [
{
id: '20202020-1011-4011-a011-111111111111',
pageLayoutTabId: '20202020-1010-4010-a010-101010101010',
title: 'Valid Iframe Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: TEST_IFRAME_CONFIG,
},
{
id: '20202020-1012-4012-a012-121212121212',
pageLayoutTabId: '20202020-1010-4010-a010-101010101010',
title: 'Valid Graph Widget',
type: WidgetType.GRAPH,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 1,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
configuration: TEST_NUMBER_CHART_CONFIG_MINIMAL,
},
{
id: '20202020-1013-4013-a013-131313131313',
pageLayoutTabId: '20202020-1010-4010-a010-101010101010',
title: 'Valid View Widget',
type: WidgetType.VIEW,
objectMetadataId: testObjectMetadataId,
gridPosition: {
row: 0,
column: 1,
rowSpan: 1,
columnSpan: 1,
},
configuration: null,
},
],
},
],
};
const operation = updatePageLayoutWithTabsOperationFactory({
pageLayoutId: pageLayout.id,
data: validConfigInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const result = response.body.data.updatePageLayoutWithTabsAndWidgets;
const widgets = result.tabs[0].widgets;
expect(widgets).toHaveLength(3);
const iframeWidget = widgets.find(
(w: any) => w.type === WidgetType.IFRAME,
);
expect(iframeWidget.configuration).toBeDefined();
expect(iframeWidget.configuration.url).toBe(TEST_IFRAME_CONFIG.url);
const graphWidget = widgets.find((w: any) => w.type === WidgetType.GRAPH);
expect(graphWidget.configuration).toBeDefined();
expect(graphWidget.configuration.graphType).toBe(
TEST_NUMBER_CHART_CONFIG_MINIMAL.graphType,
);
const viewWidget = widgets.find((w: any) => w.type === WidgetType.VIEW);
expect(viewWidget.configuration).toBeNull();
});
});
});
@@ -1,511 +0,0 @@
import { TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID } from 'test/integration/constants/test-page-layout-widget-ids.constants';
import { createPageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/create-page-layout-widget-operation-factory.util';
import { deletePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util';
import { deletePageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-widget-operation-factory.util';
import { destroyPageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/destroy-page-layout-widget-operation-factory.util';
import { findPageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/find-page-layout-widget-operation-factory.util';
import { findPageLayoutWidgetsOperationFactory } from 'test/integration/graphql/utils/find-page-layout-widgets-operation-factory.util';
import {
assertGraphQLErrorResponse,
assertGraphQLSuccessfulResponse,
} from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
cleanupPageLayoutRecordsWithGraphQL,
createTestPageLayoutWithGraphQL,
} from 'test/integration/graphql/utils/page-layout-graphql.util';
import { createTestPageLayoutTabWithGraphQL } from 'test/integration/graphql/utils/page-layout-tab-graphql.util';
import {
cleanupPageLayoutWidgetRecordsWithGraphQL,
createTestPageLayoutWidgetWithGraphQL,
} from 'test/integration/graphql/utils/page-layout-widget-graphql.util';
import { restorePageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/restore-page-layout-widget-operation-factory.util';
import { updatePageLayoutWidgetOperationFactory } from 'test/integration/graphql/utils/update-page-layout-widget-operation-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { assertPageLayoutWidgetStructure } from 'test/integration/utils/page-layout-widget-test.util';
import { isDefined } from 'twenty-shared/utils';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
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';
import {
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout-widget.exception';
describe('Page Layout Widget Resolver', () => {
let testObjectMetadataId: string;
let testPageLayoutId: string;
let testPageLayoutTabId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestPageLayoutWidgetObject',
namePlural: 'myTestPageLayoutWidgetObjects',
labelSingular: 'My Test Page Layout Widget Object',
labelPlural: 'My Test Page Layout Widget Objects',
icon: 'IconWidget',
},
});
testObjectMetadataId = objectMetadataId;
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Test Page Layout for Widgets',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: testObjectMetadataId,
});
testPageLayoutId = pageLayout.id;
const pageLayoutTab = await createTestPageLayoutTabWithGraphQL({
title: 'Test Tab for Widgets',
position: 0,
pageLayoutId: testPageLayoutId,
});
testPageLayoutTabId = pageLayoutTab.id;
});
afterAll(async () => {
await cleanupPageLayoutRecordsWithGraphQL();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(async () => {
await cleanupPageLayoutWidgetRecordsWithGraphQL(testPageLayoutTabId);
});
describe('getPageLayoutWidgets', () => {
it('should return empty array when no page layout widgets exist', async () => {
const operation = findPageLayoutWidgetsOperationFactory({
pageLayoutTabId: testPageLayoutTabId,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayoutWidgets).toEqual([]);
});
it('should return all page layout widgets for a specific page layout tab', async () => {
const widgetTitle1 = 'Widget 1';
const widgetTitle2 = 'Widget 2';
const [{ id: widget1Id }, { id: widget2Id }] = await Promise.all([
createTestPageLayoutWidgetWithGraphQL({
title: widgetTitle1,
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
}),
createTestPageLayoutWidgetWithGraphQL({
title: widgetTitle2,
type: WidgetType.GRAPH,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 3, rowSpan: 1, columnSpan: 2 },
}),
]);
const operation = findPageLayoutWidgetsOperationFactory({
pageLayoutTabId: testPageLayoutTabId,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getPageLayoutWidgets).toHaveLength(2);
const widgets: PageLayoutWidgetEntity[] =
response.body.data.getPageLayoutWidgets ?? [];
const widget1 = widgets.find((widget) => widget.id === widget1Id);
const widget2 = widgets.find((widget) => widget.id === widget2Id);
if (!isDefined(widget1) || !isDefined(widget2)) {
throw new Error('Widget not found');
}
assertPageLayoutWidgetStructure(widget1, {
title: widgetTitle1,
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
});
assertPageLayoutWidgetStructure(widget2, {
title: widgetTitle2,
type: WidgetType.GRAPH,
pageLayoutTabId: testPageLayoutTabId,
});
});
});
describe('getPageLayoutWidget', () => {
it('should throw when page layout widget does not exist', async () => {
const operation = findPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
),
);
});
it('should return page layout widget when it exists', async () => {
const widgetTitle = 'Widget';
const input = {
title: widgetTitle,
type: WidgetType.FIELDS,
pageLayoutTabId: testPageLayoutTabId,
objectMetadataId: testObjectMetadataId,
gridPosition: { row: 1, column: 1, rowSpan: 1, columnSpan: 2 },
configuration: null,
};
const widget = await createTestPageLayoutWidgetWithGraphQL(input);
const operation = findPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutWidgetStructure(
response.body.data.getPageLayoutWidget,
input,
);
});
});
describe('createPageLayoutWidget', () => {
it('should create a new page layout widget with all properties', async () => {
const input = {
title: 'New Widget',
type: WidgetType.FIELDS,
pageLayoutTabId: testPageLayoutTabId,
objectMetadataId: testObjectMetadataId,
gridPosition: { row: 2, column: 1, rowSpan: 3, columnSpan: 4 },
configuration: null,
};
const operation = createPageLayoutWidgetOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdWidget = response.body.data.createPageLayoutWidget;
assertPageLayoutWidgetStructure(createdWidget, input);
});
it('should create a page layout widget with minimum required fields', async () => {
const input = {
title: 'Widget',
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
};
const operation = createPageLayoutWidgetOperationFactory({ data: input });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
const createdWidget = response.body.data.createPageLayoutWidget;
assertPageLayoutWidgetStructure(createdWidget, input);
});
});
describe('updatePageLayoutWidget', () => {
it('should update an existing page layout widget', async () => {
const widget = await createTestPageLayoutWidgetWithGraphQL({
title: 'Widget',
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
});
const updateInput = {
title: 'Updated Widget',
type: WidgetType.IFRAME,
gridPosition: { row: 1, column: 2, rowSpan: 2, columnSpan: 3 },
configuration: null,
};
const operation = updatePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutWidgetStructure(
response.body.data.updatePageLayoutWidget,
updateInput,
);
});
it('should update only provided fields', async () => {
const widget = await createTestPageLayoutWidgetWithGraphQL({
title: 'Widget',
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
});
const updateInput = {
title: 'Updated Widget',
};
const operation = updatePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
data: updateInput,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
assertPageLayoutWidgetStructure(
response.body.data.updatePageLayoutWidget,
updateInput,
);
});
it('should throw error when updating non-existent page layout widget', async () => {
const operation = updatePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
data: { title: 'Non-existent Widget' },
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
),
);
});
});
describe('deletePageLayoutWidget', () => {
it('should delete an existing page layout widget (soft delete)', async () => {
const input = {
title: 'Widget',
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
};
const widget = await createTestPageLayoutWidgetWithGraphQL(input);
const deleteOperation = deletePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
assertPageLayoutWidgetStructure(
deleteResponse.body.data.deletePageLayoutWidget,
input,
);
const getOperation = findPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLErrorResponse(
getResponse,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
widget.id,
),
);
});
it('should throw an error when deleting non-existent page layout widget', async () => {
const operation = deletePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
),
);
});
});
describe('destroyPageLayoutWidget', () => {
it('should destroy an existing page layout widget (hard delete)', async () => {
const input = {
title: 'Widget',
type: WidgetType.VIEW,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
};
const widget = await createTestPageLayoutWidgetWithGraphQL(input);
const destroyOperation = destroyPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyOperation);
assertGraphQLSuccessfulResponse(destroyResponse);
expect(destroyResponse.body.data.destroyPageLayoutWidget).toBe(true);
});
it('should throw an error when destroying non-existent page layout widget', async () => {
const operation = destroyPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
),
);
});
});
describe('restorePageLayoutWidget', () => {
it('should restore a soft deleted page layout widget', async () => {
const input = {
title: 'Widget',
type: WidgetType.GRAPH,
pageLayoutTabId: testPageLayoutTabId,
gridPosition: { row: 2, column: 1, rowSpan: 1, columnSpan: 2 },
configuration: null,
};
const widget = await createTestPageLayoutWidgetWithGraphQL(input);
const deleteOperation = deletePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteOperation);
assertGraphQLSuccessfulResponse(deleteResponse);
const restoreOperation = restorePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreOperation);
assertGraphQLSuccessfulResponse(restoreResponse);
assertPageLayoutWidgetStructure(
restoreResponse.body.data.restorePageLayoutWidget,
input,
);
const getOperation = findPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const getResponse = await makeGraphqlAPIRequest(getOperation);
assertGraphQLSuccessfulResponse(getResponse);
assertPageLayoutWidgetStructure(
getResponse.body.data.getPageLayoutWidget,
input,
);
});
it('should throw an error when restoring non-existent page layout widget', async () => {
const operation = restorePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
});
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLErrorResponse(
response,
ErrorCode.NOT_FOUND,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
TEST_NOT_EXISTING_PAGE_LAYOUT_WIDGET_ID,
),
);
});
it('should throw an error when restoring widget with deleted parent tab', async () => {
const separateTab = await createTestPageLayoutTabWithGraphQL({
title: 'Tab for Widget Restore Test',
pageLayoutId: testPageLayoutId,
position: 1,
});
const input = {
title: 'Widget with Deleted Tab',
type: WidgetType.GRAPH,
pageLayoutTabId: separateTab.id,
gridPosition: { row: 1, column: 1, rowSpan: 1, columnSpan: 1 },
configuration: null,
};
const widget = await createTestPageLayoutWidgetWithGraphQL(input);
const deleteWidgetOperation = deletePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
await makeGraphqlAPIRequest(deleteWidgetOperation);
const deleteTabOperation = deletePageLayoutTabOperationFactory({
pageLayoutTabId: separateTab.id,
});
await makeGraphqlAPIRequest(deleteTabOperation);
const restoreOperation = restorePageLayoutWidgetOperationFactory({
pageLayoutWidgetId: widget.id,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreOperation);
assertGraphQLErrorResponse(
restoreResponse,
ErrorCode.BAD_USER_INPUT,
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
});
});
});
@@ -1,25 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
type CreatePageLayoutOperationFactoryParams = {
gqlFields?: string;
data: CreatePageLayoutInput;
};
export const createPageLayoutOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
data,
}: CreatePageLayoutOperationFactoryParams) => ({
query: gql`
mutation CreatePageLayout($input: CreatePageLayoutInput!) {
createPageLayout(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: data,
},
});
@@ -1,25 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
type CreatePageLayoutTabOperationFactoryParams = {
gqlFields?: string;
data: CreatePageLayoutTabInput;
};
export const createPageLayoutTabOperationFactory = ({
gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS,
data,
}: CreatePageLayoutTabOperationFactoryParams) => ({
query: gql`
mutation CreatePageLayoutTab($input: CreatePageLayoutTabInput!) {
createPageLayoutTab(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: data,
},
});
@@ -1,25 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
type CreatePageLayoutWidgetOperationFactoryParams = {
gqlFields?: string;
data: CreatePageLayoutWidgetInput;
};
export const createPageLayoutWidgetOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
data,
}: CreatePageLayoutWidgetOperationFactoryParams) => ({
query: gql`
mutation CreatePageLayoutWidget($input: CreatePageLayoutWidgetInput!) {
createPageLayoutWidget(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: data,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type DeletePageLayoutOperationFactoryParams = {
pageLayoutId: string;
gqlFields?: string;
};
export const deletePageLayoutOperationFactory = ({
pageLayoutId,
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
}: DeletePageLayoutOperationFactoryParams) => ({
query: gql`
mutation DeletePageLayout($id: String!) {
deletePageLayout(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type DeletePageLayoutWidgetOperationFactoryParams = {
gqlFields?: string;
pageLayoutWidgetId: string;
};
export const deletePageLayoutWidgetOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
pageLayoutWidgetId,
}: DeletePageLayoutWidgetOperationFactoryParams) => ({
query: gql`
mutation DeletePageLayoutWidget($id: String!) {
deletePageLayoutWidget(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutWidgetId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutOperationFactoryParams = {
gqlFields?: string;
pageLayoutId: string;
};
export const findPageLayoutOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
pageLayoutId,
}: FindPageLayoutOperationFactoryParams) => ({
query: gql`
query GetPageLayout($id: String!) {
getPageLayout(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutTabOperationFactoryParams = {
gqlFields?: string;
pageLayoutTabId: string;
};
export const findPageLayoutTabOperationFactory = ({
gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS,
pageLayoutTabId,
}: FindPageLayoutTabOperationFactoryParams) => ({
query: gql`
query GetPageLayoutTab($id: String!) {
getPageLayoutTab(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutTabId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutTabsOperationFactoryParams = {
gqlFields?: string;
pageLayoutId: string;
};
export const findPageLayoutTabsOperationFactory = ({
gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS,
pageLayoutId,
}: FindPageLayoutTabsOperationFactoryParams) => ({
query: gql`
query GetPageLayoutTabs($pageLayoutId: String!) {
getPageLayoutTabs(pageLayoutId: $pageLayoutId) {
${gqlFields}
}
}
`,
variables: {
pageLayoutId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutWidgetOperationFactoryParams = {
gqlFields?: string;
pageLayoutWidgetId: string;
};
export const findPageLayoutWidgetOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
pageLayoutWidgetId,
}: FindPageLayoutWidgetOperationFactoryParams) => ({
query: gql`
query GetPageLayoutWidget($id: String!) {
getPageLayoutWidget(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutWidgetId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutWidgetsOperationFactoryParams = {
gqlFields?: string;
pageLayoutTabId: string;
};
export const findPageLayoutWidgetsOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
pageLayoutTabId,
}: FindPageLayoutWidgetsOperationFactoryParams) => ({
query: gql`
query GetPageLayoutWidgets($pageLayoutTabId: String!) {
getPageLayoutWidgets(pageLayoutTabId: $pageLayoutTabId) {
${gqlFields}
}
}
`,
variables: {
pageLayoutTabId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type FindPageLayoutsOperationFactoryParams = {
gqlFields?: string;
objectMetadataId?: string;
};
export const findPageLayoutsOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
objectMetadataId,
}: FindPageLayoutsOperationFactoryParams = {}) => ({
query: gql`
query GetPageLayouts($objectMetadataId: String) {
getPageLayouts(objectMetadataId: $objectMetadataId) {
${gqlFields}
}
}
`,
variables: {
objectMetadataId,
},
});
@@ -1,60 +0,0 @@
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 PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { createPageLayoutOperationFactory } from './create-page-layout-operation-factory.util';
import { destroyPageLayoutOperationFactory } from './destroy-page-layout-operation-factory.util';
import { findPageLayoutsOperationFactory } from './find-page-layouts-operation-factory.util';
interface CreatePageLayoutResponse extends Record<string, unknown> {
createPageLayout: PageLayoutEntity;
}
export const createTestPageLayoutWithGraphQL = async (
data: {
name: string;
type?: PageLayoutType;
objectMetadataId?: string;
} = { name: 'Test Page Layout' },
): Promise<PageLayoutEntity> => {
const operation = createPageLayoutOperationFactory({
data: {
name: data.name,
type: data.type || PageLayoutType.RECORD_PAGE,
objectMetadataId: data.objectMetadataId,
},
});
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<CreatePageLayoutResponse>;
if (response.body.errors) {
throw new Error(
`Failed to create test page layout: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from createTestPageLayoutWithGraphQL');
}
return response.body.data.createPageLayout;
};
export const cleanupPageLayoutRecordsWithGraphQL = async (): Promise<void> => {
const operation = findPageLayoutsOperationFactory();
const response = await makeGraphqlAPIRequest(operation);
if (response.body.data?.getPageLayouts) {
for (const pageLayout of response.body.data.getPageLayouts) {
const destroyOperation = destroyPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
await makeGraphqlAPIRequest(destroyOperation);
}
}
};
@@ -1,59 +0,0 @@
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 PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
import { createPageLayoutTabOperationFactory } from './create-page-layout-tab-operation-factory.util';
import { destroyPageLayoutTabOperationFactory } from './destroy-page-layout-tab-operation-factory.util';
import { findPageLayoutTabsOperationFactory } from './find-page-layout-tabs-operation-factory.util';
interface CreatePageLayoutTabResponse extends Record<string, unknown> {
createPageLayoutTab: PageLayoutTabEntity;
}
export const createTestPageLayoutTabWithGraphQL = async (data: {
title: string;
position?: number;
pageLayoutId: string;
}): Promise<PageLayoutTabEntity> => {
const operation = createPageLayoutTabOperationFactory({
data: {
title: data.title,
position: data.position || 0,
pageLayoutId: data.pageLayoutId,
},
});
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<CreatePageLayoutTabResponse>;
if (response.body.errors) {
throw new Error(
`Failed to create test page layout tab: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error('No data returned from createTestPageLayoutTabWithGraphQL');
}
return response.body.data.createPageLayoutTab;
};
export const cleanupPageLayoutTabRecordsWithGraphQL = async (
pageLayoutId: string,
): Promise<void> => {
const operation = findPageLayoutTabsOperationFactory({ pageLayoutId });
const response = await makeGraphqlAPIRequest(operation);
if (response.body.data?.getPageLayoutTabs) {
for (const pageLayoutTab of response.body.data.getPageLayoutTabs) {
const destroyOperation = destroyPageLayoutTabOperationFactory({
pageLayoutTabId: pageLayoutTab.id,
});
await makeGraphqlAPIRequest(destroyOperation);
}
}
};
@@ -1,78 +0,0 @@
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 PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import { createPageLayoutWidgetOperationFactory } from './create-page-layout-widget-operation-factory.util';
import { destroyPageLayoutWidgetOperationFactory } from './destroy-page-layout-widget-operation-factory.util';
import { findPageLayoutWidgetsOperationFactory } from './find-page-layout-widgets-operation-factory.util';
interface CreatePageLayoutWidgetResponse extends Record<string, unknown> {
createPageLayoutWidget: PageLayoutWidgetEntity;
}
export const createTestPageLayoutWidgetWithGraphQL = async (data: {
title: string;
type?: WidgetType;
pageLayoutTabId: string;
objectMetadataId?: string | null;
gridPosition?: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
configuration?: Record<string, unknown> | null;
}): Promise<PageLayoutWidgetEntity> => {
const operation = createPageLayoutWidgetOperationFactory({
data: {
title: data.title,
type: data.type || WidgetType.VIEW,
pageLayoutTabId: data.pageLayoutTabId,
objectMetadataId: data.objectMetadataId || null,
gridPosition: data.gridPosition || {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: data.configuration || null,
},
});
const response = (await makeGraphqlAPIRequest(
operation,
)) as GraphQLResponse<CreatePageLayoutWidgetResponse>;
if (response.body.errors) {
throw new Error(
`Failed to create test page layout widget: ${JSON.stringify(response.body.errors)}`,
);
}
if (!response.body.data) {
throw new Error(
'No data returned from createTestPageLayoutWidgetWithGraphQL',
);
}
return response.body.data.createPageLayoutWidget;
};
export const cleanupPageLayoutWidgetRecordsWithGraphQL = async (
pageLayoutTabId: string,
): Promise<void> => {
const operation = findPageLayoutWidgetsOperationFactory({ pageLayoutTabId });
const response = await makeGraphqlAPIRequest(operation);
if (response.body.data?.getPageLayoutWidgets) {
for (const pageLayoutWidget of response.body.data.getPageLayoutWidgets) {
const destroyOperation = destroyPageLayoutWidgetOperationFactory({
pageLayoutWidgetId: pageLayoutWidget.id,
});
await makeGraphqlAPIRequest(destroyOperation);
}
}
};
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type RestorePageLayoutOperationFactoryParams = {
gqlFields?: string;
pageLayoutId: string;
};
export const restorePageLayoutOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
pageLayoutId,
}: RestorePageLayoutOperationFactoryParams) => ({
query: gql`
mutation RestorePageLayout($id: String!) {
restorePageLayout(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type RestorePageLayoutTabOperationFactoryParams = {
gqlFields?: string;
pageLayoutTabId: string;
};
export const restorePageLayoutTabOperationFactory = ({
gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS,
pageLayoutTabId,
}: RestorePageLayoutTabOperationFactoryParams) => ({
query: gql`
mutation RestorePageLayoutTab($id: String!) {
restorePageLayoutTab(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutTabId,
},
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
type RestorePageLayoutWidgetOperationFactoryParams = {
gqlFields?: string;
pageLayoutWidgetId: string;
};
export const restorePageLayoutWidgetOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
pageLayoutWidgetId,
}: RestorePageLayoutWidgetOperationFactoryParams) => ({
query: gql`
mutation RestorePageLayoutWidget($id: String!) {
restorePageLayoutWidget(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutWidgetId,
},
});
@@ -1,28 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
type UpdatePageLayoutOperationFactoryParams = {
gqlFields?: string;
pageLayoutId: string;
data: UpdatePageLayoutInput;
};
export const updatePageLayoutOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
pageLayoutId,
data,
}: UpdatePageLayoutOperationFactoryParams) => ({
query: gql`
mutation UpdatePageLayout($id: String!, $input: UpdatePageLayoutInput!) {
updatePageLayout(id: $id, input: $input) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutId,
input: data,
},
});
@@ -1,28 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
type UpdatePageLayoutTabOperationFactoryParams = {
gqlFields?: string;
pageLayoutTabId: string;
data: UpdatePageLayoutTabInput;
};
export const updatePageLayoutTabOperationFactory = ({
gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS,
pageLayoutTabId,
data,
}: UpdatePageLayoutTabOperationFactoryParams) => ({
query: gql`
mutation UpdatePageLayoutTab($id: String!, $input: UpdatePageLayoutTabInput!) {
updatePageLayoutTab(id: $id, input: $input) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutTabId,
input: data,
},
});
@@ -1,28 +0,0 @@
import gql from 'graphql-tag';
import { PAGE_LAYOUT_WIDGET_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants';
import { type UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-widget.input';
type UpdatePageLayoutWidgetOperationFactoryParams = {
gqlFields?: string;
pageLayoutWidgetId: string;
data: UpdatePageLayoutWidgetInput;
};
export const updatePageLayoutWidgetOperationFactory = ({
gqlFields = PAGE_LAYOUT_WIDGET_GQL_FIELDS,
pageLayoutWidgetId,
data,
}: UpdatePageLayoutWidgetOperationFactoryParams) => ({
query: gql`
mutation UpdatePageLayoutWidget($id: String!, $input: UpdatePageLayoutWidgetInput!) {
updatePageLayoutWidget(id: $id, input: $input) {
${gqlFields}
}
}
`,
variables: {
id: pageLayoutWidgetId,
input: data,
},
});
@@ -1,53 +0,0 @@
import gql from 'graphql-tag';
import {
PAGE_LAYOUT_GQL_FIELDS,
PAGE_LAYOUT_WIDGET_CONFIGURATION_FIELDS,
} from 'test/integration/constants/page-layout-gql-fields.constants';
import { type UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
type UpdatePageLayoutWithTabsOperationFactoryParams = {
gqlFields?: string;
pageLayoutId: string;
data: UpdatePageLayoutWithTabsInput;
};
export const updatePageLayoutWithTabsOperationFactory = ({
gqlFields = PAGE_LAYOUT_GQL_FIELDS,
pageLayoutId,
data,
}: UpdatePageLayoutWithTabsOperationFactoryParams) => ({
query: gql`
mutation UpdatePageLayoutWithTabsAndWidgets($id: String!, $input: UpdatePageLayoutWithTabsInput!) {
updatePageLayoutWithTabsAndWidgets(id: $id, input: $input) {
${gqlFields}
tabs {
id
title
position
pageLayoutId
widgets {
id
title
type
pageLayoutTabId
objectMetadataId
gridPosition {
row
column
rowSpan
columnSpan
}
configuration {
${PAGE_LAYOUT_WIDGET_CONFIGURATION_FIELDS}
}
}
}
}
}
`,
variables: {
id: pageLayoutId,
input: data,
},
});
@@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab creation should fail when pageLayoutId references non-existent layout 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [],
"cronTrigger": [],
"databaseEventTrigger": [],
"fieldMetadata": [],
"index": [],
"objectMetadata": [],
"pageLayout": [],
"pageLayoutTab": [
{
"errors": [
{
"code": "PAGE_LAYOUT_NOT_FOUND",
"message": "Page layout not found",
"userFriendlyMessage": "Page layout not found",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"title": "Tab With Non-Existent Layout",
},
"status": "fail",
"type": "create_page_layout_tab",
},
],
"pageLayoutWidget": [],
"role": [],
"roleTarget": [],
"routeTrigger": [],
"serverlessFunction": [],
"view": [],
"viewField": [],
"viewFilter": [],
"viewGroup": [],
},
"message": "Validation failed for 0 object(s) and 0 field(s)",
"summary": {
"invalidAgent": 0,
"invalidCronTrigger": 0,
"invalidDatabaseEventTrigger": 0,
"invalidFieldMetadata": 0,
"invalidIndex": 0,
"invalidObjectMetadata": 0,
"invalidPageLayout": 0,
"invalidPageLayoutTab": 0,
"invalidPageLayoutWidget": 0,
"invalidRole": 0,
"invalidRoleTarget": 0,
"invalidRouteTrigger": 0,
"invalidServerlessFunction": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
"invalidViewGroup": 0,
"totalErrors": 0,
},
"userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)",
},
"message": "Multiple validation errors occurred while creating page layout tab",
"name": "GraphQLError",
}
`;
exports[`Page layout tab creation should fail when title is missing 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "title" of required type "String!" was not provided.",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab deletion should fail when deleting a non-existent page layout tab 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Page layout tab to delete not found",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab update should fail when updating a non-existent page layout tab 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Page layout tab to update not found",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab creation should succeed should create a page layout tab 1`] = `
{
"createdAt": Any<String>,
"deletedAt": null,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Test Tab",
"updatedAt": Any<String>,
"widgets": null,
}
`;
exports[`Page layout tab creation should succeed should create a page layout tab with position 1`] = `
{
"createdAt": Any<String>,
"deletedAt": null,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 5,
"title": "Positioned Tab",
"updatedAt": Any<String>,
"widgets": null,
}
`;
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab deletion should succeed should soft delete and restore a page layout tab 1`] = `
{
"createdAt": Any<String>,
"deletedAt": null,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Tab To Delete",
"updatedAt": Any<String>,
}
`;
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout tab update should succeed should update page layout tab position 1`] = `
{
"createdAt": Any<String>,
"deletedAt": null,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 10,
"title": "Original Tab Title",
"updatedAt": Any<String>,
}
`;
exports[`Page layout tab update should succeed should update page layout tab title 1`] = `
{
"createdAt": Any<String>,
"deletedAt": null,
"id": Any<String>,
"pageLayoutId": Any<String>,
"position": 0,
"title": "Updated Tab Title",
"updatedAt": Any<String>,
}
`;
@@ -0,0 +1,48 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.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 { type CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
describe('Page layout tab creation should fail', () => {
let testPageLayoutId: string;
beforeAll(async () => {
const { data } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For Tab Creation Failures' },
});
testPageLayoutId = data.createPageLayout.id;
});
afterAll(async () => {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
});
it('when title is missing', async () => {
const { errors } = await createOnePageLayoutTab({
expectToFail: true,
input: { pageLayoutId: testPageLayoutId } as CreatePageLayoutTabInput,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('when pageLayoutId references non-existent layout', async () => {
const { errors } = await createOnePageLayoutTab({
expectToFail: true,
input: {
title: 'Tab With Non-Existent Layout',
pageLayoutId: faker.string.uuid(),
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
@@ -0,0 +1,14 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { deleteOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/delete-one-page-layout-tab.util';
describe('Page layout tab deletion should fail', () => {
it('when deleting a non-existent page layout tab', async () => {
const { errors } = await deleteOnePageLayoutTab({
expectToFail: true,
input: { id: faker.string.uuid() },
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
@@ -0,0 +1,17 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { updateOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/update-one-page-layout-tab.util';
describe('Page layout tab update should fail', () => {
it('when updating a non-existent page layout tab', async () => {
const { errors } = await updateOnePageLayoutTab({
expectToFail: true,
input: {
id: faker.string.uuid(),
title: 'Updated Title',
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
@@ -0,0 +1,86 @@
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 { 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';
type TestContext = {
input: {
title: string;
position?: number;
};
};
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'create a page layout tab',
context: {
input: {
title: 'Test Tab',
},
},
},
{
title: 'create a page layout tab with position',
context: {
input: {
title: 'Positioned Tab',
position: 5,
},
},
},
];
describe('Page layout tab creation should succeed', () => {
let testPageLayoutId: string;
let createdPageLayoutTabId: string;
beforeAll(async () => {
const { data } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For Tabs' },
});
testPageLayoutId = data.createPageLayout.id;
});
afterAll(async () => {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
});
afterEach(async () => {
if (createdPageLayoutTabId) {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: createdPageLayoutTabId },
});
createdPageLayoutTabId = '';
}
});
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'should $title',
async ({ context: { input } }) => {
const { data } = await createOnePageLayoutTab({
expectToFail: false,
input: {
...input,
pageLayoutId: testPageLayoutId,
},
});
createdPageLayoutTabId = data?.createPageLayoutTab?.id;
expect(data.createPageLayoutTab).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...data.createPageLayoutTab }),
);
},
);
});
@@ -0,0 +1,100 @@
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
import { deleteOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/delete-one-page-layout-tab.util';
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
import { restoreOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/restore-one-page-layout-tab.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';
type TestContext = {
title: string;
operation: 'soft-delete-restore' | 'hard-delete';
};
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'soft delete and restore a page layout tab',
context: {
title: 'Tab To Delete',
operation: 'soft-delete-restore',
},
},
{
title: 'hard delete a page layout tab',
context: {
title: 'Tab To Destroy',
operation: 'hard-delete',
},
},
];
describe('Page layout tab deletion should succeed', () => {
let testPageLayoutId: string;
beforeAll(async () => {
const { data } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For Tab Deletions' },
});
testPageLayoutId = data.createPageLayout.id;
});
afterAll(async () => {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
});
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'should $title',
async ({ context: { title, operation } }) => {
const { data: createData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title,
pageLayoutId: testPageLayoutId,
},
});
const tabId = createData.createPageLayoutTab.id;
if (operation === 'soft-delete-restore') {
const { data: deleteData } = await deleteOnePageLayoutTab({
expectToFail: false,
input: { id: tabId },
});
expect(deleteData.deletePageLayoutTab).toBe(true);
const { data: restoreData } = await restoreOnePageLayoutTab({
expectToFail: false,
input: { id: tabId },
});
expect(restoreData.restorePageLayoutTab).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({
...restoreData.restorePageLayoutTab,
}),
);
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: tabId },
});
} else {
const { data: destroyData } = await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: tabId },
});
expect(destroyData.destroyPageLayoutTab).toBe(true);
}
},
);
});
@@ -0,0 +1,93 @@
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 { updateOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/update-one-page-layout-tab.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';
type TestContext = {
input: {
title?: string;
position?: number;
};
};
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'update page layout tab title',
context: {
input: {
title: 'Updated Tab Title',
},
},
},
{
title: 'update page layout tab position',
context: {
input: {
position: 10,
},
},
},
];
describe('Page layout tab update should succeed', () => {
let testPageLayoutId: string;
let testPageLayoutTabId: string;
beforeAll(async () => {
const { data } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For Tab Updates' },
});
testPageLayoutId = data.createPageLayout.id;
});
afterAll(async () => {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
});
beforeEach(async () => {
const { data } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Original Tab Title',
pageLayoutId: testPageLayoutId,
},
});
testPageLayoutTabId = data.createPageLayoutTab.id;
});
afterEach(async () => {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: testPageLayoutTabId },
});
});
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'should $title',
async ({ context: { input } }) => {
const { data } = await updateOnePageLayoutTab({
expectToFail: false,
input: {
id: testPageLayoutTabId,
...input,
},
});
expect(data.updatePageLayoutTab).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...data.updatePageLayoutTab }),
);
},
);
});
@@ -0,0 +1,52 @@
import gql from 'graphql-tag';
import { WIDGET_CONFIGURATION_GQL_FIELDS } from 'test/integration/metadata/suites/page-layout-widget/constants/widget-configuration-gql-fields.constant';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
export type CreateOnePageLayoutTabFactoryInput = CreatePageLayoutTabInput;
const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
widgets {
id
title
type
pageLayoutTabId
objectMetadataId
gridPosition {
row
column
rowSpan
columnSpan
}
configuration {
${WIDGET_CONFIGURATION_GQL_FIELDS}
}
createdAt
updatedAt
deletedAt
}
`;
export const createOnePageLayoutTabQueryFactory = ({
input,
gqlFields = DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOnePageLayoutTabFactoryInput>) => ({
query: gql`
mutation CreatePageLayoutTab($input: CreatePageLayoutTabInput!) {
createPageLayoutTab(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,43 @@
import {
type CreateOnePageLayoutTabFactoryInput,
createOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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 PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const createOnePageLayoutTab = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOnePageLayoutTabFactoryInput>): CommonResponseBody<{
createPageLayoutTab: PageLayoutTabDTO;
}> => {
const graphqlOperation = createOnePageLayoutTabQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Page layout tab creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Page layout tab creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,19 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type DeleteOnePageLayoutTabFactoryInput = {
id: string;
};
export const deleteOnePageLayoutTabQueryFactory = ({
input,
}: PerformMetadataQueryParams<DeleteOnePageLayoutTabFactoryInput>) => ({
query: gql`
mutation DeletePageLayoutTab($id: String!) {
deletePageLayoutTab(id: $id)
}
`,
variables: {
id: input.id,
},
});
@@ -0,0 +1,39 @@
import {
type DeleteOnePageLayoutTabFactoryInput,
deleteOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/delete-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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';
export const deleteOnePageLayoutTab = async ({
input,
expectToFail = false,
token,
}: PerformMetadataQueryParams<DeleteOnePageLayoutTabFactoryInput>): CommonResponseBody<{
deletePageLayoutTab: boolean;
}> => {
const graphqlOperation = deleteOnePageLayoutTabQueryFactory({
input,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Page layout tab deletion should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Page layout tab deletion has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,19 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type DestroyOnePageLayoutTabFactoryInput = {
id: string;
};
export const destroyOnePageLayoutTabQueryFactory = ({
input,
}: PerformMetadataQueryParams<DestroyOnePageLayoutTabFactoryInput>) => ({
query: gql`
mutation DestroyPageLayoutTab($id: String!) {
destroyPageLayoutTab(id: $id)
}
`,
variables: {
id: input.id,
},
});
@@ -0,0 +1,39 @@
import {
type DestroyOnePageLayoutTabFactoryInput,
destroyOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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';
export const destroyOnePageLayoutTab = async ({
input,
expectToFail = false,
token,
}: PerformMetadataQueryParams<DestroyOnePageLayoutTabFactoryInput>): CommonResponseBody<{
destroyPageLayoutTab: boolean;
}> => {
const graphqlOperation = destroyOnePageLayoutTabQueryFactory({
input,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Page layout tab destroy should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Page layout tab destroy has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,49 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type FindOnePageLayoutTabFactoryInput = {
id: string;
};
const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
widgets {
id
title
type
pageLayoutTabId
objectMetadataId
gridPosition {
row
column
rowSpan
columnSpan
}
configuration
createdAt
updatedAt
deletedAt
}
`;
export const findOnePageLayoutTabQueryFactory = ({
input,
gqlFields = DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS,
}: PerformMetadataQueryParams<FindOnePageLayoutTabFactoryInput>) => ({
query: gql`
query GetPageLayoutTab($id: String!) {
getPageLayoutTab(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: input.id,
},
});
@@ -0,0 +1,43 @@
import {
type FindOnePageLayoutTabFactoryInput,
findOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/find-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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 PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const findOnePageLayoutTab = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<FindOnePageLayoutTabFactoryInput>): CommonResponseBody<{
getPageLayoutTab: PageLayoutTabDTO;
}> => {
const graphqlOperation = findOnePageLayoutTabQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Find page layout tab should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Find page layout tab has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,32 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type FindPageLayoutTabsFactoryInput = {
pageLayoutId: string;
};
const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
`;
export const findPageLayoutTabsQueryFactory = ({
input,
gqlFields = DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS,
}: PerformMetadataQueryParams<FindPageLayoutTabsFactoryInput>) => ({
query: gql`
query GetPageLayoutTabs($pageLayoutId: String!) {
getPageLayoutTabs(pageLayoutId: $pageLayoutId) {
${gqlFields}
}
}
`,
variables: {
pageLayoutId: input.pageLayoutId,
},
});
@@ -0,0 +1,43 @@
import {
type FindPageLayoutTabsFactoryInput,
findPageLayoutTabsQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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 PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const findPageLayoutTabs = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<FindPageLayoutTabsFactoryInput>): CommonResponseBody<{
getPageLayoutTabs: PageLayoutTabDTO[];
}> => {
const graphqlOperation = findPageLayoutTabsQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Find page layout tabs should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Find page layout tabs has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,32 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type RestoreOnePageLayoutTabFactoryInput = {
id: string;
};
const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
`;
export const restoreOnePageLayoutTabQueryFactory = ({
input,
gqlFields = DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS,
}: PerformMetadataQueryParams<RestoreOnePageLayoutTabFactoryInput>) => ({
query: gql`
mutation RestorePageLayoutTab($id: String!) {
restorePageLayoutTab(id: $id) {
${gqlFields}
}
}
`,
variables: {
id: input.id,
},
});
@@ -0,0 +1,43 @@
import {
type RestoreOnePageLayoutTabFactoryInput,
restoreOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/restore-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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 PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const restoreOnePageLayoutTab = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<RestoreOnePageLayoutTabFactoryInput>): CommonResponseBody<{
restorePageLayoutTab: PageLayoutTabDTO;
}> => {
const graphqlOperation = restoreOnePageLayoutTabQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Page layout tab restore should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Page layout tab restore has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,38 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-tab.input';
export type UpdateOnePageLayoutTabFactoryInput = {
id: string;
} & UpdatePageLayoutTabInput;
const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
`;
export const updateOnePageLayoutTabQueryFactory = ({
input,
gqlFields = DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS,
}: PerformMetadataQueryParams<UpdateOnePageLayoutTabFactoryInput>) => ({
query: gql`
mutation UpdatePageLayoutTab($id: String!, $input: UpdatePageLayoutTabInput!) {
updatePageLayoutTab(id: $id, input: $input) {
${gqlFields}
}
}
`,
variables: {
id: input.id,
input: {
title: input.title,
position: input.position,
},
},
});
@@ -0,0 +1,43 @@
import {
type UpdateOnePageLayoutTabFactoryInput,
updateOnePageLayoutTabQueryFactory,
} from 'test/integration/metadata/suites/page-layout-tab/utils/update-one-page-layout-tab-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.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 PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout-tab.dto';
export const updateOnePageLayoutTab = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<UpdateOnePageLayoutTabFactoryInput>): CommonResponseBody<{
updatePageLayoutTab: PageLayoutTabDTO;
}> => {
const graphqlOperation = updateOnePageLayoutTabQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Page layout tab update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Page layout tab update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,93 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget creation should fail when gridPosition has invalid values 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "row must not be less than 0",
"name": "UserInputError",
}
`;
exports[`Page layout widget creation should fail when pageLayoutTabId references non-existent tab 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [],
"cronTrigger": [],
"databaseEventTrigger": [],
"fieldMetadata": [],
"index": [],
"objectMetadata": [],
"pageLayout": [],
"pageLayoutTab": [],
"pageLayoutWidget": [
{
"errors": [
{
"code": "PAGE_LAYOUT_TAB_NOT_FOUND",
"message": "Page layout tab not found",
"userFriendlyMessage": "Page layout tab not found",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
"pageLayoutTabId": Any<String>,
},
"status": "fail",
"type": "create_page_layout_widget",
},
],
"role": [],
"roleTarget": [],
"routeTrigger": [],
"serverlessFunction": [],
"view": [],
"viewField": [],
"viewFilter": [],
"viewGroup": [],
},
"message": "Validation failed for 0 object(s) and 0 field(s)",
"summary": {
"invalidAgent": 0,
"invalidCronTrigger": 0,
"invalidDatabaseEventTrigger": 0,
"invalidFieldMetadata": 0,
"invalidIndex": 0,
"invalidObjectMetadata": 0,
"invalidPageLayout": 0,
"invalidPageLayoutTab": 0,
"invalidPageLayoutWidget": 0,
"invalidRole": 0,
"invalidRoleTarget": 0,
"invalidRouteTrigger": 0,
"invalidServerlessFunction": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
"invalidViewGroup": 0,
"totalErrors": 0,
},
"userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)",
},
"message": "Multiple validation errors occurred while creating page layout widget",
"name": "GraphQLError",
}
`;
exports[`Page layout widget creation should fail when title is missing 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "title" of required type "String!" was not provided.",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget deletion should fail when deleting a non-existent page layout widget 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Page layout widget to delete not found",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget update should fail when updating a non-existent page layout widget 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Page layout widget with ID "ed3752e3-db7f-42ff-82c0-2757a5410044" not found",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget creation should succeed should create a page layout widget 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 0,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Test Widget",
"type": "VIEW",
"updatedAt": Any<String>,
}
`;
exports[`Page layout widget creation should succeed should create a page layout widget with specific type 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 2,
"row": 0,
"rowSpan": 2,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Graph Widget",
"type": "GRAPH",
"updatedAt": Any<String>,
}
`;
@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget deletion should succeed should soft delete and restore a page layout widget 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": Any<String>,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 0,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Widget To Delete",
"type": "VIEW",
"updatedAt": Any<String>,
}
`;
exports[`Page layout widget deletion should succeed should soft delete and restore a page layout widget 2`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 0,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Widget To Delete",
"type": "VIEW",
"updatedAt": Any<String>,
}
`;
@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget update should succeed should update page layout widget grid position 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 3,
"columnSpan": 4,
"row": 2,
"rowSpan": 2,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Original Widget Title",
"type": "VIEW",
"updatedAt": Any<String>,
}
`;
exports[`Page layout widget update should succeed should update page layout widget title 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 0,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Updated Widget Title",
"type": "VIEW",
"updatedAt": Any<String>,
}
`;
exports[`Page layout widget update should succeed should update page layout widget type 1`] = `
{
"configuration": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 0,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": null,
"pageLayoutTabId": Any<String>,
"title": "Original Widget Title",
"type": "GRAPH",
"updatedAt": Any<String>,
}
`;
@@ -1,45 +1,29 @@
export const PAGE_LAYOUT_GQL_FIELDS = `
id
name
type
objectMetadataId
createdAt
updatedAt
deletedAt
`;
export const PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
title
position
pageLayoutId
createdAt
updatedAt
deletedAt
`;
export const PAGE_LAYOUT_WIDGET_CONFIGURATION_FIELDS = `
... on IframeConfiguration {
url
}
export const WIDGET_CONFIGURATION_GQL_FIELDS = `
... on BarChartConfiguration {
graphType
aggregateFieldMetadataId
aggregateOperation
primaryAxisGroupByFieldMetadataId
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
omitNullValues
axisNameDisplay
displayDataLabel
displayLegend
rangeMin
rangeMax
filter
color
description
filter
groupMode
isCumulative
timezone
firstDayOfTheWeek
}
... on LineChartConfiguration {
graphType
@@ -47,64 +31,75 @@ export const PAGE_LAYOUT_WIDGET_CONFIGURATION_FIELDS = `
aggregateOperation
primaryAxisGroupByFieldMetadataId
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
omitNullValues
axisNameDisplay
displayDataLabel
displayLegend
rangeMin
rangeMax
filter
color
description
filter
isStacked
isCumulative
timezone
firstDayOfTheWeek
}
... on PieChartConfiguration {
graphType
groupByFieldMetadataId
aggregateFieldMetadataId
aggregateOperation
groupBySubFieldName
dateGranularity
orderBy
displayDataLabel
filter
showCenterMetric
displayLegend
color
description
filter
timezone
firstDayOfTheWeek
}
... on AggregateChartConfiguration {
graphType
aggregateFieldMetadataId
aggregateOperation
label
displayDataLabel
format
description
filter
format
label
prefix
suffix
timezone
firstDayOfTheWeek
}
... on GaugeChartConfiguration {
graphType
aggregateFieldMetadataId
aggregateOperation
displayDataLabel
color
description
filter
timezone
firstDayOfTheWeek
}
... on IframeConfiguration {
url
}
... on StandaloneRichTextConfiguration {
body {
blocknote
markdown
}
}
`;
export const PAGE_LAYOUT_WIDGET_GQL_FIELDS = `
id
title
type
pageLayoutTabId
objectMetadataId
gridPosition {
row
column
rowSpan
columnSpan
}
configuration {
${PAGE_LAYOUT_WIDGET_CONFIGURATION_FIELDS}
}
createdAt
updatedAt
deletedAt
`;
@@ -0,0 +1,97 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.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 { 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 { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
describe('Page layout widget creation should fail', () => {
let testPageLayoutId: string;
let testPageLayoutTabId: string;
beforeAll(async () => {
const { data: layoutData } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For Widget Creation Failures' },
});
testPageLayoutId = layoutData.createPageLayout.id;
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Test Tab For Widget Creation Failures',
pageLayoutId: testPageLayoutId,
},
});
testPageLayoutTabId = tabData.createPageLayoutTab.id;
});
afterAll(async () => {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: testPageLayoutTabId },
});
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
});
it('when title is missing', async () => {
const { errors } = await createOnePageLayoutWidget({
expectToFail: true,
input: {
pageLayoutTabId: testPageLayoutTabId,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
} as CreatePageLayoutWidgetInput,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('when pageLayoutTabId references non-existent tab', async () => {
const { errors } = await createOnePageLayoutWidget({
expectToFail: true,
input: {
title: 'Widget With Non-Existent Tab',
pageLayoutTabId: faker.string.uuid(),
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('when gridPosition has invalid values', async () => {
const { errors } = await createOnePageLayoutWidget({
expectToFail: true,
input: {
title: 'Widget With Invalid Grid Position',
pageLayoutTabId: testPageLayoutTabId,
gridPosition: {
row: -1,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
@@ -0,0 +1,14 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { deleteOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/delete-one-page-layout-widget.util';
describe('Page layout widget deletion should fail', () => {
it('when deleting a non-existent page layout widget', async () => {
const { errors } = await deleteOnePageLayoutWidget({
expectToFail: true,
input: { id: faker.string.uuid() },
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

Some files were not shown because too many files have changed in this diff Show More