feat: add resizable kanban column width (#21828)

## What & why

Lets users resize the columns of a Kanban (record board) view. Requested
by a user; the design avoids the "ragged board" problem by making the
width a **single shared value**.

## Behaviour

- A drag handle appears on the right edge of every column header.
- Because all columns read **one** width value, dragging any handle
resizes **every** column together — they can never end up mismatched.
- Width is clamped between **150px** and **400px** (default **200px**).
- The width is **persisted per view** and restored on reload.

## Approach

**Backend** — a new nullable `View.kanbanColumnWidth` field, threaded
through the existing view-level setting pattern (the same one
`kanbanAggregateOperation` / `shouldHideEmptyGroups` use), so it gets
create/update/manifest/override support for free:
- entity column + `ViewOverrides` + `@WasIntroducedInUpgrade`
- `CreateViewInput` / `UpdateViewInput` (`Int`, `@Min(150)`/`@Max(400)`)
+ `ViewDTO`
- flat-view editable properties, entity-properties config, compare-type,
standard-view + manifest converters
- a fast instance command adding the `core.view` column

**Frontend** — the value hydrates into a view-scoped atom and drives a
single CSS variable set on the board container, which both column
headers and bodies read. Live dragging only writes that CSS variable (no
per-move React re-render); the final width is committed to the atom and
persisted via `updateView` on pointer-up.

## Nullability / defaults

`kanbanColumnWidth` is nullable — `null` means "never resized" and the
UI falls back to the 200px default, so existing rows need no backfill.

## Validation

- `nx typecheck twenty-server`  and `nx typecheck twenty-front` 
- `nx lint:diff-with-main twenty-server` ; frontend lint fixes applied
(split constants to one-per-file, removed `useRef`-for-state in favour
of `useState`).
- Draft pending a final green CI run (the dev container reclaimed
`node_modules` mid-session; re-running locally).

## Test plan

- [ ] Drag a kanban column edge → all columns resize together, clamped
150–400px
- [ ] Reload → width persists for that view; other views unaffected
- [ ] A view that was never resized still renders at 200px

https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE

---
_Generated by [Claude
Code](https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21828?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-19 15:58:13 +02:00
committed by GitHub
parent 1f6c2b89fd
commit 23f5ba9ebf
47 changed files with 468 additions and 63 deletions
@@ -333,6 +333,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"visibility",
"mainGroupByFieldMetadataUniversalIdentifier",
"shouldHideEmptyGroups",
"kanbanColumnWidth",
"isActive",
"universalOverrides",
],
@@ -376,6 +376,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
isOverridable: true,
},
kanbanColumnWidth: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
isActive: {
toCompare: true,
toStringify: false,
@@ -15,4 +15,5 @@ export const FLAT_VIEW_EDITABLE_PROPERTIES = [
'visibility',
'mainGroupByFieldMetadataId',
'shouldHideEmptyGroups',
'kanbanColumnWidth',
] as const satisfies MetadataEntityPropertyName<'view'>[];
@@ -76,6 +76,7 @@ export const fromCreateViewInputToFlatViewToCreate = ({
icon: createViewInput.icon,
isCompact: createViewInput.isCompact ?? false,
shouldHideEmptyGroups: createViewInput.shouldHideEmptyGroups ?? false,
kanbanColumnWidth: createViewInput.kanbanColumnWidth ?? null,
kanbanAggregateOperation: createViewInput.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier,
mainGroupByFieldMetadataUniversalIdentifier,
@@ -692,6 +692,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
calendarLayout: null,
isCompact: false,
shouldHideEmptyGroups: false,
kanbanColumnWidth: null,
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier: null,
mainGroupByFieldMetadataUniversalIdentifier: null,
@@ -34,6 +34,7 @@ export const computeFlatRecordPageFieldsViewToCreate = ({
calendarLayout: null,
isCompact: false,
shouldHideEmptyGroups: false,
kanbanColumnWidth: null,
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier: null,
mainGroupByFieldMetadataUniversalIdentifier: null,
@@ -0,0 +1 @@
export const KANBAN_COLUMN_MAX_WIDTH = 400;
@@ -0,0 +1 @@
export const KANBAN_COLUMN_MIN_WIDTH = 150;
@@ -1,13 +1,16 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { Field, HideField, InputType, Int } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import {
AggregateOperations,
@@ -20,6 +23,8 @@ import {
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';
@InputType()
export class CreateViewInput {
@@ -66,6 +71,13 @@ export class CreateViewInput {
@Field({ nullable: true, defaultValue: false })
shouldHideEmptyGroups?: boolean;
@IsOptional()
@IsInt()
@Min(KANBAN_COLUMN_MIN_WIDTH)
@Max(KANBAN_COLUMN_MAX_WIDTH)
@Field(() => Int, { nullable: true })
kanbanColumnWidth?: number;
@IsOptional()
@IsEnum(ViewOpenRecordIn)
@Field(() => ViewOpenRecordIn, {
@@ -1,12 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { Field, InputType, Int } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import {
AggregateOperations,
@@ -18,6 +21,8 @@ import {
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';
// 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
@@ -99,4 +104,11 @@ export class UpdateViewInput {
@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;
}
@@ -1,6 +1,7 @@
import {
Field,
HideField,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
@@ -77,6 +78,9 @@ export class ViewDTO {
@Field({ nullable: false, defaultValue: false })
shouldHideEmptyGroups: boolean;
@Field(() => Int, { nullable: true })
kanbanColumnWidth?: number | null;
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string | null;
@@ -25,6 +25,7 @@ import {
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/is-system-side-effect-upgrade-command-name.constant';
import { ADD_VIEW_KANBAN_COLUMN_WIDTH_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/add-view-kanban-column-width-upgrade-command-name.constant';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
@@ -50,6 +51,7 @@ export type ViewOverrides = {
visibility?: ViewVisibility;
mainGroupByFieldMetadataId?: SerializedRelation | null;
shouldHideEmptyGroups?: boolean;
kanbanColumnWidth?: number | null;
};
// We could refactor this type to be dynamic to view type
@@ -185,6 +187,12 @@ export class ViewEntity
@Column({ nullable: false, default: false, type: 'boolean' })
shouldHideEmptyGroups: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName: ADD_VIEW_KANBAN_COLUMN_WIDTH_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: true, type: 'int', default: null })
kanbanColumnWidth: number | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;