Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;

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

};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,15 +26,59 @@ interface Props {
isEstimateEnabled: boolean;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => 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<HTMLTableCellElement | null>(null);
// states
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(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 (
<WithDisplayPropertiesHOC
Expand All @@ -36,7 +87,13 @@ export const SpreadsheetHeaderColumn = observer(function SpreadsheetHeaderColumn
shouldRenderProperty={() => shouldRenderProperty}
>
<th
className="h-11 min-w-36 items-center border border-t-0 border-b-0 border-subtle bg-layer-1 py-1 text-13 font-medium"
className={cn(
"relative h-11 min-w-36 items-center border border-t-0 border-b-0 border-subtle bg-layer-1 py-1 text-13 font-medium",
{
"cursor-grab": isReorderable,
"opacity-50": isDragging,
}
)}
ref={tableHeaderCellRef}
tabIndex={0}
>
Expand All @@ -49,6 +106,15 @@ export const SpreadsheetHeaderColumn = observer(function SpreadsheetHeaderColumn
}}
isEpic={isEpic}
/>
{closestEdge && (
<span
aria-hidden
className={cn("pointer-events-none absolute inset-y-0 z-[1] w-0.5 bg-accent-primary", {
"left-0": closestEdge === "left",
"right-0": closestEdge === "right",
})}
/>
)}
</th>
</WithDisplayPropertiesHOC>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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;
}
Expand All @@ -36,6 +43,7 @@ export const SpreadsheetHeader = observer(function SpreadsheetHeader(props: Prop
canEditProperties,
isEstimateEnabled,
spreadsheetColumnsList,
onColumnReorder,
selectionHelpers,
isEpic = false,
} = props;
Expand Down Expand Up @@ -84,6 +92,7 @@ export const SpreadsheetHeader = observer(function SpreadsheetHeader(props: Prop
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
isEstimateEnabled={isEstimateEnabled}
onColumnReorder={onColumnReorder}
isEpic={isEpic}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand All @@ -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;
};
Expand All @@ -55,6 +62,7 @@ export const SpreadsheetTable = observer(function SpreadsheetTable(props: Props)
containerRef,
loadMoreIssues,
spreadsheetColumnsList,
onColumnReorder,
selectionHelpers,
isEpic = false,
} = props;
Expand Down Expand Up @@ -119,6 +127,7 @@ export const SpreadsheetTable = observer(function SpreadsheetTable(props: Props)
canEditProperties={canEditProperties}
isEstimateEnabled={isEstimateEnabled}
spreadsheetColumnsList={spreadsheetColumnsList}
onColumnReorder={onColumnReorder}
selectionHelpers={selectionHelpers}
isEpic={isEpic}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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

import type { TSpreadsheetColumnKey } from "./column-order";
import { applyColumnOrder, reorderColumn } from "./column-order";
import { SpreadsheetTable } from "./spreadsheet-table";

type Props = {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -103,6 +124,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
canLoadMoreIssues={canLoadMoreIssues}
loadMoreIssues={loadMoreIssues}
spreadsheetColumnsList={spreadsheetColumnsList}
onColumnReorder={handleColumnReorder}
selectionHelpers={helpers}
isEpic={isEpic}
/>
Expand Down
6 changes: 6 additions & 0 deletions packages/types/src/view-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading