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:
+3
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -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()
|
||||
|
||||
+2
-3
@@ -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({
|
||||
|
||||
+51
@@ -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",
|
||||
}
|
||||
`;
|
||||
+88
@@ -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);
|
||||
});
|
||||
+119
@@ -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);
|
||||
});
|
||||
+1
-1
@@ -92,7 +92,7 @@ export type ViewConfiguration = {
|
||||
|
||||
export type RecordTableConfiguration = {
|
||||
configurationType: 'RECORD_TABLE';
|
||||
viewId?: string;
|
||||
viewId?: SerializedRelation | null;
|
||||
recordLimit?: number;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user