Add is active to overridable entities and deactivation logic for page layouts (#19200)

- Replace soft-deletion (deletedAt) with isActive boolean for
overridable entities (tabs, widgets, viewFieldGroups, viewFields).
Standard entities are deactivated (isActive: false) when removed from
update payloads, while custom entities are hard-deleted.
- When a viewFieldGroup is deactivated/deleted, its viewFields are
reassigned to the next section by position (or null if none remain).
- Add isActive: true filters to viewFieldGroup and viewField API queries
so deactivated entities are excluded from responses.

Next: 
- fields-widget-upsert.service.ts should be refactored a bit
- Add restore logic
This commit is contained in:
Weiko
2026-04-02 16:50:50 +02:00
committed by GitHub
parent 67d34be7c8
commit c1e4756f9c
35 changed files with 392 additions and 286 deletions
@@ -782,6 +782,7 @@ export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveO
},
objectMetadataId: object.id,
objectMetadataUniversalIdentifier: object.universalIdentifier,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsActiveToOverridableEntities1774966727625
implements MigrationInterface
{
name = 'AddIsActiveToOverridableEntities1774966727625';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFieldGroup" ADD "isActive" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewField" ADD "isActive" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" ADD "isActive" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ADD "isActive" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`UPDATE "core"."viewFieldGroup" SET "isActive" = false WHERE "deletedAt" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."viewField" SET "isActive" = false WHERE "deletedAt" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."pageLayoutTab" SET "isActive" = false WHERE "deletedAt" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."pageLayoutWidget" SET "isActive" = false WHERE "deletedAt" IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" DROP COLUMN "isActive"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "isActive"`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewField" DROP COLUMN "isActive"`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFieldGroup" DROP COLUMN "isActive"`,
);
}
}
@@ -23,6 +23,7 @@ export const fromPageLayoutTabManifestToUniversalFlatPageLayoutTab = ({
icon: pageLayoutTabManifest.icon ?? null,
layoutMode:
pageLayoutTabManifest.layoutMode ?? PageLayoutTabLayoutMode.GRID,
isActive: true,
widgetUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
@@ -19,6 +19,7 @@ export const fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget = ({
applicationUniversalIdentifier,
pageLayoutTabUniversalIdentifier,
title: pageLayoutWidgetManifest.title,
isActive: true,
type: pageLayoutWidgetManifest.type as WidgetType,
objectMetadataUniversalIdentifier:
pageLayoutWidgetManifest.objectUniversalIdentifier ?? null,
@@ -20,6 +20,7 @@ export const fromViewFieldGroupManifestToUniversalFlatViewFieldGroup = ({
name: viewFieldGroupManifest.name ?? '',
position: viewFieldGroupManifest.position,
isVisible: viewFieldGroupManifest.isVisible ?? true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt: now,
@@ -22,6 +22,7 @@ export const fromViewFieldManifestToUniversalFlatViewField = ({
viewFieldGroupUniversalIdentifier:
viewFieldManifest.viewFieldGroupUniversalIdentifier ?? null,
isVisible: viewFieldManifest.isVisible ?? true,
isActive: true,
size: viewFieldManifest.size ?? 0,
position: viewFieldManifest.position,
aggregateOperation: viewFieldManifest.aggregateOperation ?? null,
@@ -480,7 +480,11 @@ export class DataloaderService {
flatEntityIds: flatView.viewFieldGroupIds,
flatEntityMaps: flatViewFieldGroupMaps,
})
.filter((flatViewFieldGroup) => flatViewFieldGroup.deletedAt === null)
.filter(
(flatViewFieldGroup) =>
flatViewFieldGroup.deletedAt === null &&
flatViewFieldGroup.isActive,
)
.map(fromFlatViewFieldGroupToViewFieldGroupDto);
});
});
@@ -509,7 +513,11 @@ export class DataloaderService {
for (const flatViewField of Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatViewField) || flatViewField.deletedAt !== null) {
if (
!isDefined(flatViewField) ||
flatViewField.deletedAt !== null ||
!flatViewField.isActive
) {
continue;
}
@@ -575,7 +583,10 @@ export class DataloaderService {
flatEntityIds: flatView.viewFieldIds,
flatEntityMaps: flatViewFieldMaps,
})
.filter((flatViewField) => flatViewField.deletedAt === null)
.filter(
(flatViewField) =>
flatViewField.deletedAt === null && flatViewField.isActive,
)
.map(fromFlatViewFieldToViewFieldDto);
});
});
@@ -174,6 +174,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"position",
"deletedAt",
"icon",
"isActive",
"overrides",
],
"propertiesToStringify": [
@@ -190,6 +191,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"universalConfiguration",
"deletedAt",
"conditionalDisplay",
"isActive",
"overrides",
],
"propertiesToStringify": [
@@ -299,6 +301,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"aggregateOperation",
"viewFieldGroupUniversalIdentifier",
"deletedAt",
"isActive",
"universalOverrides",
],
"propertiesToStringify": [
@@ -310,6 +313,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"name",
"position",
"isVisible",
"isActive",
"deletedAt",
"overrides",
],
@@ -373,6 +373,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
isOverridable: true,
},
isActive: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: false,
},
deletedAt: {
toStringify: false,
universalProperty: undefined,
@@ -455,6 +461,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: 'viewUniversalIdentifier',
},
isActive: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: false,
},
overrides: {
toCompare: true,
toStringify: true,
@@ -916,6 +928,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
isOverridable: true,
},
isActive: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: false,
},
overrides: {
toCompare: true,
toStringify: true,
@@ -966,6 +984,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
isActive: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: false,
},
overrides: {
toCompare: true,
toStringify: true,
@@ -0,0 +1,38 @@
type EntityWithApplicationIdentifier = {
applicationUniversalIdentifier: string;
};
export const splitEntitiesByRemovalStrategy = <
T extends EntityWithApplicationIdentifier,
>({
entitiesToRemove,
workspaceCustomApplicationUniversalIdentifier,
now,
}: {
entitiesToRemove: T[];
workspaceCustomApplicationUniversalIdentifier: string;
now: string;
}): {
toHardDelete: T[];
toDeactivate: (T & { isActive: false; updatedAt: string })[];
} => {
const toHardDelete: T[] = [];
const toDeactivate: (T & { isActive: false; updatedAt: string })[] = [];
for (const entity of entitiesToRemove) {
if (
entity.applicationUniversalIdentifier ===
workspaceCustomApplicationUniversalIdentifier
) {
toHardDelete.push(entity);
} else {
toDeactivate.push({
...entity,
isActive: false as const,
updatedAt: now,
});
}
}
return { toHardDelete, toDeactivate };
};
@@ -70,6 +70,7 @@ export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({
updatedLabelIdentifierFieldMetadata.universalIdentifier,
position: lowestViewFieldPosition - 1,
isVisible: true,
isActive: true,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: flatView.id,
viewUniversalIdentifier: flatView.universalIdentifier,
@@ -44,6 +44,7 @@ export const fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate = ({
id: pageLayoutTabId,
title: createPageLayoutTabInput.title,
position: createPageLayoutTabInput.position ?? 0,
isActive: true,
pageLayoutId: createPageLayoutTabInput.pageLayoutId,
pageLayoutUniversalIdentifier,
workspaceId,
@@ -41,6 +41,7 @@ export const transformPageLayoutTabEntityToFlatPageLayoutTab = ({
id: pageLayoutTabEntity.id,
title: pageLayoutTabEntity.title,
position: pageLayoutTabEntity.position,
isActive: pageLayoutTabEntity.isActive,
pageLayoutId: pageLayoutTabEntity.pageLayoutId,
workspaceId: pageLayoutTabEntity.workspaceId,
universalIdentifier: pageLayoutTabEntity.universalIdentifier,
@@ -64,6 +64,7 @@ export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({
return {
id: pageLayoutWidgetId,
...commonProperties,
isActive: true,
workspaceId,
createdAt,
updatedAt: createdAt,
@@ -25,9 +25,7 @@ export const reconstructFlatPageLayoutWithTabsAndWidgets = ({
}): FlatPageLayoutWithTabsAndWidgets => {
const tabs = Object.values(flatPageLayoutTabMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(tab) => tab.pageLayoutId === layout.id && !isDefined(tab.deletedAt),
)
.filter((tab) => tab.pageLayoutId === layout.id && tab.isActive)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
const tabsWithWidgets: FlatPageLayoutTabWithWidgets[] = tabs.map((tab) => {
@@ -35,10 +33,7 @@ export const reconstructFlatPageLayoutWithTabsAndWidgets = ({
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(widget) =>
widget.pageLayoutTabId === tab.id && !isDefined(widget.deletedAt),
);
.filter((widget) => widget.pageLayoutTabId === tab.id && widget.isActive);
return {
...tab,
@@ -46,6 +46,7 @@ export const fromCreateViewFieldGroupInputToFlatViewFieldGroupToCreate = ({
universalIdentifier: createViewFieldGroupInput.universalIdentifier ?? v4(),
position: createViewFieldGroupInput.position ?? 0,
isVisible: createViewFieldGroupInput.isVisible ?? true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
applicationUniversalIdentifier: flatApplication.universalIdentifier,
@@ -53,6 +53,7 @@ const buildFlatViewFieldGroupMaps = (
id: entry.id,
viewId: entry.viewId ?? VIEW_ID,
position: entry.position ?? 0,
isActive: true,
deletedAt: null,
},
]),
@@ -69,7 +70,7 @@ const buildFlatViewFieldMaps = (
viewId: string;
viewFieldGroupId: string | null;
position: number;
deletedAt: string | null;
isActive: boolean;
}[] = [],
): FlatViewFieldMaps =>
({
@@ -85,18 +86,18 @@ const buildFieldsWidget = ({
objectMetadataUniversalIdentifier = OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
viewId = VIEW_ID,
isVisible = true,
deletedAt = null as string | null,
isActive = true,
}: {
widgetUniversalIdentifier?: string;
objectMetadataUniversalIdentifier?: string;
viewId?: string | null;
isVisible?: boolean;
deletedAt?: string | null;
isActive?: boolean;
} = {}) => ({
universalIdentifier: widgetUniversalIdentifier,
objectMetadataUniversalIdentifier,
type: WidgetType.FIELDS,
deletedAt,
isActive,
configuration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId,
@@ -178,7 +179,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
expect(result).toEqual([]);
});
it('should skip deleted widgets', () => {
it('should skip inactive widgets', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -188,7 +189,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ deletedAt: '2024-01-01T00:00:00.000Z' }),
buildFieldsWidget({ isActive: false }),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
@@ -349,14 +350,14 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 3,
deletedAt: null,
isActive: true,
},
{
universalIdentifier: 'existing-vf-2',
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 7,
deletedAt: null,
isActive: true,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -397,7 +398,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 2,
deletedAt: null,
isActive: true,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -413,7 +414,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
expect(result[2].position).toBe(5);
});
it('should ignore deleted view fields when computing position', () => {
it('should ignore inactive view fields when computing position', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -431,14 +432,14 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 5,
deletedAt: null,
isActive: true,
},
{
universalIdentifier: 'deleted-vf',
universalIdentifier: 'inactive-vf',
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 99,
deletedAt: '2024-01-01T00:00:00.000Z',
isActive: false,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -469,7 +470,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: 'other-view-db-id',
viewFieldGroupId: null,
position: 50,
deletedAt: null,
isActive: true,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -556,21 +557,21 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: VIEW_ID,
viewFieldGroupId: VIEW_FIELD_GROUP_ID,
position: 2,
deletedAt: null,
isActive: true,
},
{
universalIdentifier: 'vf-no-group',
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 99,
deletedAt: null,
isActive: true,
},
{
universalIdentifier: 'vf-other-group',
viewId: VIEW_ID,
viewFieldGroupId: 'other-group-id',
position: 50,
deletedAt: null,
isActive: true,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -606,14 +607,14 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
viewId: VIEW_ID,
viewFieldGroupId: null,
position: 1,
deletedAt: null,
isActive: true,
},
{
universalIdentifier: 'vf-in-group',
viewId: VIEW_ID,
viewFieldGroupId: VIEW_FIELD_GROUP_ID,
position: 99,
deletedAt: null,
isActive: true,
},
]),
flatViewMaps: buildFlatViewMaps([
@@ -39,7 +39,7 @@ const getMatchingFieldsWidgets = ({
.filter(isDefined)
.filter(
(widget) =>
!isDefined(widget.deletedAt) &&
widget.isActive &&
widget.type === WidgetType.FIELDS &&
widget.objectMetadataUniversalIdentifier ===
objectMetadataUniversalIdentifier &&
@@ -59,7 +59,7 @@ const findLastViewFieldGroupId = ({
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((group) => !isDefined(group.deletedAt) && group.viewId === viewId);
.filter((group) => group.isActive && group.viewId === viewId);
if (groupsForView.length === 0) {
return null;
@@ -87,7 +87,7 @@ const computeNextPosition = ({
.filter(isDefined)
.filter(
(viewField: FlatViewField) =>
!isDefined(viewField.deletedAt) && viewField.viewId === viewId,
viewField.isActive && viewField.viewId === viewId,
)
.filter(
(viewField: FlatViewField) =>
@@ -199,6 +199,7 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
size: DEFAULT_VIEW_FIELD_SIZE,
position,
aggregateOperation: null,
isActive: true,
universalOverrides: null,
createdAt: now,
updatedAt: now,
@@ -66,6 +66,7 @@ export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
size: createViewFieldInput.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: createViewFieldInput.position ?? 0,
aggregateOperation: createViewFieldInput.aggregateOperation ?? null,
isActive: true,
universalOverrides: null,
viewFieldGroupUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
@@ -65,6 +65,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
pageLayoutUniversalIdentifier,
widgetIds: [widgetId],
widgetUniversalIdentifiers: [widgetUniversalIdentifier],
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -117,6 +118,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
universalConfiguration,
objectMetadataId: objectMetadata.id,
objectMetadataUniversalIdentifier: objectMetadata.universalIdentifier,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -64,6 +64,7 @@ export const computeFlatViewFieldsToCreate = ({
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
isActive: true,
universalOverrides: null,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
}));
@@ -10,6 +10,7 @@ import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
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 { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.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';
@@ -167,9 +168,11 @@ export class PageLayoutUpdateService {
flatViewMaps,
});
const orphanedViewIds = this.collectOrphanedViewIdsFromDeletedWidgets({
const orphanedViewIds = this.collectOrphanedViewIdsFromRemovedWidgets({
widgetsToUpdate,
widgetsToDelete,
tabsToUpdate,
tabsToDelete,
flatPageLayoutWidgetMaps,
});
@@ -278,7 +281,7 @@ export class PageLayoutUpdateService {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
idsToRemove,
} = computeDiffBetweenObjects<
FlatPageLayoutTab,
UpdatePageLayoutTabWithWidgetsInput
@@ -286,6 +289,7 @@ export class PageLayoutUpdateService {
existingObjects: existingTabs,
receivedObjects: tabs,
propertiesToCompare: FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
isEntityIncluded: (entity) => entity.isActive,
});
const now = new Date();
@@ -313,6 +317,7 @@ export class PageLayoutUpdateService {
icon: null,
layoutMode: tabInput.layoutMode ?? PageLayoutTabLayoutMode.GRID,
overrides: null,
isActive: true,
};
},
);
@@ -346,38 +351,34 @@ export class PageLayoutUpdateService {
title: tabInput.title,
position: tabInput.position,
layoutMode: tabInput.layoutMode ?? existingTab.layoutMode,
deletedAt: null,
isActive: true,
updatedAt: now.toISOString(),
};
});
const tabsToDelete: FlatPageLayoutTab[] = idsToDelete
.map((tabId) => {
const existingTab = findFlatEntityByIdInFlatEntityMaps({
const tabsToRemove = idsToRemove
.map((tabId) =>
findFlatEntityByIdInFlatEntityMaps({
flatEntityId: tabId,
flatEntityMaps: flatPageLayoutTabMaps,
});
if (!isDefined(existingTab)) {
return null;
}
return {
...existingTab,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
}),
)
.filter(isDefined);
const { toHardDelete, toDeactivate } = splitEntitiesByRemovalStrategy({
entitiesToRemove: tabsToRemove,
workspaceCustomApplicationUniversalIdentifier,
now: now.toISOString(),
});
return {
tabsToCreate,
tabsToUpdate: [
...tabsToUpdate,
...tabsToRestoreAndUpdate,
...tabsToDelete,
...toDeactivate,
],
tabsToDelete: [],
tabsToDelete: toHardDelete,
};
}
@@ -414,9 +415,10 @@ export class PageLayoutUpdateService {
} {
const allWidgetsToCreate: FlatPageLayoutWidget[] = [];
const allWidgetsToUpdate: FlatPageLayoutWidget[] = [];
const allWidgetsToDelete: FlatPageLayoutWidget[] = [];
for (const tabInput of tabs) {
const { widgetsToCreate, widgetsToUpdate } =
const { widgetsToCreate, widgetsToUpdate, widgetsToDelete } =
this.computeWidgetOperationsForTab({
tabId: tabInput.id,
widgets: tabInput.widgets,
@@ -434,12 +436,13 @@ export class PageLayoutUpdateService {
allWidgetsToCreate.push(...widgetsToCreate);
allWidgetsToUpdate.push(...widgetsToUpdate);
allWidgetsToDelete.push(...widgetsToDelete);
}
return {
widgetsToCreate: allWidgetsToCreate,
widgetsToUpdate: allWidgetsToUpdate,
widgetsToDelete: [],
widgetsToDelete: allWidgetsToDelete,
};
}
@@ -474,6 +477,7 @@ export class PageLayoutUpdateService {
>): {
widgetsToCreate: FlatPageLayoutWidget[];
widgetsToUpdate: FlatPageLayoutWidget[];
widgetsToDelete: FlatPageLayoutWidget[];
} {
for (const widgetInput of widgets) {
this.validateChartFieldReferences({
@@ -493,7 +497,7 @@ export class PageLayoutUpdateService {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
idsToDelete,
idsToRemove,
} = computeDiffBetweenObjects<
FlatPageLayoutWidget,
UpdatePageLayoutWidgetWithIdInput
@@ -501,6 +505,7 @@ export class PageLayoutUpdateService {
existingObjects: existingWidgets,
receivedObjects: widgets,
propertiesToCompare: FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
isEntityIncluded: (entity) => entity.isActive,
});
const now = new Date();
@@ -527,6 +532,7 @@ export class PageLayoutUpdateService {
workspaceCustomApplicationUniversalIdentifier,
conditionalDisplay: null,
overrides: null,
isActive: true,
universalConfiguration:
fromPageLayoutWidgetConfigurationToUniversalConfiguration({
configuration: widgetInput.configuration,
@@ -595,7 +601,7 @@ export class PageLayoutUpdateService {
flatObjectMetadataMaps,
}),
configuration: restoredConfiguration,
deletedAt: null,
isActive: true,
updatedAt: now.toISOString(),
...(isDefined(restoredConfiguration) && {
universalConfiguration:
@@ -614,32 +620,29 @@ export class PageLayoutUpdateService {
};
});
const widgetsToDelete: FlatPageLayoutWidget[] = idsToDelete
.map((widgetId) => {
const existingWidget = findFlatEntityByIdInFlatEntityMaps({
const widgetsToRemove = idsToRemove
.map((widgetId) =>
findFlatEntityByIdInFlatEntityMaps({
flatEntityId: widgetId,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
if (!isDefined(existingWidget)) {
return null;
}
return {
...existingWidget,
deletedAt: now.toISOString(),
updatedAt: now.toISOString(),
};
})
}),
)
.filter(isDefined);
const { toHardDelete, toDeactivate } = splitEntitiesByRemovalStrategy({
entitiesToRemove: widgetsToRemove,
workspaceCustomApplicationUniversalIdentifier,
now: now.toISOString(),
});
return {
widgetsToCreate,
widgetsToUpdate: [
...widgetsToUpdate,
...widgetsToRestoreAndUpdate,
...widgetsToDelete,
...toDeactivate,
],
widgetsToDelete: toHardDelete,
};
}
@@ -665,24 +668,37 @@ export class PageLayoutUpdateService {
});
}
private collectOrphanedViewIdsFromDeletedWidgets({
private collectOrphanedViewIdsFromRemovedWidgets({
widgetsToUpdate,
widgetsToDelete,
tabsToUpdate,
tabsToDelete,
flatPageLayoutWidgetMaps,
}: {
widgetsToUpdate: FlatPageLayoutWidget[];
widgetsToDelete: FlatPageLayoutWidget[];
tabsToUpdate: FlatPageLayoutTab[];
tabsToDelete: FlatPageLayoutTab[];
flatPageLayoutWidgetMaps: Pick<
AllFlatEntityMaps,
'flatPageLayoutWidgetMaps'
>['flatPageLayoutWidgetMaps'];
}): string[] {
const viewIdsToDelete = new Set<string>();
const directlyDeletedWidgetIds = new Set<string>();
const directlyRemovedWidgetIds = new Set<string>();
for (const widget of widgetsToDelete) {
directlyRemovedWidgetIds.add(widget.id);
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.add(viewId);
}
}
for (const widget of widgetsToUpdate) {
if (isDefined(widget.deletedAt)) {
directlyDeletedWidgetIds.add(widget.id);
if (!widget.isActive) {
directlyRemovedWidgetIds.add(widget.id);
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
@@ -691,21 +707,17 @@ export class PageLayoutUpdateService {
}
}
const deletedTabIds = new Set(
tabsToUpdate
.filter((tab) => isDefined(tab.deletedAt))
.map((tab) => tab.id),
);
const removedTabIds = new Set([
...tabsToUpdate.filter((tab) => !tab.isActive).map((tab) => tab.id),
...tabsToDelete.map((tab) => tab.id),
]);
const allExistingWidgets = Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
).filter(isDefined);
for (const widget of allExistingWidgets) {
if (
!isDefined(widget.deletedAt) &&
deletedTabIds.has(widget.pageLayoutTabId)
) {
if (widget.isActive && removedTabIds.has(widget.pageLayoutTabId)) {
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
@@ -716,9 +728,9 @@ export class PageLayoutUpdateService {
for (const widget of allExistingWidgets) {
if (
!isDefined(widget.deletedAt) &&
!directlyDeletedWidgetIds.has(widget.id) &&
!deletedTabIds.has(widget.pageLayoutTabId)
widget.isActive &&
!directlyRemovedWidgetIds.has(widget.id) &&
!removedTabIds.has(widget.pageLayoutTabId)
) {
const viewId = this.getViewIdFromFieldsWidget(widget);
@@ -14,6 +14,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-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 { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -141,17 +142,13 @@ export class FieldsWidgetUpsertService {
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(group) => !isDefined(group.deletedAt) && group.viewId === viewId,
);
.filter((group) => group.isActive && group.viewId === viewId);
const existingViewFields = Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(field) => !isDefined(field.deletedAt) && field.viewId === viewId,
);
.filter((field) => field.isActive && field.viewId === viewId);
if (hasGroups) {
await this.upsertFieldsWidgetWithGroups({
@@ -228,7 +225,7 @@ export class FieldsWidgetUpsertService {
const groupsToCreate: FlatViewFieldGroup[] = [];
const groupsToUpdate: FlatViewFieldGroup[] = [];
const groupsToDelete: FlatViewFieldGroup[] = [];
const groupsToDeactivate: FlatViewFieldGroup[] = [];
for (const inputGroup of inputGroups) {
const existingGroup = existingGroups.find((g) => g.id === inputGroup.id);
@@ -277,7 +274,7 @@ export class FieldsWidgetUpsertService {
for (const existingGroup of existingGroups) {
if (!inputGroupIds.has(existingGroup.id)) {
groupsToDelete.push(existingGroup);
groupsToDeactivate.push(existingGroup);
}
}
@@ -465,6 +462,7 @@ export class FieldsWidgetUpsertService {
aggregateOperation: null,
overrides: null,
universalOverrides: null,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -472,15 +470,15 @@ export class FieldsWidgetUpsertService {
}
}
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now,
});
const {
toHardDelete: customGroupsToDelete,
toDeactivate: deactivatedGroupUpdates,
} = splitEntitiesByRemovalStrategy({
entitiesToRemove: groupsToDeactivate,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -488,16 +486,16 @@ export class FieldsWidgetUpsertService {
allFlatEntityOperationByMetadataName: {
viewFieldGroup: {
flatEntityToCreate: groupsToCreate,
flatEntityToDelete: groupsToDelete,
flatEntityToUpdate: groupsToUpdate,
flatEntityToDelete: customGroupsToDelete,
flatEntityToUpdate: [
...groupsToUpdate,
...deactivatedGroupUpdates,
],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
flatEntityToUpdate: viewFieldsToUpdate,
},
},
workspaceId,
@@ -539,7 +537,7 @@ export class FieldsWidgetUpsertService {
}): Promise<void> {
const now = new Date().toISOString();
const groupsToDelete: FlatViewFieldGroup[] = [...existingGroups];
const groupsToDeactivate: FlatViewFieldGroup[] = [...existingGroups];
const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => {
const inputField = inputFields.find(
@@ -670,21 +668,22 @@ export class FieldsWidgetUpsertService {
aggregateOperation: null,
overrides: null,
universalOverrides: null,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now: new Date().toISOString(),
});
const {
toHardDelete: customGroupsToDelete,
toDeactivate: deactivatedGroupUpdates,
} = splitEntitiesByRemovalStrategy({
entitiesToRemove: groupsToDeactivate,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -692,16 +691,13 @@ export class FieldsWidgetUpsertService {
allFlatEntityOperationByMetadataName: {
viewFieldGroup: {
flatEntityToCreate: [],
flatEntityToDelete: groupsToDelete,
flatEntityToUpdate: [],
flatEntityToDelete: customGroupsToDelete,
flatEntityToUpdate: deactivatedGroupUpdates,
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
flatEntityToUpdate: viewFieldsToUpdate,
},
},
workspaceId,
@@ -718,111 +714,6 @@ export class FieldsWidgetUpsertService {
}
}
private buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds,
now,
}: {
existingViewFields: FlatViewField[];
groupsToDelete: FlatViewFieldGroup[];
alreadyUpdatedFieldIds: Set<string>;
now: string;
}): FlatViewField[] {
if (groupsToDelete.length === 0) {
return [];
}
const deletedGroupIds = new Set(groupsToDelete.map((group) => group.id));
return existingViewFields
.filter((field) => {
if (alreadyUpdatedFieldIds.has(field.id)) {
return false;
}
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
const hasStaleBase =
overriddenGroupId === undefined &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
const hasStaleBaseHiddenByNullOverride =
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return (
hasStaleOverride || hasStaleBase || hasStaleBaseHiddenByNullOverride
);
})
.map((field) => {
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
if (hasStaleOverride) {
const { viewFieldGroupId: _, ...remainingOverrides } =
field.overrides!;
const cleanedOverrides =
Object.keys(remainingOverrides).length > 0
? (remainingOverrides as typeof field.overrides)
: null;
const baseGroupIsAlsoStale =
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return {
...field,
...(baseGroupIsAlsoStale
? {
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
}
: {}),
overrides: cleanedOverrides,
universalOverrides: isDefined(cleanedOverrides)
? fromViewFieldOverridesToUniversalOverrides({
overrides: cleanedOverrides,
viewFieldGroupUniversalIdentifierById: {},
})
: null,
updatedAt: now,
};
}
if (
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId)
) {
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
}
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
});
}
private buildGroupToCreate({
inputGroup,
viewId,
@@ -858,6 +749,7 @@ export class FieldsWidgetUpsertService {
isVisible: inputGroup.isVisible,
viewId,
viewUniversalIdentifier,
isActive: true,
overrides: null,
createdAt: now,
updatedAt: now,
@@ -347,6 +347,7 @@ export class ViewFieldGroupService {
where: {
workspaceId,
viewId,
isActive: true,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
@@ -349,6 +349,7 @@ export class ViewFieldService {
return this.viewFieldRepository.find({
where: {
workspaceId,
isActive: true,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
@@ -364,6 +365,7 @@ export class ViewFieldService {
where: {
workspaceId,
viewId,
isActive: true,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
@@ -62,6 +62,7 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
name: 'General',
position: 0,
isVisible: true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
@@ -83,6 +84,7 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
deletedAt: null,
universalIdentifier: v4(),
isVisible,
isActive: true,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
@@ -101,6 +103,7 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
name: 'Other',
position: 1,
isVisible: true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
@@ -122,6 +125,7 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
deletedAt: null,
universalIdentifier: v4(),
isVisible,
isActive: true,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
@@ -68,6 +68,7 @@ export const createStandardPageLayoutTabFlatMetadata = ({
pageLayoutUniversalIdentifier: layout.universalIdentifier,
widgetIds,
widgetUniversalIdentifiers,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -104,6 +104,7 @@ export const createStandardPageLayoutWidgetFlatMetadata = ({
universalConfiguration,
objectMetadataId,
objectMetadataUniversalIdentifier,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -60,6 +60,7 @@ export const createStandardViewFieldGroupFlatMetadata = <
viewFieldUniversalIdentifiers: [],
universalIdentifier: viewFieldGroupDefinition.universalIdentifier,
applicationId: twentyStandardApplicationId,
isActive: true,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
@@ -120,6 +120,7 @@ export const createStandardViewFieldFlatMetadata = <
isVisible,
size,
aggregateOperation,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
@@ -8,4 +8,7 @@ export abstract class OverridableEntity<
> extends SyncableEntity {
@Column({ type: 'jsonb', nullable: true })
overrides: JsonbProperty<TOverrides> | null;
@Column({ type: 'boolean', default: true })
isActive: boolean;
}
@@ -80,6 +80,36 @@ exports[`Page layout widget restore via bulk update should succeed should handle
"title": "Test Tab For Widget Restore",
"updatedAt": Any<String>,
"widgets": [
{
"configuration": {
"aggregateFieldMetadataId": Any<String>,
"aggregateOperation": "SUM",
"configurationType": "AGGREGATE_CHART",
"description": null,
"displayDataLabel": true,
"filter": null,
"firstDayOfTheWeek": 1,
"format": null,
"label": null,
"prefix": null,
"suffix": null,
"timezone": "UTC",
},
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 1,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": Any<String>,
"pageLayoutTabId": Any<String>,
"title": "Restored Widget",
"type": "GRAPH",
"updatedAt": Any<String>,
},
{
"configuration": {
"configurationType": "IFRAME",
@@ -120,36 +150,6 @@ exports[`Page layout widget restore via bulk update should succeed should handle
"type": "IFRAME",
"updatedAt": Any<String>,
},
{
"configuration": {
"aggregateFieldMetadataId": Any<String>,
"aggregateOperation": "SUM",
"configurationType": "AGGREGATE_CHART",
"description": null,
"displayDataLabel": true,
"filter": null,
"firstDayOfTheWeek": 1,
"format": null,
"label": null,
"prefix": null,
"suffix": null,
"timezone": "UTC",
},
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 1,
"row": 1,
"rowSpan": 1,
},
"id": Any<String>,
"objectMetadataId": Any<String>,
"pageLayoutTabId": Any<String>,
"title": "Restored Widget",
"type": "GRAPH",
"updatedAt": Any<String>,
},
],
},
],
@@ -1,5 +1,4 @@
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 { fetchTestFieldMetadataIds } from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.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';
@@ -193,14 +192,6 @@ describe('Page layout with tabs update should succeed', () => {
});
afterEach(async () => {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: testTabId1 },
});
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: testTabId2 },
});
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
@@ -1,5 +1,14 @@
import { isDefined } from '@/utils/validation';
import { computeDiffBetweenObjects } from '../compute-diff-between-objects';
const isEntityIncludedByDeletedAt = (entity: {
deletedAt: string | null;
}): boolean => !isDefined(entity.deletedAt);
const isEntityIncludedByIsActive = (entity: { isActive: boolean }): boolean =>
entity.isActive;
describe('computeDiffBetweenObjects', () => {
it('should return the correct diff', () => {
const existingObjects = [
@@ -15,13 +24,14 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [{ id: '3', name: 'Object 3' }],
toUpdate: [],
toRestoreAndUpdate: [],
idsToDelete: ['2'],
idsToRemove: ['2'],
});
});
@@ -33,13 +43,14 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: [],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [],
idsToDelete: [],
idsToRemove: [],
});
});
@@ -51,13 +62,14 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [{ id: '1', name: 'Object 1' }],
toUpdate: [],
toRestoreAndUpdate: [],
idsToDelete: [],
idsToRemove: [],
});
});
@@ -69,13 +81,14 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [],
idsToDelete: ['1'],
idsToRemove: ['1'],
});
});
@@ -87,17 +100,18 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [{ id: '1', name: 'Updated Object 1' }],
toRestoreAndUpdate: [],
idsToDelete: [],
idsToRemove: [],
});
});
it('should restore and update deleted objects', () => {
it('should restore and update excluded objects when using deletedAt', () => {
const existingObjects = [
{ id: '1', name: 'Object 1', deletedAt: '2024-01-01' },
];
@@ -107,17 +121,18 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [{ id: '1', name: 'Restored Object 1' }],
idsToDelete: [],
idsToRemove: [],
});
});
it('should not include deleted objects in idsToDelete', () => {
it('should not include excluded objects in idsToRemove', () => {
const existingObjects = [
{ id: '1', name: 'Object 1', deletedAt: null },
{ id: '2', name: 'Object 2', deletedAt: '2024-01-01' },
@@ -128,13 +143,55 @@ describe('computeDiffBetweenObjects', () => {
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByDeletedAt,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [],
idsToDelete: ['1'],
idsToRemove: ['1'],
});
});
it('should restore and update inactive objects when using isActive', () => {
const existingObjects = [{ id: '1', name: 'Object 1', isActive: false }];
const receivedObjects = [{ id: '1', name: 'Restored Object 1' }];
const diff = computeDiffBetweenObjects({
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByIsActive,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [{ id: '1', name: 'Restored Object 1' }],
idsToRemove: [],
});
});
it('should not include inactive objects in idsToRemove when using isActive', () => {
const existingObjects = [
{ id: '1', name: 'Object 1', isActive: true },
{ id: '2', name: 'Object 2', isActive: false },
];
const receivedObjects: { id: string; name: string }[] = [];
const diff = computeDiffBetweenObjects({
existingObjects,
receivedObjects,
propertiesToCompare: ['name'],
isEntityIncluded: isEntityIncludedByIsActive,
});
expect(diff).toEqual({
toCreate: [],
toUpdate: [],
toRestoreAndUpdate: [],
idsToRemove: ['1'],
});
});
});
@@ -5,7 +5,7 @@ type Diff<T extends { id: string }> = {
toCreate: T[];
toUpdate: T[];
toRestoreAndUpdate: T[];
idsToDelete: string[];
idsToRemove: string[];
};
const extractProperties = <T extends { id: string }>(
@@ -27,15 +27,17 @@ type ComputeDiffBetweenObjectsParams<
existingObjects: T[];
receivedObjects: K[];
propertiesToCompare: (keyof K & keyof T)[];
isEntityIncluded: (entity: NoInfer<T>) => boolean;
};
export const computeDiffBetweenObjects = <
T extends { id: string; deletedAt: string | null },
T extends { id: string },
K extends { id: string },
>({
existingObjects,
receivedObjects,
propertiesToCompare,
isEntityIncluded,
}: ComputeDiffBetweenObjectsParams<T, K>): Diff<K> => {
const toCreate: K[] = [];
const toUpdate: K[] = [];
@@ -52,7 +54,7 @@ export const computeDiffBetweenObjects = <
const existingEntity = existingEntitiesMap.get(receivedObject.id);
if (isDefined(existingEntity)) {
if (isDefined(existingEntity.deletedAt)) {
if (!isEntityIncluded(existingEntity)) {
toRestoreAndUpdate.push(receivedObject);
} else {
const comparableExistingEntity = extractProperties(
@@ -74,8 +76,8 @@ export const computeDiffBetweenObjects = <
}
}
const idsToDelete = existingObjects
.filter((existingEntity) => !isDefined(existingEntity.deletedAt))
const idsToRemove = existingObjects
.filter((existingEntity) => isEntityIncluded(existingEntity))
.filter((existingEntity) => !receivedEntitiesMap.has(existingEntity.id))
.map((entity) => entity.id);
@@ -83,6 +85,6 @@ export const computeDiffBetweenObjects = <
toCreate,
toUpdate,
toRestoreAndUpdate,
idsToDelete,
idsToRemove,
};
};