diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index cd66ee0f52..89be729f4e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -214,6 +214,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "position", "universalConfiguration", "deletedAt", + "pageLayoutTabUniversalIdentifier", "conditionalDisplay", "conditionalAvailabilityExpression", "isActive", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index 8ff75421ef..af1aca2508 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -930,7 +930,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { universalProperty: undefined, }, pageLayoutTabId: { - toCompare: false, + toCompare: true, toStringify: false, universalProperty: 'pageLayoutTabUniversalIdentifier', isOverridable: true, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/__tests__/flat-page-layout-widget-validator.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/__tests__/flat-page-layout-widget-validator.service.spec.ts new file mode 100644 index 0000000000..35d26a0f9c --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/__tests__/flat-page-layout-widget-validator.service.spec.ts @@ -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; + 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); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service.ts index 73dda3358d..b8648bb18d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service.ts @@ -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, diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/page-layout-widget-override.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/page-layout-widget-override.integration-spec.ts index 1584e187d5..f933637141 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/page-layout-widget-override.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/page-layout-widget-override.integration-spec.ts @@ -1,7 +1,10 @@ +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 { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util'; import { findPageLayoutWidgets } from 'test/integration/metadata/suites/page-layout-widget/utils/find-page-layout-widgets.util'; import { updateOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget.util'; import { findPageLayouts } from 'test/integration/metadata/suites/page-layout/utils/find-page-layouts.util'; +import { PageLayoutTabLayoutMode } from 'twenty-shared/types'; import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; @@ -17,7 +20,10 @@ const WIDGET_OVERRIDE_GQL_FIELDS = ` `; describe('Page layout widget override behavior', () => { + let seededPageLayoutId: string; let seededWidgetId: string; + let seededWidgetOriginalPageLayoutTabId: string; + let seededWidgetOriginalTabLayoutMode: PageLayoutTabLayoutMode; let seededWidgetOriginalTitle: string; beforeAll(async () => { @@ -32,15 +38,27 @@ describe('Page layout widget override behavior', () => { expect(recordPageLayout).toBeDefined(); + seededPageLayoutId = recordPageLayout!.id; + const { data: tabsData } = await findPageLayoutTabs({ expectToFail: false, - input: { pageLayoutId: recordPageLayout!.id }, + input: { pageLayoutId: seededPageLayoutId }, + gqlFields: ` + id + title + position + layoutMode + pageLayoutId + `, }); expect(tabsData.getPageLayoutTabs.length).toBeGreaterThanOrEqual(1); const firstTabId = tabsData.getPageLayoutTabs[0].id; + seededWidgetOriginalTabLayoutMode = tabsData.getPageLayoutTabs[0] + .layoutMode as PageLayoutTabLayoutMode; + const { data: widgetsData } = await findPageLayoutWidgets({ expectToFail: false, input: { pageLayoutTabId: firstTabId }, @@ -52,6 +70,7 @@ describe('Page layout widget override behavior', () => { const firstWidget = widgetsData.getPageLayoutWidgets[0]; seededWidgetId = firstWidget.id; + seededWidgetOriginalPageLayoutTabId = firstWidget.pageLayoutTabId; seededWidgetOriginalTitle = firstWidget.title; }); @@ -60,6 +79,7 @@ describe('Page layout widget override behavior', () => { expectToFail: false, input: { id: seededWidgetId, + pageLayoutTabId: seededWidgetOriginalPageLayoutTabId, title: seededWidgetOriginalTitle, }, gqlFields: WIDGET_OVERRIDE_GQL_FIELDS, @@ -81,6 +101,74 @@ describe('Page layout widget override behavior', () => { expect(data.updatePageLayoutWidget.title).toBe(overriddenTitle); }); + it('should move a seeded widget to another tab through an override', async () => { + let destinationTabId: string | undefined; + + try { + const { data: tabData } = await createOnePageLayoutTab({ + expectToFail: false, + input: { + title: `Widget Move Override ${Date.now()}`, + pageLayoutId: seededPageLayoutId, + layoutMode: seededWidgetOriginalTabLayoutMode, + }, + }); + + destinationTabId = tabData.createPageLayoutTab.id; + + const { data } = await updateOnePageLayoutWidget({ + expectToFail: false, + input: { + id: seededWidgetId, + pageLayoutTabId: destinationTabId, + }, + gqlFields: WIDGET_OVERRIDE_GQL_FIELDS, + }); + + expect(data.updatePageLayoutWidget.pageLayoutTabId).toBe( + destinationTabId, + ); + + const { data: originalTabWidgetsData } = await findPageLayoutWidgets({ + expectToFail: false, + input: { pageLayoutTabId: seededWidgetOriginalPageLayoutTabId }, + gqlFields: WIDGET_OVERRIDE_GQL_FIELDS, + }); + + expect( + originalTabWidgetsData.getPageLayoutWidgets.map((widget) => widget.id), + ).not.toContain(seededWidgetId); + + const { data: destinationTabWidgetsData } = await findPageLayoutWidgets({ + expectToFail: false, + input: { pageLayoutTabId: destinationTabId }, + gqlFields: WIDGET_OVERRIDE_GQL_FIELDS, + }); + + expect( + destinationTabWidgetsData.getPageLayoutWidgets.map( + (widget) => widget.id, + ), + ).toContain(seededWidgetId); + } finally { + await updateOnePageLayoutWidget({ + expectToFail: false, + input: { + id: seededWidgetId, + pageLayoutTabId: seededWidgetOriginalPageLayoutTabId, + }, + gqlFields: WIDGET_OVERRIDE_GQL_FIELDS, + }); + + if (destinationTabId !== undefined) { + await destroyOnePageLayoutTab({ + expectToFail: false, + input: { id: destinationTabId }, + }); + } + } + }); + it('should return the overridden title when querying the widget', async () => { const { data: tabsData } = await findPageLayoutTabs({ expectToFail: false, diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget-query-factory.util.ts index 3094b142a9..4c2f5f3e58 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget-query-factory.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget-query-factory.util.ts @@ -42,12 +42,16 @@ export const updateOnePageLayoutWidgetQueryFactory = ({ variables: { id: input.id, input: { + pageLayoutTabId: input.pageLayoutTabId, title: input.title, type: input.type, objectMetadataId: input.objectMetadataId, gridPosition: input.gridPosition, + position: input.position, configuration: input.configuration, conditionalDisplay: input.conditionalDisplay, + conditionalAvailabilityExpression: + input.conditionalAvailabilityExpression, }, }, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts index 2a9156da10..55b6644328 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts @@ -229,4 +229,98 @@ describe('Page layout with tabs update should succeed', () => { ); }, ); + + it('should move a widget to another tab when saving layout tabs and widgets', async () => { + const widgetId = v4(); + + await updateOnePageLayoutWithTabsAndWidgets({ + expectToFail: false, + input: { + id: testPageLayoutId, + name: 'Layout Before Widget Move', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs: [ + { + id: testTabId1, + title: 'Source Tab', + position: 0, + widgets: [ + { + id: widgetId, + pageLayoutTabId: testTabId1, + title: 'Iframe Widget', + type: WidgetType.IFRAME, + objectMetadataId: null, + gridPosition: { + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: MOCK_IFRAME_CONFIGURATION, + }, + ], + }, + { + id: testTabId2, + title: 'Destination Tab', + position: 1, + widgets: [], + }, + ], + }, + }); + + const { data } = await updateOnePageLayoutWithTabsAndWidgets({ + expectToFail: false, + input: { + id: testPageLayoutId, + name: 'Layout After Widget Move', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs: [ + { + id: testTabId1, + title: 'Source Tab', + position: 0, + widgets: [], + }, + { + id: testTabId2, + title: 'Destination Tab', + position: 1, + widgets: [ + { + id: widgetId, + pageLayoutTabId: testTabId2, + title: 'Iframe Widget', + type: WidgetType.IFRAME, + objectMetadataId: null, + gridPosition: { + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: MOCK_IFRAME_CONFIGURATION, + }, + ], + }, + ], + }, + }); + + const updatedTabs = data.updatePageLayoutWithTabsAndWidgets.tabs ?? []; + const sourceTab = updatedTabs.find((tab) => tab.id === testTabId1); + const destinationTab = updatedTabs.find((tab) => tab.id === testTabId2); + + expect(sourceTab?.widgets).toHaveLength(0); + expect(destinationTab?.widgets).toHaveLength(1); + expect(destinationTab?.widgets?.[0]).toMatchObject({ + id: widgetId, + pageLayoutTabId: testTabId2, + title: 'Iframe Widget', + }); + }); });