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;