23f5ba9ebf
## 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>
127 lines
4.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|