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