Repository navigation
feat: drag-and-drop column reordering in the spreadsheet layout #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · 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 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} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
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.lengthfirst to bail out immediately when sizes differFix → Check
a.length === b.length && a.every((x, i) => x === b[i])so arrays of different sizes stop right awayDocs