diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index ad61647279..c178feb8db 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -131,6 +131,31 @@ $root: ".widget-datagrid"; align-self: center; } + /* Drag handle */ + .drag-handle { + cursor: grab; + pointer-events: auto; + position: relative; + width: 14px; + padding: 0; + flex-grow: 0; + display: flex; + justify-content: center; + z-index: 1; + + &:hover { + background-color: var(--brand-primary-50, $brand-light); + color: var(--brand-primary, $brand-primary); + } + :active { + cursor: grabbing; + } + } + + .drag-handle + .column-caption { + padding-inline-start: 4px; + } + &:focus:not(:focus-visible) { outline: none; } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx new file mode 100644 index 0000000000..cd08a6aa89 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -0,0 +1,64 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { ColumnHeader } from "./ColumnHeader"; +import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDragnDropVM } from "../model/hooks/injection-hooks"; +import { ColumnResizerProps } from "./ColumnResizer"; +import { observer } from "mobx-react-lite"; + +export interface ColumnContainerProps { + isLast?: boolean; + resizer: ReactElement; +} + +export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { + const { columnsFilterable, id: gridId } = useDatagridConfig(); + const { columnFilters } = useColumnsStore(); + const column = useColumn(); + const { canSort, columnId, columnIndex, canResize, sortDir, header } = column; + const vm = useHeaderDragnDropVM(); + const caption = header.trim(); + + return ( +
column.setHeaderElementRef(ref)} + data-column-id={columnId} + onDrop={vm.handleOnDrop} + onDragEnter={vm.handleDragEnter} + onDragOver={vm.handleDragOver} + > +
+ + {columnsFilterable && ( +
+ {columnFilters[columnIndex]?.renderFilterWidgets()} +
+ )} +
+ {canResize ? props.resizer : null} +
+ ); +}); + +function getAriaSort(canSort: boolean, sortDir: string | undefined): "ascending" | "descending" | "none" | undefined { + if (!canSort) { + return undefined; + } + + switch (sortDir) { + case "asc": + return "ascending"; + case "desc": + return "descending"; + default: + return "none"; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx new file mode 100644 index 0000000000..0ae6ee3e39 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -0,0 +1,103 @@ +import classNames from "classnames"; +import { DragEvent, DragEventHandler, HTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from "react"; +import { FaArrowsAltV } from "./icons/FaArrowsAltV"; +import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; +import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; +import { useColumn, useHeaderDragnDropVM } from "../model/hooks/injection-hooks"; +import { observer } from "mobx-react-lite"; + +interface DragHandleProps { + draggable: boolean; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; +} + +export const ColumnHeader = observer(function ColumnHeader(): ReactElement { + const column = useColumn(); + const { header, canSort, alignment } = column; + const caption = header.trim(); + const sortProps = canSort ? getSortProps(() => column.toggleSort()) : null; + const vm = useHeaderDragnDropVM(); + + return ( +
+ {vm.isDraggable && ( + + )} + {caption.length > 0 ? caption : "\u00a0"} + {canSort ? : null} +
+ ); +}); + +function DragHandle({ draggable, onDragStart, onDragEnd }: DragHandleProps): ReactElement { + const handleMouseDown = (e: MouseEvent): void => { + // Only stop propagation, don't prevent default - we need default for drag to work + e.stopPropagation(); + }; + + const handleClick = (e: MouseEvent): void => { + // Stop click events from bubbling to prevent sorting + e.stopPropagation(); + e.preventDefault(); + }; + + const handleDragStart = (e: DragEvent): void => { + // Don't stop propagation here - let the drag start properly + if (onDragStart) { + onDragStart(e); + } + }; + + const handleDragEnd = (e: DragEvent): void => { + if (onDragEnd) { + onDragEnd(e); + } + }; + + return ( + + ⠿ + + ); +} + +function SortIcon(): ReactNode { + const column = useColumn(); + switch (column.sortDir) { + case "asc": + return ; + case "desc": + return ; + default: + return ; + } +} + +function getSortProps(toggleSort: () => void): HTMLAttributes { + return { + onClick: () => { + toggleSort(); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSort(); + } + }, + role: "button", + tabIndex: 0 + }; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx index 90dc7f7449..bcbc4bcfe9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnResizer.tsx @@ -1,24 +1,18 @@ import { useEventCallback } from "@mendix/widget-plugin-hooks/useEventCallback"; import { MouseEvent, ReactElement, TouchEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useColumn, useColumnsStore } from "../model/hooks/injection-hooks"; export interface ColumnResizerProps { minWidth?: number; - setColumnWidth: (width: number) => void; - onResizeEnds?: () => void; - onResizeStart?: () => void; } -export function ColumnResizer({ - minWidth = 50, - setColumnWidth, - onResizeEnds, - onResizeStart -}: ColumnResizerProps): ReactElement { +export function ColumnResizer({ minWidth = 50 }: ColumnResizerProps): ReactElement { + const column = useColumn(); + const columnsStore = useColumnsStore(); const [isResizing, setIsResizing] = useState(false); const [startPosition, setStartPosition] = useState(0); const [currentWidth, setCurrentWidth] = useState(0); const resizerReference = useRef(null); - const onStart = useEventCallback(onResizeStart); const onStartDrag = useCallback( (e: TouchEvent & MouseEvent): void => { @@ -26,12 +20,12 @@ export function ColumnResizer({ setStartPosition(mouseX); setIsResizing(true); if (resizerReference.current) { - const column = resizerReference.current.parentElement!; - setCurrentWidth(column.offsetWidth); + const columnElement = resizerReference.current.parentElement!; + setCurrentWidth(columnElement.offsetWidth); } - onStart(); + columnsStore.setIsResizing(true); }, - [onStart] + [columnsStore] ); const onEndDrag = useCallback((): void => { if (!isResizing) { @@ -39,9 +33,9 @@ export function ColumnResizer({ } setIsResizing(false); setCurrentWidth(0); - onResizeEnds?.(); - }, [onResizeEnds, isResizing]); - const setColumnWidthStable = useEventCallback(setColumnWidth); + columnsStore.setIsResizing(false); + }, [columnsStore, isResizing]); + const setColumnWidthStable = useEventCallback((width: number) => column.setSize(width)); const onMouseMove = useCallback( (e: TouchEvent & MouseEvent & Event): void => { if (!isResizing) { diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx index 39b86c6d3a..029dfc3bae 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx @@ -1,19 +1,16 @@ -import { ReactElement, useState } from "react"; +import { ReactElement } from "react"; import { useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; -import { ColumnId } from "../typings/GridColumn"; import { CheckboxColumnHeader } from "./CheckboxColumnHeader"; import { ColumnProvider } from "./ColumnProvider"; import { ColumnResizer } from "./ColumnResizer"; import { ColumnSelector } from "./ColumnSelector"; -import { Header } from "./Header"; +import { ColumnContainer } from "./ColumnContainer"; import { HeaderSkeletonLoader } from "./loader/HeaderSkeletonLoader"; export function GridHeader(): ReactElement { const { columnsHidable, id: gridId } = useDatagridConfig(); const columnsStore = useColumnsStore(); const columns = columnsStore.visibleColumns; - const [dragOver, setDragOver] = useState<[ColumnId, "before" | "after"] | undefined>(undefined); - const [isDragging, setIsDragging] = useState<[ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined>(); if (!columnsStore.loaded) { return ; @@ -25,19 +22,7 @@ export function GridHeader(): ReactElement { {columns.map(column => ( -
columnsStore.setIsResizing(true)} - onResizeEnds={() => columnsStore.setIsResizing(false)} - setColumnWidth={(width: number) => column.setSize(width)} - /> - } - setDropTarget={setDragOver} - setIsDragging={setIsDragging} - /> + } /> ))} {columnsHidable && ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx deleted file mode 100644 index ed334d2ad9..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import classNames from "classnames"; -import { - Dispatch, - DragEvent, - DragEventHandler, - HTMLAttributes, - KeyboardEvent, - ReactElement, - ReactNode, - SetStateAction, - useCallback -} from "react"; -import { FaArrowsAltV } from "./icons/FaArrowsAltV"; -import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; -import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; - -import { useColumn, useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; -import { ColumnId, GridColumn } from "../typings/GridColumn"; -import { ColumnResizerProps } from "./ColumnResizer"; - -export interface HeaderProps { - isLast?: boolean; - resizer: ReactElement; - - dropTarget?: [ColumnId, "before" | "after"]; - isDragging?: [ColumnId | undefined, ColumnId, ColumnId | undefined]; - setDropTarget: Dispatch>; - setIsDragging: Dispatch>; -} - -export function Header(props: HeaderProps): ReactElement { - const { columnsFilterable, id: gridId, columnsDraggable, columnsResizable, columnsSortable } = useDatagridConfig(); - const columnsStore = useColumnsStore(); - const column = useColumn(); - const canDrag = columnsDraggable && column.canDrag; - const canSort = columnsSortable && column.canSort; - const canResize = columnsResizable && column.canResize; - - const draggableProps = useDraggable( - canDrag, - columnsStore.swapColumns.bind(columnsStore), - props.dropTarget, - props.setDropTarget, - props.isDragging, - props.setIsDragging - ); - - const sortIcon = canSort ? getSortIcon(column) : null; - const sortProps = canSort ? getSortProps(column) : null; - const caption = column.header.trim(); - - return ( -
column.setHeaderElementRef(ref)} - data-column-id={column.columnId} - onDrop={draggableProps.onDrop} - onDragEnter={draggableProps.onDragEnter} - onDragOver={draggableProps.onDragOver} - > -
-
- {caption.length > 0 ? caption : "\u00a0"} - {sortIcon} -
- {columnsFilterable && ( -
- {columnsStore.columnFilters[column.columnIndex]?.renderFilterWidgets()} -
- )} -
- {canResize ? props.resizer : null} -
- ); -} - -function useDraggable( - columnsDraggable: boolean, - setColumnOrder: (source: ColumnId, target: [ColumnId, "after" | "before"]) => void, - dropTarget: [ColumnId, "before" | "after"] | undefined, - setDropTarget: Dispatch>, - dragging: [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined, - setDragging: Dispatch> -): { - draggable?: boolean; - onDragStart?: DragEventHandler; - onDragOver?: DragEventHandler; - onDrop?: DragEventHandler; - onDragEnter?: DragEventHandler; - onDragEnd?: DragEventHandler; -} { - const handleDragStart = useCallback( - (e: DragEvent): void => { - const elt = (e.target as HTMLDivElement).closest(".th") as HTMLDivElement; - const columnId = elt.dataset.columnId ?? ""; - - const columnAtTheLeft = (elt.previousElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; - const columnAtTheRight = (elt.nextElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; - - setDragging([columnAtTheLeft, columnId as ColumnId, columnAtTheRight]); - }, - [setDragging] - ); - - const handleDragOver = useCallback( - (e: DragEvent): void => { - if (!dragging) { - return; - } - const columnId = (e.currentTarget as HTMLDivElement).dataset.columnId as ColumnId; - if (!columnId) { - return; - } - e.preventDefault(); - - const [leftSiblingColumnId, draggingColumnId, rightSiblingColumnId] = dragging; - - if (columnId === draggingColumnId) { - // hover on itself place, no highlight - if (dropTarget !== undefined) { - setDropTarget(undefined); - } - return; - } - - let isAfter: boolean; - - if (columnId === leftSiblingColumnId) { - isAfter = false; - } else if (columnId === rightSiblingColumnId) { - isAfter = true; - } else { - // check position in element - const rect = e.currentTarget.getBoundingClientRect(); - isAfter = rect.width / 2 + (dropTarget?.[1] === "after" ? -10 : 10) < e.clientX - rect.left; - } - - const newPosition = isAfter ? "after" : "before"; - - if (columnId !== dropTarget?.[0] || newPosition !== dropTarget?.[1]) { - setDropTarget([columnId, newPosition]); - } - }, - [dragging, dropTarget, setDropTarget] - ); - - const handleDragEnter = useCallback((e: DragEvent): void => { - e.preventDefault(); - }, []); - - const handleDragEnd = useCallback((): void => { - setDragging(undefined); - setDropTarget(undefined); - }, [setDropTarget, setDragging]); - - const handleOnDrop = useCallback( - (_e: DragEvent): void => { - handleDragEnd(); - if (!dragging || !dropTarget) { - return; - } - - setColumnOrder(dragging[1], dropTarget); - }, - [handleDragEnd, setColumnOrder, dragging, dropTarget] - ); - - return columnsDraggable - ? { - draggable: true, - onDragStart: handleDragStart, - onDragOver: handleDragOver, - onDrop: handleOnDrop, - onDragEnter: handleDragEnter, - onDragEnd: handleDragEnd - } - : {}; -} - -function getSortIcon(column: GridColumn): ReactNode { - switch (column.sortDir) { - case "asc": - return ; - case "desc": - return ; - default: - return ; - } -} - -function getAriaSort(canSort: boolean, column: GridColumn): "ascending" | "descending" | "none" | undefined { - if (!canSort) { - return undefined; - } - - switch (column.sortDir) { - case "asc": - return "ascending"; - case "desc": - return "descending"; - default: - return "none"; - } -} - -function getSortProps(column: GridColumn): HTMLAttributes { - return { - onClick: () => { - column.toggleSort(); - }, - onKeyDown: (e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - column.toggleSort(); - } - }, - role: "button", - tabIndex: 0 - }; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnContainer.spec.tsx similarity index 88% rename from packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx rename to packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnContainer.spec.tsx index defbdfd369..63167f0fe9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnContainer.spec.tsx @@ -7,10 +7,10 @@ import { createDatagridContainer } from "../../model/containers/createDatagridCo import { CORE_TOKENS } from "../../model/tokens"; import { column, mockContainerProps } from "../../utils/test-utils"; import { ColumnProvider } from "../ColumnProvider"; +import { ColumnContainer } from "../ColumnContainer"; import { ColumnResizer } from "../ColumnResizer"; -import { Header, HeaderProps } from "../Header"; -describe("Header", () => { +describe("ColumnContainer", () => { it("renders the structure correctly", () => { const props = mockContainerProps({ columns: [column("Column 1")] @@ -22,7 +22,7 @@ describe("Header", () => { const component = render( -
+ } /> ); @@ -44,7 +44,7 @@ describe("Header", () => { const component = render( -
+ } /> ); @@ -66,7 +66,7 @@ describe("Header", () => { const component = render( -
resizer} /> + resizer} /> ); @@ -88,7 +88,7 @@ describe("Header", () => { const component = render( -
+ } /> ); @@ -110,7 +110,7 @@ describe("Header", () => { const component = render( -
+ } /> ); @@ -134,7 +134,7 @@ describe("Header", () => { const component = render( -
+ } /> ); @@ -159,19 +159,10 @@ describe("Header", () => { const component = render( -
+ } /> ); expect(component.asFragment()).toMatchSnapshot(); }); }); - -function mockHeaderProps(): HeaderProps { - return { - dropTarget: undefined, - resizer: , - setDropTarget: jest.fn(), - setIsDragging: jest.fn() - }; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx index daa0d9572b..a3aaed7e23 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnResizer.spec.tsx @@ -1,10 +1,26 @@ import "@testing-library/jest-dom"; import { render } from "@testing-library/react"; +import { ContainerProvider } from "brandi-react"; +import { createDatagridContainer } from "../../model/containers/createDatagridContainer"; +import { CORE_TOKENS as CORE } from "../../model/tokens"; +import { mockContainerProps } from "../../utils/test-utils"; +import { ColumnProvider } from "../ColumnProvider"; import { ColumnResizer } from "../ColumnResizer"; describe("Column Resizer", () => { it("renders the structure correctly", () => { - const component = render(); + const props = mockContainerProps(); + const [container] = createDatagridContainer(props); + const columnsStore = container.get(CORE.columnsStore); + const column = columnsStore.visibleColumns[0]; + + const component = render( + + + + + + ); expect(component).toMatchSnapshot(); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap similarity index 79% rename from packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap rename to packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap index 6deadade40..1516a489f8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Header renders the structure correctly 1`] = ` +exports[`ColumnContainer renders the structure correctly 1`] = `
- + Column 1
@@ -29,7 +31,7 @@ exports[`Header renders the structure correctly 1`] = `
`; -exports[`Header renders the structure correctly when draggable 1`] = ` +exports[`ColumnContainer renders the structure correctly when draggable 1`] = `
- + + ⠿ + + Column 1
@@ -59,7 +68,7 @@ exports[`Header renders the structure correctly when draggable 1`] = ` `; -exports[`Header renders the structure correctly when filterable with custom filter 1`] = ` +exports[`ColumnContainer renders the structure correctly when filterable with custom filter 1`] = `
- + Column 1
@@ -92,7 +103,7 @@ exports[`Header renders the structure correctly when filterable with custom filt
`; -exports[`Header renders the structure correctly when resizable 1`] = ` +exports[`ColumnContainer renders the structure correctly when resizable 1`] = `
- + Column 1
@@ -121,7 +134,7 @@ exports[`Header renders the structure correctly when resizable 1`] = `
`; -exports[`Header renders the structure correctly when sortable 1`] = ` +exports[`ColumnContainer renders the structure correctly when sortable 1`] = `
- + Column 1 `; -exports[`Header renders the structure correctly when value is empty 1`] = ` +exports[`ColumnContainer renders the structure correctly when value is empty 1`] = `
- +  
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts new file mode 100644 index 0000000000..54fc13b982 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts @@ -0,0 +1,41 @@ +import { action, makeAutoObservable } from "mobx"; +import { ColumnId } from "../../typings/GridColumn"; + +/** + * MobX store for managing drag & drop state of column headers. + * Tracks which column is being dragged and where it can be dropped. + * @injectable + */ +export class HeaderDragnDropStore { + private _dragOver: [ColumnId, "before" | "after"] | undefined = undefined; + private _isDragging: [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined = undefined; + + constructor() { + makeAutoObservable(this, { + setDragOver: action, + setIsDragging: action, + clearDragState: action + }); + } + + get dragOver(): [ColumnId, "before" | "after"] | undefined { + return this._dragOver; + } + + get isDragging(): [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined { + return this._isDragging; + } + + setDragOver(value: [ColumnId, "before" | "after"] | undefined): void { + this._dragOver = value; + } + + setIsDragging(value: [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined): void { + this._isDragging = value; + } + + clearDragState(): void { + this._dragOver = undefined; + this._isDragging = undefined; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts new file mode 100644 index 0000000000..fed47eca29 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts @@ -0,0 +1,95 @@ +import { makeAutoObservable } from "mobx"; +import { DragEvent } from "react"; +import { HeaderDragnDropStore } from "./HeaderDragnDrop.store"; +import { ColumnId, GridColumn } from "../../typings/GridColumn"; +import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; + +/** + * View model for a single column header drag & drop interactions. + * Encapsulates previous `useDraggable` hook logic and uses MobX store for shared drag state. + * @injectable + */ +export class HeaderDragnDropViewModel { + constructor( + private dndStore: HeaderDragnDropStore, + private columnsStore: ColumnGroupStore, + private config: { columnsDraggable: boolean }, + private column: GridColumn + ) { + makeAutoObservable(this); + } + + get dropTarget(): [ColumnId, "before" | "after"] | undefined { + return this.dndStore.dragOver; + } + + get dragging(): [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined { + return this.dndStore.isDragging; + } + + get isDraggable(): boolean { + return this.config.columnsDraggable && this.column.canDrag; + } + + handleDragStart = (e: DragEvent): void => { + const elt = (e.target as HTMLDivElement).closest(".th") as HTMLDivElement; + if (!elt) { + return; + } + const columnId = (elt.dataset.columnId ?? "") as ColumnId; + const columnAtTheLeft = (elt.previousElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; + const columnAtTheRight = (elt.nextElementSibling as HTMLDivElement)?.dataset?.columnId as ColumnId; + this.dndStore.setIsDragging([columnAtTheLeft, columnId, columnAtTheRight]); + }; + + handleDragOver = (e: DragEvent): void => { + const dragging = this.dragging; + if (!dragging) { + return; + } + const columnId = (e.currentTarget as HTMLDivElement).dataset.columnId as ColumnId; + if (!columnId) { + return; + } + e.preventDefault(); + const [leftSiblingColumnId, draggingColumnId, rightSiblingColumnId] = dragging; + if (columnId === draggingColumnId) { + if (this.dropTarget !== undefined) { + this.dndStore.setDragOver(undefined); + } + return; + } + let isAfter: boolean; + if (columnId === leftSiblingColumnId) { + isAfter = false; + } else if (columnId === rightSiblingColumnId) { + isAfter = true; + } else { + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); + isAfter = rect.width / 2 + (this.dropTarget?.[1] === "after" ? -10 : 10) < e.clientX - rect.left; + } + const newPosition: "before" | "after" = isAfter ? "after" : "before"; + if (columnId !== this.dropTarget?.[0] || newPosition !== this.dropTarget?.[1]) { + this.dndStore.setDragOver([columnId, newPosition]); + } + }; + + handleDragEnter = (e: DragEvent): void => { + e.preventDefault(); + }; + + handleDragEnd = (): void => { + this.dndStore.clearDragState(); + }; + + handleOnDrop = (_e: DragEvent): void => { + const dragging = this.dragging; + const dropTarget = this.dropTarget; + this.handleDragEnd(); + if (!dragging || !dropTarget) { + return; + } + // Reorder columns using existing columns store logic + this.columnsStore.swapColumns(dragging[1], dropTarget); + }; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts new file mode 100644 index 0000000000..a11aa24123 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts @@ -0,0 +1,213 @@ +import { DragEvent } from "react"; +import { HeaderDragnDropViewModel } from "../HeaderDragnDrop.viewModel"; +import { HeaderDragnDropStore } from "../HeaderDragnDrop.store"; +import { ColumnId } from "../../../typings/GridColumn"; + +describe("ColumnHeaderViewModel", () => { + let dndStore: HeaderDragnDropStore; + let mockColumnsStore: any; + let mockColumn: any; + + beforeEach(() => { + dndStore = new HeaderDragnDropStore(); + mockColumnsStore = { + swapColumns: jest.fn() + }; + mockColumn = { + canDrag: true, + columnId: "col1" as ColumnId + }; + }); + + describe("when columnsDraggable is false", () => { + it("is not draggable", () => { + const vm = new HeaderDragnDropViewModel( + dndStore, + mockColumnsStore, + { columnsDraggable: false }, + mockColumn + ); + + expect(vm.isDraggable).toBe(false); + }); + }); + + describe("when columnsDraggable is true", () => { + let vm: HeaderDragnDropViewModel; + + beforeEach(() => { + vm = new HeaderDragnDropViewModel(dndStore, mockColumnsStore, { columnsDraggable: true }, mockColumn); + }); + + it("is draggable", () => { + expect(vm.isDraggable).toBe(true); + }); + + describe("handleDragStart", () => { + it("sets dragging state with column siblings", () => { + const mockElement = createMockElement("col1", "col0", "col2"); + const event = createMockDragEvent(mockElement); + + vm.handleDragStart(event); + + expect(dndStore.isDragging).toEqual(["col0", "col1", "col2"]); + }); + + it("handles missing previous sibling", () => { + const mockElement = createMockElement("col1", undefined, "col2"); + const event = createMockDragEvent(mockElement); + + vm.handleDragStart(event); + + expect(dndStore.isDragging).toEqual([undefined, "col1", "col2"]); + }); + + it("handles missing next sibling", () => { + const mockElement = createMockElement("col1", "col0", undefined); + const event = createMockDragEvent(mockElement); + + vm.handleDragStart(event); + + expect(dndStore.isDragging).toEqual(["col0", "col1", undefined]); + }); + + it("does nothing when element is not found", () => { + const event = { + target: { + closest: jest.fn().mockReturnValue(null) + } + } as any; + + vm.handleDragStart(event); + + expect(dndStore.isDragging).toBeUndefined(); + }); + }); + + describe("handleDragOver", () => { + beforeEach(() => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + }); + + it("does nothing when not dragging", () => { + dndStore.clearDragState(); + const event = createMockDragOverEvent("col2", 100, 50); + + vm.handleDragOver(event); + + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("does nothing when columnId is missing", () => { + const event = createMockDragOverEvent("", 100, 50); + + vm.handleDragOver(event); + + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("clears dropTarget when hovering over self", () => { + dndStore.setDragOver(["col2" as ColumnId, "after"]); + const event = createMockDragOverEvent("col1", 100, 50); + + vm.handleDragOver(event); + + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("sets dropTarget to before when hovering over left sibling", () => { + const event = createMockDragOverEvent("col0", 100, 50); + + vm.handleDragOver(event); + + expect(dndStore.dragOver).toEqual(["col0", "before"]); + }); + + it("sets dropTarget to after when hovering over right sibling", () => { + const event = createMockDragOverEvent("col2", 100, 50); + + vm.handleDragOver(event); + + expect(dndStore.dragOver).toEqual(["col2", "after"]); + }); + }); + + describe("handleDragEnter", () => { + it("prevents default behavior", () => { + const event = { preventDefault: jest.fn() } as any; + + vm.handleDragEnter(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe("handleDragEnd", () => { + it("clears drag state", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + dndStore.setDragOver(["col2" as ColumnId, "after"]); + + vm.handleDragEnd(); + + expect(dndStore.isDragging).toBeUndefined(); + expect(dndStore.dragOver).toBeUndefined(); + }); + }); + + describe("handleOnDrop", () => { + it("calls swapColumns with correct parameters", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + dndStore.setDragOver(["col3" as ColumnId, "after"]); + + vm.handleOnDrop({} as any); + + expect(mockColumnsStore.swapColumns).toHaveBeenCalledWith("col1", ["col3", "after"]); + }); + + it("clears drag state after drop", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + dndStore.setDragOver(["col3" as ColumnId, "after"]); + + vm.handleOnDrop({} as any); + + expect(dndStore.isDragging).toBeUndefined(); + expect(dndStore.dragOver).toBeUndefined(); + }); + }); + }); +}); + +// Helper functions to create mock DOM elements and events + +function createMockElement( + columnId: string, + prevSiblingId: string | undefined, + nextSiblingId: string | undefined +): HTMLDivElement { + const element = { + dataset: { columnId }, + previousElementSibling: prevSiblingId ? { dataset: { columnId: prevSiblingId } } : null, + nextElementSibling: nextSiblingId ? { dataset: { columnId: nextSiblingId } } : null + } as any; + + return element; +} + +function createMockDragEvent(targetElement: HTMLDivElement): DragEvent { + return { + target: { + closest: jest.fn().mockReturnValue(targetElement) + } + } as any; +} + +function createMockDragOverEvent(columnId: string, width: number, clientX: number): DragEvent { + return { + currentTarget: { + dataset: { columnId }, + getBoundingClientRect: jest.fn().mockReturnValue({ width, left: 0 }) + }, + clientX, + preventDefault: jest.fn() + } as any; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts index fd99730e77..c92a252400 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -22,6 +22,8 @@ import { createCellEventsController } from "../../features/row-interaction/CellE import { creteCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController"; import { SelectAllModule } from "../../features/select-all/SelectAllModule.container"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; +import { HeaderDragnDropStore } from "../../features/column/HeaderDragnDrop.store"; +import { HeaderDragnDropViewModel } from "../../features/column/HeaderDragnDrop.viewModel"; import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore"; import { DatagridConfig } from "../configs/Datagrid.config"; @@ -84,6 +86,10 @@ injected( DG.selectionCounterCfg.optional ); +// drag and drop +injected(HeaderDragnDropStore); +injected(HeaderDragnDropViewModel, DG.headerDragDrop, CORE.columnsStore, CORE.config, CORE.column); + export class DatagridContainer extends Container { id = `DatagridContainer@${generateUUID()}`; constructor(root: Container) { @@ -94,6 +100,10 @@ export class DatagridContainer extends Container { this.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope(); // Columns store this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); + // Drag and Drop store + this.bind(DG.headerDragDrop).toInstance(HeaderDragnDropStore).inSingletonScope(); + // Drag and Drop view model + this.bind(DG.headerDragnDropVM).toInstance(HeaderDragnDropViewModel).inSingletonScope(); // Query service this.bind(DG.query).toInstance(DatasourceService).inSingletonScope(); // Pagination service diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts index 337a03637f..c09a2d2dcf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -20,6 +20,7 @@ export const [useRowClass] = createInjectionHooks(DG.rowClass); export const [useDatagridRootVM] = createInjectionHooks(DG.datagridRootVM); export const [useRows] = createInjectionHooks(CORE.rows); export const [useSelectActions] = createInjectionHooks(DG.selectActions); +export const [useHeaderDragnDropVM] = createInjectionHooks(DG.headerDragnDropVM); export const [useClickActionHelper] = createInjectionHooks(DG.clickActionHelper); export const [useFocusService] = createInjectionHooks(DG.focusService); export const [useCheckboxEventsHandler] = createInjectionHooks(DG.checkboxEventsHandler); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index 26b3e38201..1bb6a87af4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -33,6 +33,8 @@ import { SelectionProgressDialogViewModel } from "../features/select-all/Selecti import { ColumnGroupStore } from "../helpers/state/ColumnGroupStore"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../helpers/state/GridPersonalizationStore"; +import { HeaderDragnDropStore } from "../features/column/HeaderDragnDrop.store"; +import { HeaderDragnDropViewModel } from "../features/column/HeaderDragnDrop.viewModel"; import { DatasourceParamsController } from "../model/services/DatasourceParamsController"; import { GridColumn } from "../typings/GridColumn"; import { DatagridConfig } from "./configs/Datagrid.config"; @@ -131,6 +133,8 @@ export const DG_TOKENS = { clickActionHelper: token("@service:ClickActionHelper"), focusService: token("@service:FocusTargetController"), checkboxEventsHandler: token("@service:CheckboxEventsController"), + headerDragDrop: token("@store:HeaderDragnDropStore"), + headerDragnDropVM: token("@viewmodel:ColumnHeaderViewModel"), cellEventsHandler: token("@service:CellEventsController") }; diff --git a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts index 165f58d6cb..c5bcccad46 100644 --- a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts +++ b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts @@ -6,7 +6,7 @@ import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType, WidgetSelection export class SelectActionHandler { constructor( private selection: WidgetSelectionProperty, - protected selectionHelper: SelectionHelperService | undefined + protected selectionHelper: SelectionHelperService ) {} get selectionType(): SelectionType {