Files
twenty/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts
T
Paul Rastoin 476bdf764c Refactor flat entity maps to be universal oriented (#17665)
# Introduction
In preparation of the workspace agnostic builder, we're migrating
`FlatEntityMaps` to be universal identifier oriented and based
As in the builder context there're won't be any ids at all
Please also note that the FlatEntity is a UniversalFlatEntity superset

From
```ts
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';

export type FlatEntityMaps<T extends SyncableFlatEntity> = {
  byId: Partial<Record<string, T>>;
  idByUniversalIdentifier: Partial<Record<string, string>>;
  universalIdentifiersByApplicationId: Partial<Record<string, string[]>>;
};
```

To
```ts
export type FlatEntityMaps<
  T extends SyncableFlatEntity | UniversalSyncableFlatEntity,
> = {
  byUniversalIdentifier: Partial<Record<string, T>>;
  universalIdentifierById: Partial<Record<string, string>>;
  universalIdentifiersByApplicationId: Partial<Record<string, string[]>>; // this might make more sense to be migrated to universalIdentifiersByApplicationUniversalIdentifier but it's the main topic of this PR
};
```

## Low level maps tools
Had to refactor find | create | delete | replace | find-many | get-sub
tools ( through mutations and or throw equivalent )
2026-02-03 16:16:02 +00:00

488 lines
16 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/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 { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
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 { FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-widget/constants/flat-page-layout-widget-editable-properties.constant';
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-tab/dtos/inputs/update-page-layout-tab-with-widgets.input';
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/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 { 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 { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
type UpdatePageLayoutWithTabsParams = {
id: string;
workspaceId: string;
input: UpdatePageLayoutWithTabsInput;
};
@Injectable()
export class PageLayoutUpdateService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly dashboardSyncService: DashboardSyncService,
) {}
async updatePageLayoutWithTabs({
id,
workspaceId,
input,
}: UpdatePageLayoutWithTabsParams): Promise<PageLayoutDTO> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
],
},
);
const existingPageLayout = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatPageLayoutMaps,
});
// TODO move in validator
if (
!isDefined(existingPageLayout) ||
isDefined(existingPageLayout.deletedAt)
) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
///
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { tabs, ...updateData } = input;
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,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
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',
],
},
);
const flatLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatPageLayoutMaps,
});
await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByPageLayoutId(
{
pageLayoutId: id,
workspaceId,
updatedAt: new Date(flatLayout.updatedAt),
},
);
return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout: flatLayout,
flatPageLayoutTabMaps: recomputedFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: recomputedFlatPageLayoutWidgetMaps,
}),
);
}
private computeTabOperations({
existingPageLayout,
tabs,
flatPageLayoutTabMaps,
workspaceId,
workspaceCustomApplicationId,
}: {
existingPageLayout: FlatPageLayout;
tabs: UpdatePageLayoutTabWithWidgetsInput[];
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
tabsToCreate: FlatPageLayoutTab[];
tabsToUpdate: FlatPageLayoutTab[];
tabsToDelete: FlatPageLayoutTab[];
} {
const existingTabs = Object.values(
flatPageLayoutTabMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((tab) => tab.pageLayoutId === existingPageLayout.id);
const {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
} = computeDiffBetweenObjects<
FlatPageLayoutTab,
UpdatePageLayoutTabWithWidgetsInput
>({
existingObjects: existingTabs,
receivedObjects: tabs,
propertiesToCompare: FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
});
const now = new Date();
const tabsToCreate: FlatPageLayoutTab[] = entitiesToCreate.map(
(tabInput) => {
const tabId = tabInput.id ?? v4();
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: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
};
},
);
const tabsToUpdate: FlatPageLayoutTab[] = entitiesToUpdate.map(
(tabInput) => {
const existingTab = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: tabInput.id,
flatEntityMaps: flatPageLayoutTabMaps,
});
return {
...existingTab,
title: tabInput.title,
position: tabInput.position,
updatedAt: now.toISOString(),
};
},
);
const tabsToRestoreAndUpdate: FlatPageLayoutTab[] =
entitiesToRestoreAndUpdate.map((tabInput) => {
const existingTab = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: tabInput.id,
flatEntityMaps: flatPageLayoutTabMaps,
});
return {
...existingTab,
title: tabInput.title,
position: tabInput.position,
deletedAt: null,
updatedAt: now.toISOString(),
};
});
const tabsToDelete: FlatPageLayoutTab[] = idsToDelete
.map((tabId) => {
const existingTab = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: tabId,
flatEntityMaps: flatPageLayoutTabMaps,
});
if (!isDefined(existingTab)) {
return null;
}
return {
...existingTab,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
.filter(isDefined);
return {
tabsToCreate,
tabsToUpdate: [
...tabsToUpdate,
...tabsToRestoreAndUpdate,
...tabsToDelete,
],
tabsToDelete: [],
};
}
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,
workspaceCustomApplicationId,
}: {
tabId: string;
widgets: UpdatePageLayoutWidgetWithIdInput[];
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
workspaceId: string;
workspaceCustomApplicationId: string;
}): {
widgetsToCreate: FlatPageLayoutWidget[];
widgetsToUpdate: FlatPageLayoutWidget[];
} {
const existingWidgets = Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((widget) => widget.pageLayoutTabId === tabId);
const {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
} = computeDiffBetweenObjects<
FlatPageLayoutWidget,
UpdatePageLayoutWidgetWithIdInput
>({
existingObjects: existingWidgets,
receivedObjects: widgets,
propertiesToCompare: FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
});
const now = new Date();
const widgetsToCreate: FlatPageLayoutWidget[] = entitiesToCreate.map(
(widgetInput) => {
const widgetId = widgetInput.id ?? v4();
return {
id: widgetId,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
position: widgetInput.position ?? null,
configuration: widgetInput.configuration ?? null,
workspaceId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
deletedAt: null,
universalIdentifier: widgetId,
applicationId: workspaceCustomApplicationId,
conditionalDisplay: null,
};
},
);
const widgetsToUpdate: FlatPageLayoutWidget[] = entitiesToUpdate.map(
(widgetInput) => {
const existingWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: widgetInput.id,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
return {
...existingWidget,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
position: widgetInput.position ?? null,
configuration: widgetInput.configuration ?? null,
updatedAt: now.toISOString(),
};
},
);
const widgetsToRestoreAndUpdate: FlatPageLayoutWidget[] =
entitiesToRestoreAndUpdate.map((widgetInput) => {
const existingWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: widgetInput.id,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
return {
...existingWidget,
pageLayoutTabId: widgetInput.pageLayoutTabId,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
gridPosition: widgetInput.gridPosition,
position: widgetInput.position ?? null,
configuration: widgetInput.configuration ?? null,
deletedAt: null,
updatedAt: now.toISOString(),
};
});
const widgetsToDelete: FlatPageLayoutWidget[] = idsToDelete
.map((widgetId) => {
const existingWidget = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: widgetId,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
if (!isDefined(existingWidget)) {
return null;
}
return {
...existingWidget,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
.filter(isDefined);
return {
widgetsToCreate,
widgetsToUpdate: [
...widgetsToUpdate,
...widgetsToRestoreAndUpdate,
...widgetsToDelete,
],
};
}
}