From f7aab2e988f499e285d7e4cec77047146dfb533b Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 5 Aug 2026 17:22:14 +0200 Subject: [PATCH] fix: allow app-manifest RECORD_TABLE widgets to reference a view by universal identifier (#23634) ## Context Fixes #23065. App-manifest dashboard `RECORD_TABLE` widgets could not reference a view by universal identifier. `RecordTableConfiguration.viewId` was typed as a plain `string`, so `FormatRecordSerializedRelationProperties` (which only renames properties branded with `SerializedRelation`) left it as `viewId` in the manifest type. As a result the manifest rejected `viewUniversalIdentifier`, and the widget could not be made portable across workspaces the way `FIELDS` widgets already are. ## Changes - `RecordTableConfiguration.viewId` is now `SerializedRelation | null` (was `string`), matching `FieldsConfiguration`. This makes the manifest type surface `viewUniversalIdentifier` instead of `viewId`. - `RecordTableConfigurationDTO.viewId` retyped to match. - Forward converter (`fromPageLayoutWidgetConfigurationToUniversalConfiguration`): the `RECORD_TABLE` case now emits the `viewUniversalIdentifier` key instead of `viewId`, since the branded property is renamed in the universal type. Now consistent with the `FIELDS` case (uses `| null`). - Reverse converter (`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration`): the `RECORD_TABLE` case now reads `viewUniversalIdentifier` and resolves it back to a concrete `viewId`. Frontend readers need no change: `SerializedRelation` is a runtime string, so the existing `typeof === 'string'` guards and `as string` casts still hold. ## Migration None needed. The persisted `pageLayoutWidget.configuration` still stores a concrete `viewId`; `universalConfiguration` (which carries `viewUniversalIdentifier`) is computed on the fly from it and never persisted. Only the manifest/universal representation changes, so there is no stored data in the old shape to backfill. ## Verification - `nx typecheck twenty-server` and `nx typecheck twenty-front`: pass - oxlint + oxfmt on the changed files: clean - End-to-end against a server built from this branch: built a minimal app declaring a view (by `universalIdentifier`) and a `DASHBOARD` page layout with a `RECORD_TABLE` widget referencing that view via `viewUniversalIdentifier`, then installed it. The manifest carried `viewUniversalIdentifier`, and the installed `pageLayoutWidget.configuration.viewId` resolved to the concrete workspace view id. Review in cubic --- ...uration-to-universal-configuration.util.ts | 7 +- .../dtos/record-table-configuration.dto.ts | 7 +- ...t-page-layout-widget-configuration.util.ts | 5 +- ...versal-identifier.integration-spec.ts.snap | 51 ++++++++ ...w-universal-identifier.integration-spec.ts | 88 +++++++++++++ ...w-universal-identifier.integration-spec.ts | 119 ++++++++++++++++++ .../page-layout-widget-configuration.type.ts | 2 +- 7 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-manifest-record-table-view-universal-identifier.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/failing-manifest-record-table-view-universal-identifier.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-record-table-view-universal-identifier.integration-spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts index 8012a1a28a..cd7691d5e3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts @@ -281,11 +281,10 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({ case WidgetConfigurationType.RECORD_TABLE: { const { viewId, ...rest } = configuration; - let viewUniversalIdentifier: string | undefined = undefined; + let viewUniversalIdentifier: string | null = null; if (isDefined(viewId)) { - viewUniversalIdentifier = - viewUniversalIdentifierById[viewId] ?? undefined; + viewUniversalIdentifier = viewUniversalIdentifierById[viewId] ?? null; if ( !isDefined(viewUniversalIdentifier) && @@ -300,7 +299,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({ return { ...rest, - viewId: viewUniversalIdentifier, + viewUniversalIdentifier, }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/record-table-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/record-table-configuration.dto.ts index 1eec6e955b..39086dcc42 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/record-table-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/record-table-configuration.dto.ts @@ -8,7 +8,10 @@ import { IsUUID, Min, } from 'class-validator'; -import { type RecordTableConfiguration } from 'twenty-shared/types'; +import { + type RecordTableConfiguration, + type SerializedRelation, +} from 'twenty-shared/types'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; @@ -22,7 +25,7 @@ export class RecordTableConfigurationDTO implements RecordTableConfiguration { @Field(() => String, { nullable: true }) @IsOptional() @IsUUID() - viewId?: string; + viewId?: SerializedRelation | null; @Field(() => Int, { nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts index d2ff878bb0..fa08011c6b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts @@ -260,10 +260,9 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({ } case WidgetConfigurationType.RECORD_TABLE: { - const { viewId: viewUniversalIdentifier, ...rest } = - universalConfiguration; + const { viewUniversalIdentifier, ...rest } = universalConfiguration; - let viewId: string | undefined = undefined; + let viewId: string | null = null; if (isDefined(viewUniversalIdentifier)) { const flatView = findFlatEntityByUniversalIdentifier({ diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-manifest-record-table-view-universal-identifier.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-manifest-record-table-view-universal-identifier.integration-spec.ts.snap new file mode 100644 index 0000000000..334c80de13 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-manifest-record-table-view-universal-identifier.integration-spec.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Failing manifest sync - RECORD_TABLE widget with unknown view universal identifier rejects a dashboard RECORD_TABLE widget whose target view does not exist 1`] = ` +{ + "eventId": Any, + "extensions": { + "action": { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "conditionalAvailabilityExpression": null, + "conditionalDisplay": null, + "createdAt": Any, + "deletedAt": null, + "gridPosition": { + "column": 0, + "columnSpan": 6, + "row": 0, + "rowSpan": 4, + }, + "isActive": true, + "isSystemSideEffect": false, + "objectMetadataUniversalIdentifier": Any, + "pageLayoutTabUniversalIdentifier": Any, + "position": null, + "title": "RT test table", + "type": "RECORD_TABLE", + "universalConfiguration": { + "configurationType": "RECORD_TABLE", + "viewUniversalIdentifier": Any, + }, + "universalIdentifier": Any, + "universalOverrides": null, + "updatedAt": Any, + }, + "metadataName": "pageLayoutWidget", + "type": "create", + }, + "code": "APPLICATION_INSTALLATION_FAILED", + "errors": { + "actionTranspilation": { + "code": "ENTITY_NOT_FOUND", + "message": "View not found for universal identifier: b0000000-0000-4000-8000-00000000000b", + }, + }, + "exceptionEventId": Any, + "userFriendlyMessage": "Migration execution failed.", + }, + "message": "Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: a0000000-0000-4000-8000-00000000000a) failed: [actionTranspilation] View not found for universal identifier: b0000000-0000-4000-8000-00000000000b", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-manifest-record-table-view-universal-identifier.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-manifest-record-table-view-universal-identifier.integration-spec.ts new file mode 100644 index 0000000000..2a37845a41 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-manifest-record-table-view-universal-identifier.integration-spec.ts @@ -0,0 +1,88 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { type Manifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_LAYOUT_ID = uuidv4(); +const TEST_TAB_ID = uuidv4(); +const TEST_WIDGET_ID = 'a0000000-0000-4000-8000-00000000000a'; +const UNKNOWN_VIEW_ID = 'b0000000-0000-4000-8000-00000000000b'; + +const PERSON_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.universalIdentifier; + +const buildManifest = (overrides?: Partial>) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides, + }); + +describe('Failing manifest sync - RECORD_TABLE widget with unknown view universal identifier', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: + 'App for testing a RECORD_TABLE widget referencing an unknown view universal identifier', + sourcePath: 'test-manifest-record-table-unknown-view-universal-identifier', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('rejects a dashboard RECORD_TABLE widget whose target view does not exist', async () => { + const { errors } = await syncApplication({ + manifest: buildManifest({ + pageLayouts: [ + { + universalIdentifier: TEST_LAYOUT_ID, + name: 'RT dashboard', + type: PageLayoutType.DASHBOARD, + tabs: [ + { + universalIdentifier: TEST_TAB_ID, + title: 'Tables', + position: 0, + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: TEST_WIDGET_ID, + title: 'RT test table', + type: 'RECORD_TABLE', + objectUniversalIdentifier: + PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + gridPosition: { + row: 0, + column: 0, + rowSpan: 4, + columnSpan: 6, + }, + configuration: { + configurationType: 'RECORD_TABLE', + viewUniversalIdentifier: UNKNOWN_VIEW_ID, + }, + }, + ], + }, + ], + }, + ], + }), + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-record-table-view-universal-identifier.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-record-table-view-universal-identifier.integration-spec.ts new file mode 100644 index 0000000000..06cef79400 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-record-table-view-universal-identifier.integration-spec.ts @@ -0,0 +1,119 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { type Manifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + ViewType, +} from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_VIEW_ID = uuidv4(); +const TEST_LAYOUT_ID = uuidv4(); +const TEST_TAB_ID = uuidv4(); +const TEST_WIDGET_ID = uuidv4(); + +const PERSON_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.universalIdentifier; + +const buildManifest = ( + overrides?: Partial>, +) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides, + }); + +const buildManifestWithRecordTableDashboard = () => + buildManifest({ + views: [ + { + universalIdentifier: TEST_VIEW_ID, + name: 'RT test view', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconList', + position: 0, + }, + ], + pageLayouts: [ + { + universalIdentifier: TEST_LAYOUT_ID, + name: 'RT dashboard', + type: PageLayoutType.DASHBOARD, + tabs: [ + { + universalIdentifier: TEST_TAB_ID, + title: 'Tables', + position: 0, + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: TEST_WIDGET_ID, + title: 'RT test table', + type: 'RECORD_TABLE', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 6 }, + configuration: { + configurationType: 'RECORD_TABLE', + viewUniversalIdentifier: TEST_VIEW_ID, + }, + }, + ], + }, + ], + }, + ], + }); + +describe('Manifest sync - RECORD_TABLE widget view universal identifier', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: + 'App for testing RECORD_TABLE widget viewUniversalIdentifier resolution', + sourcePath: 'test-manifest-record-table-view-universal-identifier', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('resolves a dashboard RECORD_TABLE widget viewUniversalIdentifier to the concrete view id', async () => { + const { data, errors } = await syncApplication({ + manifest: buildManifestWithRecordTableDashboard(), + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(data?.syncApplication).toBeDefined(); + + const [viewRow] = await globalThis.testDataSource.query( + `SELECT id FROM core."view" WHERE "universalIdentifier" = $1`, + [TEST_VIEW_ID], + ); + + expect(viewRow?.id).toBeDefined(); + expect(viewRow.id).not.toBe(TEST_VIEW_ID); + + const [widgetRow] = await globalThis.testDataSource.query( + `SELECT configuration FROM core."pageLayoutWidget" WHERE "universalIdentifier" = $1`, + [TEST_WIDGET_ID], + ); + + expect(widgetRow?.configuration).toEqual({ + configurationType: 'RECORD_TABLE', + viewId: viewRow.id, + }); + }, 60000); +}); diff --git a/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts b/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts index 7675d9269c..6574bd1ea5 100644 --- a/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts +++ b/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts @@ -92,7 +92,7 @@ export type ViewConfiguration = { export type RecordTableConfiguration = { configurationType: 'RECORD_TABLE'; - viewId?: string; + viewId?: SerializedRelation | null; recordLimit?: number; };