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
@@ -343,6 +343,7 @@ type Object {
isUICreatable: Boolean!
isUIReadOnly: Boolean! @deprecated(reason: "Use isUIEditable")
isSearchable: Boolean!
openRecordIn: ObjectOpenRecordIn!
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
@@ -369,6 +370,12 @@ type Object {
): ObjectIndexMetadatasConnection!
}
enum ObjectOpenRecordIn {
SIDE_PANEL
RECORD_PAGE
USER_CHOICE
}
input CursorPaging {
"""Paginate before opaque cursor"""
before: ConnectionCursor
@@ -436,6 +443,7 @@ type WorkspaceMember {
name: FullName!
userEmail: String!
colorScheme: String!
openRecordIn: OpenRecordIn!
avatarUrl: String
locale: String
calendarStartDay: Int
@@ -447,6 +455,11 @@ type WorkspaceMember {
numberFormat: WorkspaceMemberNumberFormatEnum
}
enum OpenRecordIn {
SIDE_PANEL
RECORD_PAGE
}
"""Date format as Month first, Day first, Year first or system as default"""
enum WorkspaceMemberDateFormatEnum {
SYSTEM
@@ -793,7 +806,7 @@ type View {
position: Float!
isCompact: Boolean!
isCustom: Boolean!
openRecordIn: ViewOpenRecordIn!
openRecordIn: ViewOpenRecordIn! @deprecated(reason: "Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend.")
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
mainGroupByFieldMetadataId: UUID
@@ -3749,6 +3762,10 @@ input CreateViewInput {
isCompact: Boolean = false
shouldHideEmptyGroups: Boolean = false
kanbanColumnWidth: Int
"""
Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend.
"""
openRecordIn: ViewOpenRecordIn = SIDE_PANEL
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
@@ -3767,6 +3784,10 @@ input UpdateViewInput {
icon: String
position: Float
isCompact: Boolean
"""
Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend.
"""
openRecordIn: ViewOpenRecordIn
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
@@ -3809,6 +3830,10 @@ input UpsertViewWidgetViewSettingsInput {
type: ViewType
mainGroupByFieldMetadataId: UUID
shouldHideEmptyGroups: Boolean
"""
Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend.
"""
openRecordIn: ViewOpenRecordIn
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
@@ -4182,6 +4207,7 @@ input UpdateObjectPayload {
imageIdentifierFieldMetadataId: UUID
isLabelSyncedWithName: Boolean
isSearchable: Boolean
openRecordIn: ObjectOpenRecordIn
}
input CreateOneIndexInput {
@@ -245,6 +245,7 @@ export interface Object {
/** @deprecated Use isUIEditable */
isUIReadOnly: Scalars['Boolean']
isSearchable: Scalars['Boolean']
openRecordIn: ObjectOpenRecordIn
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -260,6 +261,8 @@ export interface Object {
__typename: 'Object'
}
export type ObjectOpenRecordIn = 'SIDE_PANEL' | 'RECORD_PAGE' | 'USER_CHOICE'
export interface FullName {
firstName: Scalars['String']
lastName: Scalars['String']
@@ -271,6 +274,7 @@ export interface WorkspaceMember {
name: FullName
userEmail: Scalars['String']
colorScheme: Scalars['String']
openRecordIn: OpenRecordIn
avatarUrl?: Scalars['String']
locale?: Scalars['String']
calendarStartDay?: Scalars['Int']
@@ -283,6 +287,8 @@ export interface WorkspaceMember {
__typename: 'WorkspaceMember'
}
export type OpenRecordIn = 'SIDE_PANEL' | 'RECORD_PAGE'
/** Date format as Month first, Day first, Year first or system as default */
export type WorkspaceMemberDateFormatEnum = 'SYSTEM' | 'MONTH_FIRST' | 'DAY_FIRST' | 'YEAR_FIRST'
@@ -551,6 +557,7 @@ export interface View {
position: Scalars['Float']
isCompact: Scalars['Boolean']
isCustom: Scalars['Boolean']
/** @deprecated Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend. */
openRecordIn: ViewOpenRecordIn
kanbanAggregateOperation?: AggregateOperations
kanbanAggregateOperationFieldMetadataId?: Scalars['UUID']
@@ -3394,6 +3401,7 @@ export interface ObjectGenqlSelection{
/** @deprecated Use isUIEditable */
isUIReadOnly?: boolean | number
isSearchable?: boolean | number
openRecordIn?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -3448,6 +3456,7 @@ export interface WorkspaceMemberGenqlSelection{
name?: FullNameGenqlSelection
userEmail?: boolean | number
colorScheme?: boolean | number
openRecordIn?: boolean | number
avatarUrl?: boolean | number
locale?: boolean | number
calendarStartDay?: boolean | number
@@ -3719,6 +3728,7 @@ export interface ViewGenqlSelection{
position?: boolean | number
isCompact?: boolean | number
isCustom?: boolean | number
/** @deprecated Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend. */
openRecordIn?: boolean | number
kanbanAggregateOperation?: boolean | number
kanbanAggregateOperationFieldMetadataId?: boolean | number
@@ -6498,9 +6508,13 @@ export interface DestroyViewFilterInput {
/** The id of the view filter to destroy. */
id: Scalars['UUID']}
export interface CreateViewInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],objectMetadataId: Scalars['UUID'],type?: (ViewType | null),key?: (ViewKey | null),icon: Scalars['String'],position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null)}
export interface CreateViewInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],objectMetadataId: Scalars['UUID'],type?: (ViewType | null),key?: (ViewKey | null),icon: Scalars['String'],position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),
/** Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend. */
openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null)}
export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),type?: (ViewType | null),icon?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),kanbanColumnWidth?: (Scalars['Int'] | null)}
export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),type?: (ViewType | null),icon?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),
/** Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend. */
openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),kanbanColumnWidth?: (Scalars['Int'] | null)}
export interface UpsertViewWidgetInput {
/** The id of the view widget (page layout widget). */
@@ -6518,7 +6532,9 @@ viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
export interface UpsertViewWidgetViewSettingsInput {
/** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */
type?: (ViewType | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null)}
type?: (ViewType | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),
/** Deprecated: Superseded by objectMetadata.openRecordIn and the workspace member preference; kept one release for API compatibility, no longer read by the frontend. */
openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null)}
export interface UpsertViewWidgetViewFieldInput {
/** The id of an existing view field to update. */
@@ -6652,7 +6668,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload,
/** The id of the object to update */
id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null),openRecordIn?: (ObjectOpenRecordIn | null)}
export interface CreateOneIndexInput {
/** The custom index to create */
@@ -9120,6 +9136,17 @@ export const enumIndexType = {
GIN: 'GIN' as const
}
export const enumObjectOpenRecordIn = {
SIDE_PANEL: 'SIDE_PANEL' as const,
RECORD_PAGE: 'RECORD_PAGE' as const,
USER_CHOICE: 'USER_CHOICE' as const
}
export const enumOpenRecordIn = {
SIDE_PANEL: 'SIDE_PANEL' as const,
RECORD_PAGE: 'RECORD_PAGE' as const
}
export const enumWorkspaceMemberDateFormatEnum = {
SYSTEM: 'SYSTEM' as const,
MONTH_FIRST: 'MONTH_FIRST' as const,
File diff suppressed because it is too large Load Diff