Skip to content

feat: drag-and-drop column reordering in the spreadsheet layout - #2

Merged
thestumonkey merged 1 commit into
planeforkfrom
feat/spreadsheet-column-reorder
Aug 23, 2026
Merged

thestumonkey merged 1 commit into
planeforkfrom
feat/spreadsheet-column-reorder

Conversation

@thestumonkey

Copy link
Copy Markdown
Member

What

Spreadsheet columns can now be dragged left/right into any order. Header cells are both drag sources and drop targets, with a vertical accent line showing where the column will land.

The order persists per user, per project.

How it works

spreadsheetColumnsList already drove both the header row and every body row, so permuting that one array reorders the whole table in lockstep — no per-cell coordination needed.

Persistence rides on a new column_order key inside display_filters:

  • display_filters is an unvalidated JSONField (db/models/project.py:353) behind a fields = "__all__" serializer, and the filter store spreads unknown keys straight through (project/filter.store.ts:230) — no backend change or migration.
  • column_order is absent from NON_SERVER_DISPLAY_FILTERS, so a reorder triggers neither getShouldReFetchIssues nor getShouldClearIssues. Dragging a column does not refetch issues.
  • Project Views keep their existing in-memory-only display-filter behaviour, so reordering inside a shared view does not mutate it for other members.

Reconciliation

The stored order is untrusted — plain JSON, possibly written by an older build, against a column list that shifts with project settings. applyColumnOrder normalises it:

Case Behaviour
Column no longer available (cycles disabled) dropped
Column missing from stored order (modules re-enabled, or a new column ships) inserted at its canonical neighbour, not appended
Duplicate or unknown keys discarded

The canonical-neighbour choice is deliberate: appending would make a re-enabled cycle column leap to the far right even though the user never moved it. The result is always an exact permutation of the available columns — extras, gaps or repeats would desync header cells from body cells.

Not included

Sorting is unchanged. It already exists as a per-column dropdown (asc / desc / clear) writing display_filters.order_by. Multi-column sort would need backend work — apps/api/plane/utils/order_queryset.py:166 sanitises order_by to a single allowlisted field.

Testing

  • tsc --noEmit clean; oxlint clean on all touched files.
  • applyColumnOrder and reorderColumn exercised against 18 cases — permutation invariant, cycles-disabled dropping, re-enable landing after modules, corrupt/duplicate/unknown keys, every reorder direction including the no-op, and input non-mutation. All pass.

⚠️ Not yet verified in a browser. The drag has not been driven against the real table. The specific risk worth a reviewer's eye: the header is both a drag source and a CustomMenu button. CustomMenu opens on onClick (packages/ui/src/dropdowns/custom-menu.tsx:254), which does not fire after a drag, so the sort menu and the drag should not conflict — but that is reasoning, not evidence. Sticky first column and horizontal auto-scroll mid-drag are also untested.

The helper test cases were run as a throwaway script rather than committed; happy to port them into the repo's test setup if wanted.

🤖 Generated with Claude Code

Spreadsheet column order was fixed to SPREADSHEET_PROPERTY_LIST. Header
cells are now drag sources and drop targets, so columns can be dragged
left/right into any order.

The order persists per user, per project via a new column_order key on
display_filters. That field is an unvalidated JSONField behind a
fields = "__all__" serializer and the filter store spreads unknown keys
straight through, so this needs no backend change or migration. It is
also absent from NON_SERVER_DISPLAY_FILTERS, so a reorder neither
refetches nor clears issues.

spreadsheetColumnsList already drove both the header row and every body
row, so permuting that one array reorders the whole table in lockstep.

applyColumnOrder reconciles the stored order against the columns
actually available, which is necessary because the stored value is
untrusted JSON written by a possibly older build:

- columns that are no longer available are dropped (a project with
  cycles disabled has no "cycle" column)
- columns missing from the stored order are inserted at their canonical
  neighbour rather than appended, so re-enabling modules puts that
  column back where it was instead of stranding it off-screen right
- duplicates and unknown keys are discarded

The result is always an exact permutation of the available columns;
extras, gaps or repeats would desync header cells from body cells.

Sorting is unchanged - it already exists as a per-column dropdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

React Doctor found 2 new issues in 2 files · 2 warnings · score 93 / 100 (Great) · 0 fixed · vs planefork

2 warnings

core/components/issues/issue-layouts/spreadsheet/column-order.ts

  • ⚠️ L100 Array compare without length check js-length-check-first

core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx

  • ⚠️ L23 Import from a barrel file no-barrel-import

Reviewed by React Doctor for commit e4c063d. See inline comments for fixes.


// Dropping on the near edge of an adjacent column is a no-op. Return the same
// reference so callers can skip persisting an identical order.
return reordered.every((column, index) => column === columns[index]) ? columns : reordered;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-length-check-first (warning)

This is slow because .every() compares two arrays item by item, so check a.length === b.length first to bail out immediately when sizes differ

Fix → Check a.length === b.length && a.every((x, i) => x === b[i]) so arrays of different sizes stop right away

Docs

@@ -20,6 +21,8 @@ import { useBulkOperationStatus } from "@/hooks/use-bulk-operation-status";
// local imports
import type { TRenderQuickActions } from "../list/list-view-types";
import { QuickAddIssueRoot, SpreadsheetAddIssueButton } from "../quick-add";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-barrel-import (warning)

This ships extra code to your users & slows page load. Import directly from: "../quick-add/root", "../quick-add/button/spreadsheet".

Fix → Import from the direct path: import { Button } from './components/Button' instead of ./components

Docs

@thestumonkey
thestumonkey merged commit 38d9c20 into planefork Aug 23, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant