Files
twenty/packages/twenty-server/test/integration/graphql/suites/view/update-view.integration-spec.ts
T
Félix Malfait 23f5ba9ebf 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>
2026-06-19 15:58:13 +02:00

127 lines
4.0 KiB
TypeScript

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';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { updateOneView } from 'test/integration/metadata/suites/view/utils/update-one-view.util';
import { ViewType } from 'twenty-shared/types';
const TEST_NOT_EXISTING_VIEW_ID = '20202020-0000-4000-8000-000000000000';
describe('Update core view', () => {
let testObjectMetadataId: string;
let testSelectFieldMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'myViewTestObject',
namePlural: 'myViewTestObjects',
labelSingular: 'My View Test Object',
labelPlural: 'My View Test Objects',
icon: 'Icon123',
},
});
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
expectToFail: false,
input: {
objectMetadataId,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 0,
},
{ label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 },
{
label: 'Option 3',
value: 'OPTION_3',
color: 'green',
position: 2,
},
],
},
});
testObjectMetadataId = objectMetadataId;
testSelectFieldMetadataId = selectFieldMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should update an existing view', async () => {
const {
data: { createView: view },
} = await createOneView({
input: {
icon: '123Icon',
name: 'Original View',
type: ViewType.TABLE,
isCompact: false,
objectMetadataId: testObjectMetadataId,
},
expectToFail: false,
});
const updateInput = {
id: view.id,
name: 'Updated 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,
});
expect(errors).toBeUndefined();
expect(data.updateView).toMatchObject({
id: view.id,
name: 'Updated View',
type: ViewType.KANBAN,
isCompact: true,
kanbanColumnWidth: 250,
});
});
it('should throw error when updating non-existent view', async () => {
const { errors } = await updateOneView({
viewId: TEST_NOT_EXISTING_VIEW_ID,
input: { id: TEST_NOT_EXISTING_VIEW_ID, name: 'Non-existent View' },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});