Files
twenty/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts
T
Félix Malfait f663cd3c68 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>
2026-07-31 18:28:55 +02:00

212 lines
7.2 KiB
TypeScript

import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { ObjectOpenRecordIn } from 'twenty-shared/types';
import { ADD_METADATA_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-metadata-overrides-column-upgrade-command-name.constant';
import { ADD_OBJECT_METADATA_OPEN_RECORD_IN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-27/add-object-metadata-open-record-in-upgrade-command-name.constant';
import { DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant';
import { type WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME } from 'src/engine/metadata-modules/object-metadata/constants/rename-is-ui-read-only-to-is-ui-editable-upgrade-command-name.constant';
import { type ObjectMetadataOverrides } from 'src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity('objectMetadata')
@Unique('IDX_OBJECT_METADATA_NAME_SINGULAR_WORKSPACE_ID_UNIQUE', [
'nameSingular',
'workspaceId',
])
@Unique('IDX_OBJECT_METADATA_NAME_PLURAL_WORKSPACE_ID_UNIQUE', [
'namePlural',
'workspaceId',
])
export class ObjectMetadataEntity
extends SyncableEntity
implements Required<ObjectMetadataEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
// @deprecated - FK dropped, column kept for data preservation only
@Column({ nullable: true, type: 'uuid' })
dataSourceId: string;
@Column({ nullable: false })
nameSingular: string;
@Column({ nullable: false })
namePlural: string;
@Column({ nullable: false })
labelSingular: string;
@Column({ nullable: false })
labelPlural: string;
@Column({ nullable: true, type: 'text' })
description: string | null;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ nullable: true, type: 'text' })
color: string | null;
@WasIntroducedInUpgrade({
upgradeCommandName: ADD_OBJECT_METADATA_OPEN_RECORD_IN_UPGRADE_COMMAND_NAME,
})
@Column({
type: 'enum',
enum: Object.values(ObjectOpenRecordIn),
default: ObjectOpenRecordIn.USER_CHOICE,
})
openRecordIn: ObjectOpenRecordIn;
@WasIntroducedInUpgrade({
upgradeCommandName: ADD_METADATA_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'jsonb', nullable: true })
overrides: JsonbProperty<ObjectMetadataOverrides> | null;
/**
* @deprecated Please use `overrides` instead.
*/
@WasRemovedInUpgrade({
upgradeCommandName:
DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'jsonb', nullable: true })
standardOverrides: WasRemovedInUpgrade<JsonbProperty<ObjectMetadataOverrides> | null>;
/**
* @deprecated
*/
@Column({ nullable: false })
targetTableName: string;
@WasRemovedInUpgrade({
upgradeCommandName:
'2.12.0_DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand_1780579070012',
})
@Column({ type: 'boolean', default: false })
isCustom: WasRemovedInUpgrade<boolean>;
@Column({ default: false })
isRemote: boolean;
@Column({ default: false })
isActive: boolean;
@Column({ default: false })
isSystem: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUIEditable: boolean;
// Superseded by isUIEditable. Intentionally NOT @WasRemovedInUpgrade: dropping
// it in 2.13 would break the previous release's pods mid rolling-deploy, since
// they still SELECT it. The WasRemovedInUpgrade<T> type is kept so callers may
// omit it; the decorator + physical drop are deferred (core-team-issues#2542).
@Column({ type: 'boolean', default: false })
isUIReadOnly: WasRemovedInUpgrade<boolean>;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUICreatable: boolean;
@Column({ default: true })
isAuditLogged: boolean;
@Column({ default: false })
isSearchable: boolean;
@Column({ type: 'jsonb', nullable: true })
duplicateCriteria: JsonbProperty<WorkspaceEntityDuplicateCriteria[]> | null;
@Column({ nullable: true, type: 'varchar' })
shortcut: string | null;
// TODO: This should not be nullable - legacy field introduced when label identifier was nullable
// TODO: This should be a joinColumn and we should have a FK on this too https://github.com/twentyhq/core-team-issues/issues/2172
@Column({ nullable: true, type: 'uuid' })
labelIdentifierFieldMetadataId: string | null;
@Column({ nullable: true, type: 'uuid' })
imageIdentifierFieldMetadataId: string | null;
@Column({ default: false })
isLabelSyncedWithName: boolean;
@OneToMany(() => FieldMetadataEntity, (field) => field.object, {
cascade: true,
})
fields: Relation<FieldMetadataEntity[]>;
@OneToMany(() => IndexMetadataEntity, (index) => index.objectMetadata, {
cascade: true,
})
indexMetadatas: Relation<IndexMetadataEntity[]>;
@OneToMany(
() => SearchFieldMetadataEntity,
(searchFieldMetadata) => searchFieldMetadata.objectMetadata,
{
cascade: true,
},
)
searchFieldMetadatas: Relation<SearchFieldMetadataEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@OneToMany(
() => ObjectPermissionEntity,
(objectPermission) => objectPermission.objectMetadata,
{
cascade: true,
},
)
objectPermissions: Relation<ObjectPermissionEntity[]>;
@OneToMany(
() => FieldPermissionEntity,
(fieldPermission) => fieldPermission.objectMetadata,
{
cascade: true,
},
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.objectMetadata, {
cascade: true,
})
views: Relation<ViewEntity[]>;
}