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
@@ -869,6 +869,7 @@ type View {
kanbanAggregateOperationFieldMetadataId: UUID
mainGroupByFieldMetadataId: UUID
shouldHideEmptyGroups: Boolean!
kanbanColumnWidth: Int
calendarFieldMetadataId: UUID
workspaceId: UUID!
anyFieldFilterValue: String
@@ -3575,6 +3576,7 @@ input CreateViewInput {
position: Float = 0
isCompact: Boolean = false
shouldHideEmptyGroups: Boolean = false
kanbanColumnWidth: Int
openRecordIn: ViewOpenRecordIn = SIDE_PANEL
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
@@ -3601,6 +3603,7 @@ input UpdateViewInput {
visibility: ViewVisibility
mainGroupByFieldMetadataId: UUID
shouldHideEmptyGroups: Boolean
kanbanColumnWidth: Int
}
input UpsertViewWidgetInput {
@@ -602,6 +602,7 @@ export interface View {
kanbanAggregateOperationFieldMetadataId?: Scalars['UUID']
mainGroupByFieldMetadataId?: Scalars['UUID']
shouldHideEmptyGroups: Scalars['Boolean']
kanbanColumnWidth?: Scalars['Int']
calendarFieldMetadataId?: Scalars['UUID']
workspaceId: Scalars['UUID']
anyFieldFilterValue?: Scalars['String']
@@ -3631,6 +3632,7 @@ export interface ViewGenqlSelection{
kanbanAggregateOperationFieldMetadataId?: boolean | number
mainGroupByFieldMetadataId?: boolean | number
shouldHideEmptyGroups?: boolean | number
kanbanColumnWidth?: boolean | number
calendarFieldMetadataId?: boolean | number
workspaceId?: boolean | number
anyFieldFilterValue?: boolean | number
@@ -6202,9 +6204,9 @@ 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),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (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),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (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),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | 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),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). */
@@ -1712,6 +1712,9 @@ export default {
"shouldHideEmptyGroups": [
6
],
"kanbanColumnWidth": [
21
],
"calendarFieldMetadataId": [
3
],
@@ -9352,6 +9355,9 @@ export default {
"shouldHideEmptyGroups": [
6
],
"kanbanColumnWidth": [
21
],
"openRecordIn": [
73
],
@@ -9426,6 +9432,9 @@ export default {
"shouldHideEmptyGroups": [
6
],
"kanbanColumnWidth": [
21
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -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}
/>
)
);
@@ -28,6 +28,7 @@ export const VIEW_FRAGMENT = gql`
kanbanAggregateOperationFieldMetadataId
mainGroupByFieldMetadataId
shouldHideEmptyGroups
kanbanColumnWidth
anyFieldFilterValue
calendarFieldMetadataId
calendarLayout
@@ -122,6 +122,9 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperationFieldMetadataId
: undefined,
kanbanColumnWidth: shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanColumnWidth
: undefined,
mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate
? sourceView.mainGroupByFieldMetadataId
: mainGroupByFieldMetadataId,
@@ -0,0 +1,38 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { usePerformViewAPIUpdate } from '@/views/hooks/internal/usePerformViewAPIUpdate';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useUpdateViewKanbanColumnWidth = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
const { performViewAPIUpdate } = usePerformViewAPIUpdate();
const updateViewKanbanColumnWidth = useCallback(
async (kanbanColumnWidth: number) => {
if (!canPersistChanges) {
return;
}
if (!isDefined(contextStoreCurrentViewId)) {
return;
}
await performViewAPIUpdate({
id: contextStoreCurrentViewId,
input: {
kanbanColumnWidth,
},
});
},
[canPersistChanges, contextStoreCurrentViewId, performViewAPIUpdate],
);
return {
updateViewKanbanColumnWidth,
};
};
@@ -19,6 +19,7 @@ export type GraphQLView = {
key?: ViewKey | null;
mainGroupByFieldMetadataId?: string | null;
shouldHideEmptyGroups: boolean;
kanbanColumnWidth?: number | null;
kanbanAggregateOperation?: AggregateOperations | null;
kanbanAggregateOperationFieldMetadataId?: string | null;
objectMetadataId: string;
@@ -30,6 +30,7 @@ export type View = {
kanbanAggregateOperationFieldMetadataId?: string | null;
mainGroupByFieldMetadataId?: string | null;
shouldHideEmptyGroups: boolean;
kanbanColumnWidth?: number | null;
calendarFieldMetadataId?: string | null;
calendarLayout?: ViewCalendarLayout | null;
position: number;
@@ -36,6 +36,9 @@ export const convertUpdateViewInputToGql = (
...(isDefined(view.shouldHideEmptyGroups) && {
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
}),
...(view.kanbanColumnWidth !== undefined && {
kanbanColumnWidth: view.kanbanColumnWidth,
}),
...(view.mainGroupByFieldMetadataId !== undefined && {
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId,
}),
@@ -0,0 +1,21 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.15.0', 1781900000000)
export class AddViewKanbanColumnWidthFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" ADD COLUMN IF NOT EXISTS "kanbanColumnWidth" integer`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" DROP COLUMN IF EXISTS "kanbanColumnWidth"`,
);
}
}
@@ -0,0 +1,2 @@
export const ADD_VIEW_KANBAN_COLUMN_WIDTH_UPGRADE_COMMAND_NAME =
'2.15.0_AddViewKanbanColumnWidthFastInstanceCommand_1781900000000';
@@ -74,6 +74,7 @@ import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/data
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
import { AddHasPaymentMethodToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-fast-1781280240009-add-has-payment-method-to-billing-customer';
import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781714499016-add-folder-import-to-message-folder-pending-sync-action';
import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -150,4 +151,5 @@ export const INSTANCE_COMMANDS = [
AddIsSystemSideEffectFastInstanceCommand,
BackfillConnectionSecuritySlowInstanceCommand,
AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand,
AddViewKanbanColumnWidthFastInstanceCommand,
];
@@ -39,6 +39,7 @@ export const fromViewManifestToUniversalFlatView = ({
mainGroupByFieldMetadataUniversalIdentifier:
viewManifest.mainGroupByFieldMetadataUniversalIdentifier ?? null,
shouldHideEmptyGroups: viewManifest.shouldHideEmptyGroups ?? false,
kanbanColumnWidth: viewManifest.kanbanColumnWidth ?? null,
anyFieldFilterValue: null,
createdByUserWorkspaceId: null,
isActive: true,
@@ -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;
@@ -137,6 +137,7 @@ export const createStandardViewFlatMetadata = <
kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId,
shouldHideEmptyGroups: false,
kanbanColumnWidth: null,
calendarLayout: null,
calendarFieldMetadataId,
anyFieldFilterValue: null,
@@ -62,6 +62,7 @@ type Assertions = [
| 'calendarFieldMetadataUniversalIdentifier'
| 'mainGroupByFieldMetadataUniversalIdentifier'
| 'shouldHideEmptyGroups'
| 'kanbanColumnWidth'
| 'anyFieldFilterValue'
| 'visibility'
| 'createdByUserWorkspaceId'
@@ -1,3 +1,4 @@
import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
@@ -92,11 +93,14 @@ describe('Update core view', () => {
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: testSelectFieldMetadataId,
isCompact: true,
kanbanColumnWidth: 250,
};
const { data, errors } = await updateOneView({
viewId: view.id,
input: updateInput,
gqlFields: `${VIEW_GQL_FIELDS}
kanbanColumnWidth`,
expectToFail: false,
});
@@ -106,6 +110,7 @@ describe('Update core view', () => {
name: 'Updated View',
type: ViewType.KANBAN,
isCompact: true,
kanbanColumnWidth: 250,
});
});
@@ -75,6 +75,7 @@ export type ViewManifest = SyncableEntityOptions & {
openRecordIn?: ViewOpenRecordIn;
mainGroupByFieldMetadataUniversalIdentifier?: string;
shouldHideEmptyGroups?: boolean;
kanbanColumnWidth?: number | null;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldMetadataUniversalIdentifier?: string;
calendarLayout?: ViewCalendarLayout;