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
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { useContext, useRef } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { RecordBoardColumnWidthEffect } from '@/object-record/record-board/components/RecordBoardColumnWidthEffect';
import { RecordBoardColumns } from '@/object-record/record-board/components/RecordBoardColumns';
import { RecordBoardDragDropContext } from '@/object-record/record-board/components/RecordBoardDragDropContext';
import { RecordBoardDragSelect } from '@/object-record/record-board/components/RecordBoardDragSelect';
@@ -12,6 +13,8 @@ import { RecordBoardHeader } from '@/object-record/record-board/components/Recor
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { getRecordBoardHtmlId } from '@/object-record/record-board/utils/getRecordBoardHtmlId';
const StyledContainer = styled.div`
display: flex;
flex: 1;
@@ -43,7 +46,8 @@ export const RecordBoard = () => {
componentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
>
<RecordBoardEffects />
<StyledContainerContainer>
<RecordBoardColumnWidthEffect />
<StyledContainerContainer id={getRecordBoardHtmlId(recordBoardId)}>
<RecordBoardHeader />
<StyledBoardContentContainer>
<StyledContainer ref={boardRef}>
@@ -0,0 +1,23 @@
import { useContext, useLayoutEffect } from 'react';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { setRecordBoardColumnWidthCssVariable } from '@/object-record/record-board/utils/setRecordBoardColumnWidthCssVariable';
import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const RecordBoardColumnWidthEffect = () => {
const { recordBoardId } = useContext(RecordBoardContext);
const recordIndexKanbanColumnWidth = useAtomComponentStateValue(
recordIndexKanbanColumnWidthComponentState,
);
useLayoutEffect(() => {
setRecordBoardColumnWidthCssVariable(
recordBoardId,
recordIndexKanbanColumnWidth,
);
}, [recordBoardId, recordIndexKanbanColumnWidth]);
return null;
};
@@ -5,7 +5,7 @@ import { useInView } from 'react-intersection-observer';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState';
import { RECORD_BOARD_QUERY_PAGE_SIZE } from '@/object-record/record-board/constants/RecordBoardQueryPageSize';
import { recordBoardShouldFetchMoreComponentState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreComponentState';
import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState';
@@ -69,8 +69,12 @@ export const RecordBoardFetchMoreInViewTriggerComponent = () => {
ViewType.KANBAN,
);
const recordIndexKanbanColumnWidth = useAtomComponentStateValue(
recordIndexKanbanColumnWidthComponentState,
);
const componentWidth =
visibleRecordGroupIds.length * RECORD_BOARD_COLUMN_WIDTH +
visibleRecordGroupIds.length * recordIndexKanbanColumnWidth +
visibleRecordGroupIds.length *
RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH -
1;
@@ -0,0 +1 @@
export const RECORD_BOARD_COLUMN_MAX_WIDTH = 400;
@@ -0,0 +1 @@
export const RECORD_BOARD_COLUMN_MIN_WIDTH = 150;
@@ -0,0 +1,2 @@
export const RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME =
'--record-board-column-width';
@@ -2,6 +2,8 @@ import { styled } from '@linaria/react';
import { Droppable } from '@hello-pangea/dnd';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/record-board/constants/RecordBoardColumnWidthCssVariableName';
import { RecordBoardColumnCardsContainer } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup';
@@ -16,8 +18,14 @@ const StyledColumn = styled.div`
background-color: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
max-width: 200px;
min-width: 200px;
max-width: var(
${RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME},
${RECORD_BOARD_COLUMN_WIDTH}px
);
min-width: var(
${RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME},
${RECORD_BOARD_COLUMN_WIDTH}px
);
padding: ${themeCssVariables.spacing[2]};
padding-top: 0px;
position: relative;
@@ -8,6 +8,8 @@ import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/reco
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/record-board/constants/RecordBoardColumnWidthCssVariableName';
import { RecordBoardColumnResizeHandler } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnResizeHandler';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
@@ -61,8 +63,14 @@ const StyledColumn = styled.div`
background-color: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
max-width: ${RECORD_BOARD_COLUMN_WIDTH}px;
min-width: ${RECORD_BOARD_COLUMN_WIDTH}px;
max-width: var(
${RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME},
${RECORD_BOARD_COLUMN_WIDTH}px
);
min-width: var(
${RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME},
${RECORD_BOARD_COLUMN_WIDTH}px
);
padding: ${themeCssVariables.spacing[2]};
@@ -202,6 +210,7 @@ export const RecordBoardColumnHeader = () => {
</StyledRightContainer>
</StyledHeaderContainer>
</StyledHeader>
<RecordBoardColumnResizeHandler />
</StyledColumn>
);
};
@@ -0,0 +1,22 @@
import { useIsMobile } from 'twenty-ui/utilities';
import { useResizeBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useResizeBoardColumn';
import { RecordColumnResizeHandle } from '@/object-record/record-index/components/RecordColumnResizeHandle';
export const RecordBoardColumnResizeHandler = () => {
const isMobile = useIsMobile();
const { isResizing, handleResizeStart } = useResizeBoardColumn();
if (isMobile) {
return null;
}
return (
<RecordColumnResizeHandle
isResizing={isResizing}
position="right"
onPointerDown={handleResizeStart}
/>
);
};
@@ -0,0 +1,114 @@
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useContext,
useState,
} from 'react';
import { isDefined } from 'twenty-shared/utils';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { clampRecordBoardColumnWidth } from '@/object-record/record-board/utils/clampRecordBoardColumnWidth';
import { setRecordBoardColumnWidthCssVariable } from '@/object-record/record-board/utils/setRecordBoardColumnWidthCssVariable';
import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { type PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useUpdateViewKanbanColumnWidth } from '@/views/hooks/useUpdateViewKanbanColumnWidth';
export const useResizeBoardColumn = () => {
const { recordBoardId } = useContext(RecordBoardContext);
const [recordIndexKanbanColumnWidth, setRecordIndexKanbanColumnWidth] =
useAtomComponentState(recordIndexKanbanColumnWidthComponentState);
const { updateViewKanbanColumnWidth } = useUpdateViewKanbanColumnWidth();
const { setDragSelectionStartEnabled } = useDragSelect();
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
>(null);
const isResizing = isDefined(initialPointerPositionX);
const handleResizeStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') {
return;
}
setDragSelectionStartEnabled(false);
setInitialPointerPositionX(event.clientX);
},
[setDragSelectionStartEnabled],
);
const handleResizeMove = useCallback<PointerEventListener>(
({ x, event }) => {
if (!isDefined(initialPointerPositionX)) {
return;
}
if ('buttons' in event && event.buttons === 0) {
setInitialPointerPositionX(null);
setDragSelectionStartEnabled(true);
setRecordBoardColumnWidthCssVariable(
recordBoardId,
recordIndexKanbanColumnWidth,
);
return;
}
setRecordBoardColumnWidthCssVariable(
recordBoardId,
clampRecordBoardColumnWidth(
recordIndexKanbanColumnWidth + (x - initialPointerPositionX),
),
);
},
[
initialPointerPositionX,
recordIndexKanbanColumnWidth,
recordBoardId,
setDragSelectionStartEnabled,
],
);
const handleResizeEnd = useCallback<PointerEventListener>(
({ x }) => {
if (!isDefined(initialPointerPositionX)) {
return;
}
setInitialPointerPositionX(null);
setDragSelectionStartEnabled(true);
const nextWidth = Math.round(
clampRecordBoardColumnWidth(
recordIndexKanbanColumnWidth + (x - initialPointerPositionX),
),
);
if (nextWidth !== recordIndexKanbanColumnWidth) {
setRecordIndexKanbanColumnWidth(nextWidth);
updateViewKanbanColumnWidth(nextWidth);
}
},
[
initialPointerPositionX,
recordIndexKanbanColumnWidth,
setRecordIndexKanbanColumnWidth,
updateViewKanbanColumnWidth,
setDragSelectionStartEnabled,
],
);
useTrackPointer({
shouldTrackPointer: isResizing,
onMouseMove: handleResizeMove,
onMouseUp: handleResizeEnd,
});
return { isResizing, handleResizeStart };
};
@@ -0,0 +1,8 @@
import { RECORD_BOARD_COLUMN_MAX_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnMaxWidth';
import { RECORD_BOARD_COLUMN_MIN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnMinWidth';
export const clampRecordBoardColumnWidth = (width: number): number =>
Math.min(
RECORD_BOARD_COLUMN_MAX_WIDTH,
Math.max(RECORD_BOARD_COLUMN_MIN_WIDTH, width),
);
@@ -0,0 +1,2 @@
export const getRecordBoardHtmlId = (recordBoardId: string): string =>
`record-board-${recordBoardId}`;
@@ -0,0 +1,14 @@
import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/record-board/constants/RecordBoardColumnWidthCssVariableName';
import { getRecordBoardHtmlId } from '@/object-record/record-board/utils/getRecordBoardHtmlId';
export const setRecordBoardColumnWidthCssVariable = (
recordBoardId: string,
widthInPixels: number,
) => {
document
.getElementById(getRecordBoardHtmlId(recordBoardId))
?.style.setProperty(
RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME,
`${widthInPixels}px`,
);
};
@@ -0,0 +1,50 @@
import { styled } from '@linaria/react';
import { type PointerEvent as ReactPointerEvent } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRecordColumnResizeHandle = styled.div<{
isResizing: boolean;
position: 'left' | 'right';
}>`
bottom: 0;
cursor: col-resize;
left: ${({ position }) => (position === 'left' ? '-1px' : 'auto')};
position: absolute;
right: ${({ position }) => (position === 'right' ? '-1px' : 'auto')};
top: 0;
width: 10px;
z-index: 1;
&:after {
background-color: ${themeCssVariables.color.blue};
bottom: 0;
content: '';
display: ${({ isResizing }) => (isResizing ? 'block' : 'none')};
left: ${({ position }) => (position === 'left' ? '-1px' : 'auto')};
position: absolute;
right: ${({ position }) => (position === 'right' ? '-1px' : 'auto')};
top: 0;
width: 2px;
}
`;
type RecordColumnResizeHandleProps = {
isResizing: boolean;
position: 'left' | 'right';
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
};
export const RecordColumnResizeHandle = ({
isResizing,
position,
onPointerDown,
}: RecordColumnResizeHandleProps) => (
<StyledRecordColumnResizeHandle
className="cursor-col-resize"
role="separator"
aria-orientation="vertical"
isResizing={isResizing}
position={position}
onPointerDown={onPointerDown}
/>
);
@@ -15,10 +15,13 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { clampRecordBoardColumnWidth } from '@/object-record/record-board/utils/clampRecordBoardColumnWidth';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState';
import { recordIndexShouldHideEmptyRecordGroupsComponentState } from '@/object-record/record-index/states/recordIndexShouldHideEmptyRecordGroupsComponentState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
@@ -68,6 +71,10 @@ export const useLoadRecordIndexStates = () => {
recordIndexShouldHideEmptyRecordGroupsComponentState,
);
const recordIndexKanbanColumnWidthAtom = useAtomComponentStateCallbackState(
recordIndexKanbanColumnWidthComponentState,
);
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
@@ -318,6 +325,13 @@ export const useLoadRecordIndexStates = () => {
view.shouldHideEmptyGroups,
);
batchSet(
recordIndexKanbanColumnWidthAtom,
clampRecordBoardColumnWidth(
view.kanbanColumnWidth ?? RECORD_BOARD_COLUMN_WIDTH,
),
);
if (isDefined(recordIndexGroupFieldMetadataItemValue)) {
batchSet(
recordIndexGroupFieldMetadataItemAtom,
@@ -355,6 +369,7 @@ export const useLoadRecordIndexStates = () => {
recordIndexGroupAggregateOperationAtom,
recordIndexGroupAggregateFieldMetadataItemAtom,
recordIndexShouldHideEmptyRecordGroupsAtom,
recordIndexKanbanColumnWidthAtom,
getFieldMetadataItemByIdOrThrow,
setRecordGroupsFromViewGroups,
syncRecordIndexViewFields,
@@ -0,0 +1,10 @@
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const recordIndexKanbanColumnWidthComponentState =
createAtomComponentState<number>({
key: 'recordIndexKanbanColumnWidthComponentState',
defaultValue: RECORD_BOARD_COLUMN_WIDTH,
componentInstanceContext: ViewComponentInstanceContext,
});
@@ -1,38 +1,11 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { RecordColumnResizeHandle } from '@/object-record/record-index/components/RecordColumnResizeHandle';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState';
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledResizeHandler = styled.div<{
isResizing: boolean;
position: 'left' | 'right';
}>`
bottom: 0;
cursor: col-resize;
left: ${({ position }) => (position === 'left' ? '-1px' : 'auto')};
position: absolute;
right: ${({ position }) => (position === 'right' ? '-1px' : 'auto')};
top: 0;
width: 10px;
z-index: 1;
&:after {
background-color: ${themeCssVariables.color.blue};
bottom: 0;
content: '';
display: ${({ isResizing }) => (isResizing ? 'block' : 'none')};
left: ${({ position }) => (position === 'left' ? '-1px' : 'auto')};
position: absolute;
right: ${({ position }) => (position === 'right' ? '-1px' : 'auto')};
top: 0;
width: 2px;
}
`;
export const RecordTableHeaderResizeHandler = ({
recordFieldIndex,
position,
@@ -66,12 +39,10 @@ export const RecordTableHeaderResizeHandler = ({
return (
!columnResizeDisabled && (
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={handlePointerDown}
<RecordColumnResizeHandle
isResizing={isResizing}
position={position}
onPointerDown={handlePointerDown}
/>
)
);