Ensure command backfills all relations as Field widget (#18858)

\+ add missing Field widget for workflow relations

Copies the logic of the injectRelationWidgetsIntoLayout front-end
function
This commit is contained in:
Baptiste Devessier
2026-03-23 17:49:05 +01:00
committed by GitHub
parent 1cfdd2e8ed
commit 88d8b621f1
5 changed files with 392 additions and 70 deletions
@@ -1,7 +1,7 @@
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isNonEmptyString } from '@sniptt/guards';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
type AssertFieldWidgetOrThrow = (
widget: PageLayoutWidget,
@@ -18,8 +18,7 @@ export const assertFieldWidgetOrThrow: AssertFieldWidgetOrThrow = (
);
if (
!isNonEmptyString(widget.configuration.__typename) ||
widget.configuration.__typename !== 'FieldConfiguration'
widget.configuration.configurationType !== WidgetConfigurationType.FIELD
) {
throw new Error(
`Expected FieldConfiguration but got ${widget.configuration.__typename}`,
@@ -1,24 +1,84 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import {
CoreObjectNameSingular,
FieldMetadataType,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import {
GRID_POSITIONS,
VERTICAL_LIST_LAYOUT_POSITIONS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const HOME_TAB_POSITION = 10;
const isActivityTargetField = (
fieldName: string,
objectNameSingular: string,
): boolean =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldName === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldName === 'taskTargets');
const isJunctionRelationField = (field: FlatFieldMetadata): boolean => {
const settings = field.settings as
| { junctionTargetFieldId?: string }
| undefined;
return (
isDefined(settings?.junctionTargetFieldId) &&
typeof settings?.junctionTargetFieldId === 'string' &&
settings.junctionTargetFieldId.length > 0
);
};
const isRelationTargetAvailable = (
targetObject: FlatObjectMetadata | undefined,
): boolean => {
if (!isDefined(targetObject)) {
return false;
}
if (targetObject.isRemote) {
return false;
}
if (
targetObject.isSystem &&
targetObject.nameSingular !== CoreObjectNameSingular.WorkspaceMember
) {
return false;
}
return true;
};
@Command({
name: 'upgrade:1-20:backfill-field-widgets',
description:
'Backfill FIELD widgets for standard object relation fields in existing page layouts',
'Backfill FIELD widgets for relation fields in existing page layouts',
})
export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@@ -43,50 +103,254 @@ export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigr
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of FIELD widgets for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
]);
const standardFieldWidgets = Object.values(
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(widget) =>
widget.configuration?.configurationType ===
WidgetConfigurationType.FIELD,
);
// Build a set of fieldMetadataIds that already have a FIELD widget
const existingFieldWidgetFieldIds = new Set<string>();
if (standardFieldWidgets.length === 0) {
this.logger.log(
`No FIELD widgets found in standard configs for workspace ${workspaceId}`,
);
return;
for (const widget of Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)) {
if (
isDefined(widget) &&
widget.configuration?.configurationType ===
WidgetConfigurationType.FIELD &&
isDefined(widget.configuration.fieldMetadataId)
) {
existingFieldWidgetFieldIds.add(widget.configuration.fieldMetadataId);
}
}
const { flatPageLayoutWidgetMaps: existingWidgetMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatPageLayoutWidgetMaps',
]);
// Build object ID → objectMetadata map
const objectById = new Map<string, FlatObjectMetadata>();
const widgetsToCreate = standardFieldWidgets.filter(
(widget) =>
!isDefined(
existingWidgetMaps.byUniversalIdentifier[widget.universalIdentifier],
),
);
for (const obj of Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)) {
if (isDefined(obj)) {
objectById.set(obj.id, obj);
}
}
if (widgetsToCreate.length === 0) {
// Build object ID → RECORD_PAGE layout map
const recordPageLayoutByObjectId = new Map<
string,
{ id: string; universalIdentifier: string }
>();
for (const layout of Object.values(
flatPageLayoutMaps.byUniversalIdentifier,
)) {
if (
isDefined(layout) &&
layout.type === PageLayoutType.RECORD_PAGE &&
isDefined(layout.objectMetadataId)
) {
recordPageLayoutByObjectId.set(layout.objectMetadataId, {
id: layout.id,
universalIdentifier: layout.universalIdentifier,
});
}
}
// Build pageLayoutId → home tab map
const homeTabByPageLayoutId = new Map<
string,
{
id: string;
universalIdentifier: string;
widgetCount: number;
}
>();
for (const tab of Object.values(
flatPageLayoutTabMaps.byUniversalIdentifier,
)) {
if (
isDefined(tab) &&
tab.position === HOME_TAB_POSITION &&
tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST
) {
homeTabByPageLayoutId.set(tab.pageLayoutId, {
id: tab.id,
universalIdentifier: tab.universalIdentifier,
widgetCount: tab.widgetIds?.length ?? 0,
});
}
}
// Group fields by objectMetadataId
const fieldsByObjectId = new Map<string, FlatFieldMetadata[]>();
// Map morphId → all field IDs sharing that morphId (for dedup)
const fieldIdsByMorphId = new Map<string, string[]>();
for (const field of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(field) || !isDefined(field.objectMetadataId)) {
continue;
}
const list = fieldsByObjectId.get(field.objectMetadataId) ?? [];
list.push(field);
fieldsByObjectId.set(field.objectMetadataId, list);
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
const morphFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
morphFieldIds.push(field.id);
fieldIdsByMorphId.set(field.morphId, morphFieldIds);
}
}
const now = new Date().toISOString();
const standardWidgetsToCreate: FlatPageLayoutWidget[] = [];
const customWidgetsToCreate: FlatPageLayoutWidget[] = [];
const processedMorphIds = new Set<string>();
for (const object of objectById.values()) {
const pageLayout = recordPageLayoutByObjectId.get(object.id);
if (!isDefined(pageLayout)) {
continue;
}
const homeTab = homeTabByPageLayoutId.get(pageLayout.id);
if (!isDefined(homeTab)) {
continue;
}
const flatApplication: FlatApplication = object.isCustom
? workspaceCustomFlatApplication
: twentyStandardFlatApplication;
const targetList: FlatPageLayoutWidget[] = object.isCustom
? customWidgetsToCreate
: standardWidgetsToCreate;
const fields = fieldsByObjectId.get(object.id) ?? [];
let nextWidgetIndex = homeTab.widgetCount;
for (const field of fields) {
if (
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION
) {
continue;
}
if (isActivityTargetField(field.name, object.nameSingular)) {
continue;
}
if (isJunctionRelationField(field)) {
continue;
}
if (field.type === FieldMetadataType.RELATION) {
const targetObject = isDefined(field.relationTargetObjectMetadataId)
? objectById.get(field.relationTargetObjectMetadataId)
: undefined;
if (!isRelationTargetAvailable(targetObject)) {
continue;
}
}
// For morph relations, skip if any sibling field (same morphId)
// already has a widget or was already processed in this run
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
if (processedMorphIds.has(field.morphId)) {
continue;
}
const siblingFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
const alreadyHasWidget = siblingFieldIds.some((id) =>
existingFieldWidgetFieldIds.has(id),
);
if (alreadyHasWidget) {
continue;
}
processedMorphIds.add(field.morphId);
}
if (existingFieldWidgetFieldIds.has(field.id)) {
continue;
}
const widget: FlatPageLayoutWidget = {
id: v4(),
universalIdentifier: v4(),
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
workspaceId,
pageLayoutTabId: homeTab.id,
pageLayoutTabUniversalIdentifier: homeTab.universalIdentifier,
title: field.label,
type: WidgetType.FIELD,
gridPosition: { ...GRID_POSITIONS.FULL_WIDTH },
position: {
...VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
index: nextWidgetIndex,
},
configuration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.id,
fieldDisplayMode: FieldDisplayMode.CARD,
},
universalConfiguration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.universalIdentifier,
fieldDisplayMode: FieldDisplayMode.CARD,
},
objectMetadataId: object.id,
objectMetadataUniversalIdentifier: object.universalIdentifier,
createdAt: now,
updatedAt: now,
deletedAt: null,
conditionalDisplay: null,
overrides: null,
};
targetList.push(widget);
existingFieldWidgetFieldIds.add(field.id);
nextWidgetIndex++;
}
}
const totalWidgets =
standardWidgetsToCreate.length + customWidgetsToCreate.length;
if (totalWidgets === 0) {
this.logger.log(
`All FIELD widgets already exist for workspace ${workspaceId}, skipping`,
);
@@ -95,44 +359,73 @@ export class BackfillFieldWidgetsCommand extends ActiveOrSuspendedWorkspacesMigr
}
this.logger.log(
`Found ${widgetsToCreate.length} FIELD widget(s) to create for workspace ${workspaceId}`,
`Found ${totalWidgets} FIELD widget(s) to create for workspace ${workspaceId} (${standardWidgetsToCreate.length} standard, ${customWidgetsToCreate.length} custom)`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`,
`[DRY RUN] Would create ${totalWidgets} FIELD widget(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: widgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
if (standardWidgetsToCreate.length > 0) {
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: standardWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create FIELD widgets:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create FIELD widgets for workspace ${workspaceId}`,
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create standard FIELD widgets:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create standard FIELD widgets for workspace ${workspaceId}`,
);
}
}
if (customWidgetsToCreate.length > 0) {
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: customWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create custom FIELD widgets:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create custom FIELD widgets for workspace ${workspaceId}`,
);
}
}
this.logger.log(
`Successfully created ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`,
`Successfully created ${totalWidgets} FIELD widget(s) for workspace ${workspaceId}`,
);
}
}
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard page layout metadata related entity ids 1`] = `
{
@@ -340,21 +340,24 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
"workflowRunRecordPage": {
"id": "00000000-0000-0000-0000-000000000093",
"id": "00000000-0000-0000-0000-000000000094",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000096",
"id": "00000000-0000-0000-0000-000000000098",
"widgets": {
"workflowRun": {
"id": "00000000-0000-0000-0000-000000000097",
"id": "00000000-0000-0000-0000-000000000099",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000094",
"id": "00000000-0000-0000-0000-000000000095",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000095",
"id": "00000000-0000-0000-0000-000000000096",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000097",
},
},
},
@@ -364,10 +367,10 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
"id": "00000000-0000-0000-0000-000000000088",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000091",
"id": "00000000-0000-0000-0000-000000000092",
"widgets": {
"workflowVersion": {
"id": "00000000-0000-0000-0000-000000000092",
"id": "00000000-0000-0000-0000-000000000093",
},
},
},
@@ -377,6 +380,9 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
"fields": {
"id": "00000000-0000-0000-0000-000000000090",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000091",
},
},
},
},
@@ -1,8 +1,11 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
GRID_POSITIONS,
TAB_PROPS,
VERTICAL_LIST_LAYOUT_POSITIONS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
@@ -19,6 +22,15 @@ const WORKFLOW_RUN_PAGE_TABS = {
universalIdentifier: '20202020-ac08-4008-8008-a0bcf10a8811',
...WIDGET_PROPS.fields,
},
workflow: {
universalIdentifier: '20202020-ac08-4008-8008-a0bcf10a8813',
title: 'Workflow',
type: WidgetType.FIELD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.THIRD,
fieldUniversalIdentifier:
STANDARD_OBJECTS.workflowRun.fields.workflow.universalIdentifier,
},
},
},
flow: {
@@ -1,8 +1,11 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
GRID_POSITIONS,
TAB_PROPS,
VERTICAL_LIST_LAYOUT_POSITIONS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
@@ -19,6 +22,15 @@ const WORKFLOW_VERSION_PAGE_TABS = {
universalIdentifier: '20202020-ac07-4007-8007-a0bcf10a7711',
...WIDGET_PROPS.fields,
},
workflow: {
universalIdentifier: '20202020-ac07-4007-8007-a0bcf10a7712',
title: 'Workflow',
type: WidgetType.FIELD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
fieldUniversalIdentifier:
STANDARD_OBJECTS.workflowVersion.fields.workflow.universalIdentifier,
},
},
},
flow: {