Move open-record-in to object metadata and member preference (#23614)

Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
This commit is contained in:
Félix Malfait
2026-07-31 18:28:55 +02:00
committed by GitHub
parent 6f361a9bf2
commit f663cd3c68
116 changed files with 2395 additions and 1544 deletions
@@ -0,0 +1,27 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.27.0', 1785504900000)
export class AddOpenRecordInToObjectMetadataFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."objectMetadata_openrecordin_enum" AS ENUM('SIDE_PANEL', 'RECORD_PAGE', 'USER_CHOICE')`,
);
await queryRunner.query(
`ALTER TABLE "core"."objectMetadata" ADD "openRecordIn" "core"."objectMetadata_openrecordin_enum" NOT NULL DEFAULT 'USER_CHOICE'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN "openRecordIn"',
);
await queryRunner.query(
'DROP TYPE "core"."objectMetadata_openrecordin_enum"',
);
}
}
@@ -1,18 +1,26 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddWorkspaceMemberOpenRecordInCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785505000000-add-workspace-member-open-record-in.command';
import { SeedObjectOpenRecordInCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785505100000-seed-object-open-record-in.command';
import { BackfillMissingStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
WorkspaceIteratorModule,
],
providers: [
AddWorkspaceMemberOpenRecordInCommand,
SeedObjectOpenRecordInCommand,
BackfillMissingStandardSkillsCommand,
],
providers: [BackfillMissingStandardSkillsCommand],
})
export class V2_27_UpgradeVersionCommandModule {}
@@ -0,0 +1,145 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { getStandardFlatEntitiesToCreateOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
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 { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const WORKSPACE_MEMBER_OPEN_RECORD_IN_FIELD_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.workspaceMember.fields.openRecordIn.universalIdentifier,
];
@RegisteredWorkspaceCommand('2.27.0', 1785505000000)
@Command({
name: 'upgrade:2-27:add-workspace-member-open-record-in',
description:
'Create the workspace member openRecordIn preference field in existing workspaces',
})
export class AddWorkspaceMemberOpenRecordInCommand extends ProvisionedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const existingWorkspaceMemberObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
STANDARD_OBJECTS.workspaceMember.universalIdentifier
];
if (!isDefined(existingWorkspaceMemberObjectMetadata)) {
this.logger.log(
`workspaceMember object metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
// Cheap idempotency check before building the whole standard application.
if (
WORKSPACE_MEMBER_OPEN_RECORD_IN_FIELD_UNIVERSAL_IDENTIFIERS.every(
(universalIdentifier) =>
isDefined(
flatFieldMetadataMaps.byUniversalIdentifier[universalIdentifier],
),
)
) {
this.logger.log(
`workspaceMember openRecordIn already exists for workspace ${workspaceId}, skipping`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const fieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
existingFlatEntityMaps: flatFieldMetadataMaps,
universalIdentifiers:
WORKSPACE_MEMBER_OPEN_RECORD_IN_FIELD_UNIVERSAL_IDENTIFIERS,
});
if (fieldsToCreate.length === 0) {
this.logger.log(
`workspaceMember openRecordIn already exists for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Creating the workspaceMember openRecordIn field for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: fieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create the workspaceMember openRecordIn field:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create the workspaceMember openRecordIn field for workspace ${workspaceId}`,
);
}
this.logger.log(
`Created the workspaceMember openRecordIn field for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,195 @@
import { Command } from 'nest-commander';
import {
ObjectOpenRecordIn,
ViewKey,
ViewOpenRecordIn,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
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 { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@RegisteredWorkspaceCommand('2.27.0', 1785505100000)
@Command({
name: 'upgrade:2-27:seed-object-open-record-in',
description:
'Seed objectMetadata.openRecordIn from the standard definitions and from deliberate per-view record page choices',
})
export class SeedObjectOpenRecordInCommand extends ProvisionedWorkspaceCommandRunner {
// Workspace-invariant, so the standard application is only built once per run.
private standardOpenRecordInByUniversalIdentifier?: Record<
string,
ObjectOpenRecordIn
>;
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
private async getStandardOpenRecordInByUniversalIdentifier(
workspaceId: string,
): Promise<Record<string, ObjectOpenRecordIn>> {
if (isDefined(this.standardOpenRecordInByUniversalIdentifier)) {
return this.standardOpenRecordInByUniversalIdentifier;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
this.standardOpenRecordInByUniversalIdentifier = Object.fromEntries(
Object.values(
standardAllFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(standardObjectMetadata) =>
standardObjectMetadata.openRecordIn !==
ObjectOpenRecordIn.USER_CHOICE,
)
.map((standardObjectMetadata) => [
standardObjectMetadata.universalIdentifier,
standardObjectMetadata.openRecordIn,
]),
);
return this.standardOpenRecordInByUniversalIdentifier;
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatObjectMetadataMaps, flatViewMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatViewMaps',
]);
const targetOpenRecordInByUniversalIdentifier: Record<
string,
ObjectOpenRecordIn
> = {
...(await this.getStandardOpenRecordInByUniversalIdentifier(workspaceId)),
};
// A deliberate per-view record page choice is lifted to the object, unless
// the standard definitions already pin that object.
for (const flatView of Object.values(flatViewMaps.byUniversalIdentifier)) {
if (
isDefined(flatView) &&
flatView.key === ViewKey.INDEX &&
flatView.openRecordIn === ViewOpenRecordIn.RECORD_PAGE &&
!isDefined(
targetOpenRecordInByUniversalIdentifier[
flatView.objectMetadataUniversalIdentifier
],
)
) {
targetOpenRecordInByUniversalIdentifier[
flatView.objectMetadataUniversalIdentifier
] = ObjectOpenRecordIn.RECORD_PAGE;
}
}
const now = new Date().toISOString();
const objectMetadatasToUpdate = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.flatMap((flatObjectMetadata) => {
const targetOpenRecordIn =
targetOpenRecordInByUniversalIdentifier[
flatObjectMetadata.universalIdentifier
];
if (
!isDefined(targetOpenRecordIn) ||
flatObjectMetadata.openRecordIn === targetOpenRecordIn
) {
return [];
}
return [
{
...flatObjectMetadata,
openRecordIn: targetOpenRecordIn,
updatedAt: now,
},
];
});
if (objectMetadatasToUpdate.length === 0) {
this.logger.log(
`Object openRecordIn already seeded for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Workspace ${workspaceId}: seeding openRecordIn on ${objectMetadatasToUpdate.length} object(s)`,
);
if (isDryRun) {
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: objectMetadatasToUpdate,
},
},
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to seed object openRecordIn:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to seed object openRecordIn for workspace ${workspaceId}`,
);
}
this.logger.log(`Seeded object openRecordIn for workspace ${workspaceId}`);
}
}
@@ -0,0 +1,2 @@
export const ADD_OBJECT_METADATA_OPEN_RECORD_IN_UPGRADE_COMMAND_NAME =
'2.27.0_AddOpenRecordInToObjectMetadataFastInstanceCommand_1785504900000';
@@ -131,6 +131,7 @@ import { AddPageLayoutCascadeDeleteIndexesFastInstanceCommand } from './2-25/2-2
import { AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785173910915-add-channel-webhook-subscription-external-id-indexes';
import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message';
import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index';
import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -264,4 +265,5 @@ export const INSTANCE_COMMANDS = [
AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand,
AddIsHiddenToAgentMessageFastInstanceCommand,
AddConnectedAccountHandleProviderIndexFastInstanceCommand,
AddOpenRecordInToObjectMetadataFastInstanceCommand,
];