Fix page layout widget tab moves (#20915)

Fixes widget moves between page layout tabs by making pageLayoutTabId
part of the flat-entity diff, so the save mutation no longer silently
drops the new tab assignment.

https://discord.com/channels/1130383047699738754/1508737039128985680
This commit is contained in:
nitin
2026-05-27 20:59:12 +05:30
committed by GitHub
parent 9264480824
commit 863f3f29a2
7 changed files with 363 additions and 5 deletions
@@ -0,0 +1,146 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
import { type 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-tab/exceptions/page-layout-tab.exception';
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
import { FlatPageLayoutWidgetValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service';
const EXISTING_TAB_UNIVERSAL_IDENTIFIER =
'00000000-0000-0000-0000-000000000aa1';
const MISSING_TAB_UNIVERSAL_IDENTIFIER = '00000000-0000-0000-0000-000000000aa2';
const DESTINATION_TAB_UNIVERSAL_IDENTIFIER =
'00000000-0000-0000-0000-000000000aa3';
const WIDGET_UNIVERSAL_IDENTIFIER = '00000000-0000-0000-0000-000000000111';
const tab = (
universalIdentifier = EXISTING_TAB_UNIVERSAL_IDENTIFIER,
): UniversalFlatPageLayoutTab =>
({
universalIdentifier,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
}) as unknown as UniversalFlatPageLayoutTab;
const widget = (
universalIdentifier = WIDGET_UNIVERSAL_IDENTIFIER,
pageLayoutTabUniversalIdentifier = EXISTING_TAB_UNIVERSAL_IDENTIFIER,
): FlatPageLayoutWidget =>
({
universalIdentifier,
pageLayoutTabUniversalIdentifier,
title: 'widget',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
position: { layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, index: 0 },
}) as unknown as FlatPageLayoutWidget;
const mapsFrom = (entities: { universalIdentifier: string }[]): any => {
const maps = createEmptyFlatEntityMaps() as any;
for (const entity of entities) {
maps.byUniversalIdentifier[entity.universalIdentifier] = entity;
}
return maps;
};
const buildUpdateArgs = ({
update,
tabs = [tab()],
existingWidgets = [widget()],
}: {
update: Record<string, unknown>;
tabs?: UniversalFlatPageLayoutTab[];
existingWidgets?: FlatPageLayoutWidget[];
}) =>
({
universalIdentifier: WIDGET_UNIVERSAL_IDENTIFIER,
flatEntityUpdate: update,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutTabMaps: mapsFrom(tabs),
flatPageLayoutWidgetMaps: mapsFrom(existingWidgets),
},
additionalCacheDataMaps: { featureFlagsMap: {} },
workspaceId: 'workspace-id',
buildOptions: {} as never,
}) as unknown as Parameters<
FlatPageLayoutWidgetValidatorService['validateFlatPageLayoutWidgetUpdate']
>[0];
describe('FlatPageLayoutWidgetValidatorService', () => {
let service: FlatPageLayoutWidgetValidatorService;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
FlatPageLayoutWidgetValidatorService,
{
provide: FlatPageLayoutWidgetTypeValidatorService,
useValue: {
validateFlatPageLayoutWidgetTypeSpecificitiesForCreation: () => [],
validateFlatPageLayoutWidgetTypeSpecificitiesForUpdate: () => [],
},
},
],
}).compile();
service = moduleRef.get(FlatPageLayoutWidgetValidatorService);
});
describe('validateFlatPageLayoutWidgetUpdate', () => {
it('rejects moving a widget to an unknown tab', async () => {
const result = await service.validateFlatPageLayoutWidgetUpdate(
buildUpdateArgs({
update: {
pageLayoutTabUniversalIdentifier: MISSING_TAB_UNIVERSAL_IDENTIFIER,
},
}),
);
expect(result.errors.map((error) => error.code)).toContain(
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
it('rejects moving an overridden widget to an unknown tab', async () => {
const result = await service.validateFlatPageLayoutWidgetUpdate(
buildUpdateArgs({
update: {
universalOverrides: {
pageLayoutTabUniversalIdentifier:
MISSING_TAB_UNIVERSAL_IDENTIFIER,
},
},
}),
);
expect(result.errors.map((error) => error.code)).toContain(
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
it('accepts moving an overridden widget to a known tab and exposes the override target as the effective tab', async () => {
const result = await service.validateFlatPageLayoutWidgetUpdate(
buildUpdateArgs({
update: {
universalOverrides: {
pageLayoutTabUniversalIdentifier:
DESTINATION_TAB_UNIVERSAL_IDENTIFIER,
},
},
tabs: [tab(), tab(DESTINATION_TAB_UNIVERSAL_IDENTIFIER)],
}),
);
expect(result.errors.map((error) => error.code)).not.toContain(
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
expect(
result.flatEntityMinimalInformation.pageLayoutTabUniversalIdentifier,
).toBe(DESTINATION_TAB_UNIVERSAL_IDENTIFIER);
});
});
});
@@ -17,6 +17,7 @@ import { validatePageLayoutWidgetGridPosition } from 'src/engine/metadata-module
import { validatePageLayoutWidgetVerticalListPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-page-layout-widget-vertical-list-position.util';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-grid-position.util';
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
import { type UniversalFlatPageLayoutWidget } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-widget.type';
import {
FailedFlatEntityValidation,
FlatEntityValidationError,
@@ -70,19 +71,31 @@ export class FlatPageLayoutWidgetValidatorService {
...flatEntityUpdate,
};
const effectivePageLayoutTabUniversalIdentifier =
this.getEffectivePageLayoutTabUniversalIdentifier(
updatedFlatPageLayoutWidget,
);
validationResult.flatEntityMinimalInformation = {
...validationResult.flatEntityMinimalInformation,
pageLayoutTabUniversalIdentifier:
updatedFlatPageLayoutWidget.pageLayoutTabUniversalIdentifier,
effectivePageLayoutTabUniversalIdentifier,
};
const referencedPageLayoutTab = findFlatEntityByUniversalIdentifier({
universalIdentifier:
updatedFlatPageLayoutWidget.pageLayoutTabUniversalIdentifier,
universalIdentifier: effectivePageLayoutTabUniversalIdentifier,
flatEntityMaps:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.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`,
});
}
const gridPositionErrors = this.validateGridPosition({
gridPosition: updatedFlatPageLayoutWidget.gridPosition,
widgetTitle: updatedFlatPageLayoutWidget.title,
@@ -235,6 +248,18 @@ export class FlatPageLayoutWidgetValidatorService {
return validationResult;
}
private getEffectivePageLayoutTabUniversalIdentifier(
widget: Pick<
UniversalFlatPageLayoutWidget,
'pageLayoutTabUniversalIdentifier' | 'universalOverrides'
>,
): string {
return (
widget.universalOverrides?.pageLayoutTabUniversalIdentifier ??
widget.pageLayoutTabUniversalIdentifier
);
}
private validateGridPosition({
gridPosition,
widgetTitle,