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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23634?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas Trompette
2026-08-05 17:22:14 +02:00
committed by GitHub
parent 17ff17bdec
commit f7aab2e988
7 changed files with 269 additions and 10 deletions
@@ -281,11 +281,10 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
case WidgetConfigurationType.RECORD_TABLE: { case WidgetConfigurationType.RECORD_TABLE: {
const { viewId, ...rest } = configuration; const { viewId, ...rest } = configuration;
let viewUniversalIdentifier: string | undefined = undefined; let viewUniversalIdentifier: string | null = null;
if (isDefined(viewId)) { if (isDefined(viewId)) {
viewUniversalIdentifier = viewUniversalIdentifier = viewUniversalIdentifierById[viewId] ?? null;
viewUniversalIdentifierById[viewId] ?? undefined;
if ( if (
!isDefined(viewUniversalIdentifier) && !isDefined(viewUniversalIdentifier) &&
@@ -300,7 +299,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
return { return {
...rest, ...rest,
viewId: viewUniversalIdentifier, viewUniversalIdentifier,
}; };
} }
@@ -8,7 +8,10 @@ import {
IsUUID, IsUUID,
Min, Min,
} from 'class-validator'; } 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'; 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 }) @Field(() => String, { nullable: true })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
viewId?: string; viewId?: SerializedRelation | null;
@Field(() => Int, { nullable: true }) @Field(() => Int, { nullable: true })
@IsOptional() @IsOptional()
@@ -260,10 +260,9 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
} }
case WidgetConfigurationType.RECORD_TABLE: { case WidgetConfigurationType.RECORD_TABLE: {
const { viewId: viewUniversalIdentifier, ...rest } = const { viewUniversalIdentifier, ...rest } = universalConfiguration;
universalConfiguration;
let viewId: string | undefined = undefined; let viewId: string | null = null;
if (isDefined(viewUniversalIdentifier)) { if (isDefined(viewUniversalIdentifier)) {
const flatView = findFlatEntityByUniversalIdentifier({ const flatView = findFlatEntityByUniversalIdentifier({
@@ -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<String>,
"extensions": {
"action": {
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"conditionalAvailabilityExpression": null,
"conditionalDisplay": null,
"createdAt": Any<String>,
"deletedAt": null,
"gridPosition": {
"column": 0,
"columnSpan": 6,
"row": 0,
"rowSpan": 4,
},
"isActive": true,
"isSystemSideEffect": false,
"objectMetadataUniversalIdentifier": Any<String>,
"pageLayoutTabUniversalIdentifier": Any<String>,
"position": null,
"title": "RT test table",
"type": "RECORD_TABLE",
"universalConfiguration": {
"configurationType": "RECORD_TABLE",
"viewUniversalIdentifier": Any<String>,
},
"universalIdentifier": Any<String>,
"universalOverrides": null,
"updatedAt": Any<String>,
},
"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<String>,
"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",
}
`;
@@ -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<Pick<Manifest, 'pageLayouts'>>) =>
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);
});
@@ -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<Pick<Manifest, 'views' | 'pageLayouts'>>,
) =>
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);
});
@@ -92,7 +92,7 @@ export type ViewConfiguration = {
export type RecordTableConfiguration = { export type RecordTableConfiguration = {
configurationType: 'RECORD_TABLE'; configurationType: 'RECORD_TABLE';
viewId?: string; viewId?: SerializedRelation | null;
recordLimit?: number; recordLimit?: number;
}; };