From e4c063d5f348f8c11b0ccb2b0331450d69f1c739 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sun, 23 Aug 2026 13:15:19 +0100 Subject: [PATCH] feat: drag-and-drop column reordering in the spreadsheet layout 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 --- .../issue-layouts/spreadsheet/column-order.ts | 101 ++++++++++++++++++ .../spreadsheet/spreadsheet-header-column.tsx | 72 ++++++++++++- .../spreadsheet/spreadsheet-header.tsx | 9 ++ .../spreadsheet/spreadsheet-table.tsx | 9 ++ .../spreadsheet/spreadsheet-view.tsx | 38 +++++-- packages/types/src/view-props.ts | 6 ++ 6 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 apps/web/core/components/issues/issue-layouts/spreadsheet/column-order.ts diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/column-order.ts b/apps/web/core/components/issues/issue-layouts/spreadsheet/column-order.ts new file mode 100644 index 000000000000..0c307ee0160d --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/column-order.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +// plane imports +import type { IIssueDisplayProperties } from "@plane/types"; + +export type TSpreadsheetColumnKey = keyof IIssueDisplayProperties; + +/** Key used to identify a spreadsheet header column in a pragmatic-dnd payload. */ +export const SPREADSHEET_COLUMN_DRAG_TYPE = "SPREADSHEET_COLUMN"; + +export type TSpreadsheetColumnDragData = { + type: typeof SPREADSHEET_COLUMN_DRAG_TYPE; + property: TSpreadsheetColumnKey; +}; + +export const isSpreadsheetColumnDragData = (data: unknown): data is TSpreadsheetColumnDragData => + typeof data === "object" && + data !== null && + (data as TSpreadsheetColumnDragData).type === SPREADSHEET_COLUMN_DRAG_TYPE; + +/** + * Reconciles the user's persisted column order against the columns actually + * available right now. + * + * The persisted order is untrusted: it is a plain array inside a JSON blob that + * was written by a possibly older build of the app, and the available column + * list changes with project settings (a project with cycles disabled has no + * "cycle" column) and with Plane upgrades that introduce new columns. + * + * @param persistedOrder the user's saved order, possibly stale/partial/undefined + * @param availableColumns the columns to render, in Plane's canonical default order + * @returns every entry of `availableColumns` exactly once, in the order to render + */ +export const applyColumnOrder = ( + persistedOrder: TSpreadsheetColumnKey[] | undefined, + availableColumns: TSpreadsheetColumnKey[] +): TSpreadsheetColumnKey[] => { + if (!persistedOrder?.length) return availableColumns; + + const available = new Set(availableColumns); + // Drop anything no longer available (e.g. "cycle" after cycles are disabled) and + // any duplicate, both of which would desync the header row from the body rows. + const ordered = persistedOrder.filter( + (column, index) => available.has(column) && persistedOrder.indexOf(column) === index + ); + const placed = new Set(ordered); + + // Slot every column the saved order does not know about next to the canonical + // neighbour it sits behind by default, so a newly available column turns up where + // the default layout puts it rather than stranded off-screen at the far right. + availableColumns.forEach((column, canonicalIndex) => { + if (placed.has(column)) return; + const precedingSibling = availableColumns + .slice(0, canonicalIndex) + .reverse() + .find((sibling) => placed.has(sibling)); + ordered.splice(precedingSibling ? ordered.indexOf(precedingSibling) + 1 : 0, 0, column); + placed.add(column); + }); + + return ordered; +}; + +/** + * Moves `sourceProperty` to sit immediately before or after `destinationProperty`. + * + * @param columns current left-to-right order + * @param sourceProperty the column being dragged + * @param destinationProperty the column it was dropped onto + * @param edge which side of the destination it was dropped on + * @returns a new array; the input is never mutated + */ +export const reorderColumn = ( + columns: TSpreadsheetColumnKey[], + sourceProperty: TSpreadsheetColumnKey, + destinationProperty: TSpreadsheetColumnKey, + edge: Edge | null +): TSpreadsheetColumnKey[] => { + if (sourceProperty === destinationProperty) return columns; + + const sourceIndex = columns.indexOf(sourceProperty); + const destinationIndex = columns.indexOf(destinationProperty); + if (sourceIndex === -1 || destinationIndex === -1) return columns; + + const withoutSource = columns.filter((column) => column !== sourceProperty); + // Recompute against the shortened array so removing the source from the left + // of the destination does not shift the insertion point. + const anchorIndex = withoutSource.indexOf(destinationProperty); + const insertAt = edge === "right" ? anchorIndex + 1 : anchorIndex; + + const reordered = [...withoutSource.slice(0, insertAt), sourceProperty, ...withoutSource.slice(insertAt)]; + + // 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; +}; diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx index 99576d60b669..7404b7f74e7b 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx @@ -4,13 +4,20 @@ * See the LICENSE file for details. */ -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; //types import { observer } from "mobx-react"; import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { cn } from "@plane/utils"; //components import { shouldRenderColumn } from "@/helpers/issue-filter.helper"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import type { TSpreadsheetColumnDragData, TSpreadsheetColumnKey } from "./column-order"; +import { SPREADSHEET_COLUMN_DRAG_TYPE, isSpreadsheetColumnDragData } from "./column-order"; import { HeaderColumn } from "./columns/header-column"; interface Props { @@ -19,15 +26,59 @@ interface Props { isEstimateEnabled: boolean; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; + onColumnReorder?: ( + sourceProperty: TSpreadsheetColumnKey, + destinationProperty: TSpreadsheetColumnKey, + edge: Edge | null + ) => void; isEpic?: boolean; } export const SpreadsheetHeaderColumn = observer(function SpreadsheetHeaderColumn(props: Props) { - const { displayProperties, displayFilters, property, handleDisplayFilterUpdate, isEpic = false } = props; + const { + displayProperties, + displayFilters, + property, + handleDisplayFilterUpdate, + onColumnReorder, + isEpic = false, + } = props; //hooks const tableHeaderCellRef = useRef(null); + // states + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); const shouldRenderProperty = shouldRenderColumn(property); + const isReorderable = !!onColumnReorder; + + useEffect(() => { + const element = tableHeaderCellRef.current; + if (!element || !isReorderable) return; + + const dragData: TSpreadsheetColumnDragData = { type: SPREADSHEET_COLUMN_DRAG_TYPE, property }; + + return combine( + draggable({ + element, + getInitialData: () => dragData, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => isSpreadsheetColumnDragData(source.data) && source.data.property !== property, + getData: ({ input }) => attachClosestEdge(dragData, { input, element, allowedEdges: ["left", "right"] }), + onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDragLeave: () => setClosestEdge(null), + onDrop: ({ self, source }) => { + setClosestEdge(null); + if (!isSpreadsheetColumnDragData(source.data)) return; + onColumnReorder(source.data.property, property, extractClosestEdge(self.data)); + }, + }) + ); + }, [property, isReorderable, onColumnReorder]); return ( shouldRenderProperty} > @@ -49,6 +106,15 @@ export const SpreadsheetHeaderColumn = observer(function SpreadsheetHeaderColumn }} isEpic={isEpic} /> + {closestEdge && ( + + )} ); diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index c9e2bee54318..7ece107825ae 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -4,6 +4,7 @@ * See the LICENSE file for details. */ +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // constants @@ -15,6 +16,7 @@ import { cn } from "@plane/utils"; import { MultipleSelectGroupAction } from "@/components/core/multiple-select"; // hooks import type { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSpreadsheetColumnKey } from "./column-order"; import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column"; interface Props { @@ -24,6 +26,11 @@ interface Props { canEditProperties: (projectId: string | undefined) => boolean; isEstimateEnabled: boolean; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; + onColumnReorder?: ( + sourceProperty: TSpreadsheetColumnKey, + destinationProperty: TSpreadsheetColumnKey, + edge: Edge | null + ) => void; selectionHelpers: TSelectionHelper; isEpic?: boolean; } @@ -36,6 +43,7 @@ export const SpreadsheetHeader = observer(function SpreadsheetHeader(props: Prop canEditProperties, isEstimateEnabled, spreadsheetColumnsList, + onColumnReorder, selectionHelpers, isEpic = false, } = props; @@ -84,6 +92,7 @@ export const SpreadsheetHeader = observer(function SpreadsheetHeader(props: Prop displayFilters={displayFilters} handleDisplayFilterUpdate={handleDisplayFilterUpdate} isEstimateEnabled={isEstimateEnabled} + onColumnReorder={onColumnReorder} isEpic={isEpic} /> ))} diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 799eb9b71ed7..3be2a0026de2 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -6,6 +6,7 @@ import type { MutableRefObject } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { observer } from "mobx-react"; // plane imports import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; @@ -19,6 +20,7 @@ import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigatio // local imports import type { TRenderQuickActions } from "../list/list-view-types"; import { getDisplayPropertiesCount } from "../utils"; +import type { TSpreadsheetColumnKey } from "./column-order"; import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; @@ -36,6 +38,11 @@ type Props = { canLoadMoreIssues: boolean; loadMoreIssues: () => void; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; + onColumnReorder?: ( + sourceProperty: TSpreadsheetColumnKey, + destinationProperty: TSpreadsheetColumnKey, + edge: Edge | null + ) => void; selectionHelpers: TSelectionHelper; isEpic?: boolean; }; @@ -55,6 +62,7 @@ export const SpreadsheetTable = observer(function SpreadsheetTable(props: Props) containerRef, loadMoreIssues, spreadsheetColumnsList, + onColumnReorder, selectionHelpers, isEpic = false, } = props; @@ -119,6 +127,7 @@ export const SpreadsheetTable = observer(function SpreadsheetTable(props: Props) canEditProperties={canEditProperties} isEstimateEnabled={isEstimateEnabled} spreadsheetColumnsList={spreadsheetColumnsList} + onColumnReorder={onColumnReorder} selectionHelpers={selectionHelpers} isEpic={isEpic} /> diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index b9aa21619d37..566192756a8b 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -4,7 +4,8 @@ * See the LICENSE file for details. */ -import React, { useRef } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { observer } from "mobx-react"; // plane constants import { SPREADSHEET_SELECT_GROUP, SPREADSHEET_PROPERTY_LIST } from "@plane/constants"; @@ -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"; +import type { TSpreadsheetColumnKey } from "./column-order"; +import { applyColumnOrder, reorderColumn } from "./column-order"; import { SpreadsheetTable } from "./spreadsheet-table"; type Props = { @@ -67,13 +70,31 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) { const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - const spreadsheetColumnsList = isWorkspaceLevel - ? SPREADSHEET_PROPERTY_LIST - : SPREADSHEET_PROPERTY_LIST.filter((property) => { - if (property === "cycle" && !currentProjectDetails?.cycle_view) return false; - if (property === "modules" && !currentProjectDetails?.module_view) return false; - return true; - }); + const availableColumnsList = useMemo( + () => + isWorkspaceLevel + ? SPREADSHEET_PROPERTY_LIST + : SPREADSHEET_PROPERTY_LIST.filter((property) => { + if (property === "cycle" && !currentProjectDetails?.cycle_view) return false; + if (property === "modules" && !currentProjectDetails?.module_view) return false; + return true; + }), + [isWorkspaceLevel, currentProjectDetails?.cycle_view, currentProjectDetails?.module_view] + ); + + const spreadsheetColumnsList = useMemo( + () => applyColumnOrder(displayFilters.column_order, availableColumnsList), + [displayFilters.column_order, availableColumnsList] + ); + + const handleColumnReorder = useCallback( + (sourceProperty: TSpreadsheetColumnKey, destinationProperty: TSpreadsheetColumnKey, edge: Edge | null) => { + const reordered = reorderColumn(spreadsheetColumnsList, sourceProperty, destinationProperty, edge); + if (reordered === spreadsheetColumnsList) return; + handleDisplayFilterUpdate({ column_order: reordered }); + }, + [spreadsheetColumnsList, handleDisplayFilterUpdate] + ); if (!issueIds || issueIds.length === 0) return <>; return ( @@ -103,6 +124,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) { canLoadMoreIssues={canLoadMoreIssues} loadMoreIssues={loadMoreIssues} spreadsheetColumnsList={spreadsheetColumnsList} + onColumnReorder={handleColumnReorder} selectionHelpers={helpers} isEpic={isEpic} /> diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index d07549dce80c..7bfe56350807 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -161,6 +161,12 @@ export interface IIssueDisplayFilterOptions { order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; + /** + * User-defined left-to-right order of the spreadsheet layout's columns. + * Persisted as-is inside the display_filters JSON blob. May be stale with + * respect to the current column list, so always reconcile before rendering. + */ + column_order?: (keyof IIssueDisplayProperties)[]; } export interface IIssueDisplayProperties { assignee?: boolean;