Files
twenty/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/update-view.input.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

122 lines
3.1 KiB
TypeScript

import { Field, InputType, Int } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import {
AggregateOperations,
ViewCalendarLayout,
ViewOpenRecordIn,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { KANBAN_COLUMN_MAX_WIDTH } from 'src/engine/metadata-modules/view/constants/kanban-column-max-width.constant';
import { KANBAN_COLUMN_MIN_WIDTH } from 'src/engine/metadata-modules/view/constants/kanban-column-min-width.constant';
import { VIEW_OPEN_RECORD_IN_DEPRECATION } from 'src/engine/metadata-modules/view/constants/view-open-record-in-deprecation.constant';
// TODO: this should be refactored like for view-field.input.ts
// This is a temporary fix as we were extending the CreateViewInput class which was adding default values for the non filled fields
@InputType()
export class UpdateViewInput {
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id: string;
@IsOptional()
@IsNotEmpty()
@IsValidMetadataName()
@Field({ nullable: true })
name?: string;
@IsOptional()
@IsEnum(ViewType)
@Field(() => ViewType, { nullable: true })
type?: ViewType;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
position?: number;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isCompact?: boolean;
@IsOptional()
@IsEnum(ViewOpenRecordIn)
@Field(() => ViewOpenRecordIn, {
nullable: true,
description: `Deprecated: ${VIEW_OPEN_RECORD_IN_DEPRECATION}`,
})
openRecordIn?: ViewOpenRecordIn;
@IsOptional()
@IsEnum(AggregateOperations)
@Field(() => AggregateOperations, { nullable: true })
kanbanAggregateOperation?: AggregateOperations;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
kanbanAggregateOperationFieldMetadataId?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
anyFieldFilterValue?: string;
@IsOptional()
@IsEnum(ViewCalendarLayout)
@Field(() => ViewCalendarLayout, { nullable: true })
calendarLayout?: ViewCalendarLayout;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string | null;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
calendarEndFieldMetadataId?: string | null;
@IsOptional()
@IsEnum(ViewVisibility)
@Field(() => ViewVisibility, { nullable: true })
visibility?: ViewVisibility;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
mainGroupByFieldMetadataId?: string | null;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
shouldHideEmptyGroups?: boolean;
@IsOptional()
@IsInt()
@Min(KANBAN_COLUMN_MIN_WIDTH)
@Max(KANBAN_COLUMN_MAX_WIDTH)
@Field(() => Int, { nullable: true })
kanbanColumnWidth?: number | null;
}