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 8ca532f190..61d1176c6c 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -22,7 +22,7 @@ $root: ".widget-datagrid"; background-color: var(--bg-color-secondary, $bg-color-secondary); border-width: 0; border-color: var(--grid-border-color, $grid-border-color); - padding: var(--spacing-medium, $spacing-medium); + padding: 0 var(--spacing-medium, $spacing-medium); top: 0; min-width: 0; position: relative; @@ -30,41 +30,8 @@ $root: ".widget-datagrid"; } .th { - &.dragging { - opacity: 0.5; - &.dragging-over-self { - opacity: 0.8; - } - } - - &.drop-after:after, - &.drop-before:after { - content: ""; - position: absolute; - top: 0; - height: 100%; - width: var(--spacing-smaller, $spacing-smaller); - background-color: $dragging-color-effect; - - z-index: 1; - } - - &.drop-before { - &:after { - left: 0; - } - &:not(:first-child):after { - transform: translateX(-50%); - } - } - - &.drop-after { - &:after { - right: 0; - } - &:not(:last-child):after { - transform: translateX(50%); - } + &.dragging-over-self { + opacity: 0.8; } /* Clickable column header (Sortable) */ @@ -92,6 +59,76 @@ $root: ".widget-datagrid"; } } + /* Drag handle */ + .drag-handle { + cursor: grab; + pointer-events: auto; + position: relative; + padding: 12px 4px 4px 4px; + flex-grow: 0; + flex-shrink: 0; + display: flex; + justify-content: center; + align-self: flex-start; + z-index: 1; + opacity: 0; + transition: opacity 0.15s ease; + + &:hover { + svg { + color: var(--brand-primary, $brand-primary); + } + } + &:active { + cursor: grabbing; + } + &:focus-visible { + opacity: 1; + } + svg { + margin: 0; + width: 8px; + } + } + + &.locked-drag-active { + z-index: 2; + } + + &.dragging-over-self { + opacity: 0.25; + } + + &:hover .drag-handle, + &:focus-within .drag-handle { + opacity: 1; + } + + /* Parent background change on drag handle hover */ + &:has(.drag-handle:hover) { + background-color: var(--brand-primary-50, $brand-light); + } + + /* Drag preview (dnd-kit) should look like hovered header */ + &.drag-preview { + background-color: var(--brand-primary-50, $brand-light); + box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25); + border: 1px solid var(--gray-light, $gray-light); + + .drag-handle { + opacity: 1; + + svg { + color: var(--brand-primary, $brand-primary); + } + } + } + + /* Remove left padding when drag handle is present */ + &:has(.drag-handle) { + padding-left: 0; + } + /* Content of the column header */ .column-container { display: flex; @@ -99,6 +136,7 @@ $root: ".widget-datagrid"; flex-grow: 1; align-self: stretch; min-width: 0; + padding: var(--spacing-small, $spacing-small) 0; &:not(:has(.filter)) { .column-header { @@ -143,7 +181,9 @@ $root: ".widget-datagrid"; /* Header filter */ .filter { display: flex; - margin-top: 4px; + > * { + margin-top: 4px; + } > .form-group { margin-bottom: 0; } diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/variables.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/variables.scss index 8756e6e96b..1d2719721c 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/variables.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/variables.scss @@ -13,6 +13,7 @@ $brand-light: #e6eaff !default; $grid-selected-row-background: $brand-light !default; // Text and icon colors +$gray-light: #6c7180 !default; $gray-dark: #606671 !default; $gray-darker: #3b4251 !default; $pagination-caption-color: #0a1325 !default; @@ -33,7 +34,7 @@ $spacing-larger: 32px !default; $gallery-gap: $spacing-small !default; // Effects and animations -$dragging-color-effect: rgba(10, 19, 37, 0.8) !default; +$dragging-color-effect: $brand-primary !default; $skeleton-background: linear-gradient(90deg, rgba(194, 194, 194, 0.2) 0%, #d2d2d2 100%) !default; // Assets diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index 8a390bba23..5f301156b9 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -49,10 +49,9 @@ test.describe("capabilities: sorting", () => { await page.goto("/"); await page.waitForLoadState("networkidle"); await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name"); - await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute( - "data-icon", - "arrows-alt-v" - ); + await expect( + page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg[data-icon='arrows-alt-v']") + ).toBeVisible(); await expect(page.getByRole("gridcell", { name: "12" }).first()).toHaveText("12"); }); @@ -60,15 +59,13 @@ test.describe("capabilities: sorting", () => { await page.goto("/"); await page.waitForLoadState("networkidle"); await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name"); - await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute( - "data-icon", - "arrows-alt-v" - ); + await expect( + page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg[data-icon='arrows-alt-v']") + ).toBeVisible(); await page.locator(".mx-name-datagrid1 .column-header").nth(1).click(); - await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute( - "data-icon", - "long-arrow-alt-up" - ); + await expect( + page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg[data-icon='long-arrow-alt-up']") + ).toBeVisible(); await expect(page.getByRole("gridcell", { name: "10" }).first()).toHaveText("10"); }); @@ -78,10 +75,9 @@ test.describe("capabilities: sorting", () => { await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1)).toHaveText("First Name"); await page.locator(".mx-name-datagrid1 .column-header").nth(1).click(); await page.locator(".mx-name-datagrid1 .column-header").nth(1).click(); - await expect(page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg")).toHaveAttribute( - "data-icon", - "long-arrow-alt-down" - ); + await expect( + page.locator(".mx-name-datagrid1 .column-header").nth(1).locator("svg[data-icon='long-arrow-alt-down']") + ).toBeVisible(); await expect(page.getByRole("gridcell", { name: "12" }).first()).toHaveText("12"); }); }); diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png index 7c5d4f9ad5..6689e945a7 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png index 38f3b9083b..d7ac493926 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png index 45e2db5267..bafa320b90 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png index cfee3848f6..e07218c3eb 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png index 8c3e964ecd..56d1f2dc67 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png index fb2437d04c..a05f864a52 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png index 2e2f7dce38..968265d17d 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js-snapshots/datagridFilteringIntegration-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js-snapshots/datagridFilteringIntegration-chromium-linux.png index 4977aebf8f..54f17df6e4 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js-snapshots/datagridFilteringIntegration-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringIntegration.spec.js-snapshots/datagridFilteringIntegration-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js-snapshots/datagridFilteringSingle-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js-snapshots/datagridFilteringSingle-chromium-linux.png index db600cfde2..2bbac27ce2 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js-snapshots/datagridFilteringSingle-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/filtering/DataGridFilteringSingle.spec.js-snapshots/datagridFilteringSingle-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/package.json b/packages/pluggableWidgets/datagrid-web/package.json index 1d261d2c48..2d26fab4b1 100644 --- a/packages/pluggableWidgets/datagrid-web/package.json +++ b/packages/pluggableWidgets/datagrid-web/package.json @@ -41,6 +41,9 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.26.27", "@mendix/widget-plugin-component-kit": "workspace:*", "@mendix/widget-plugin-external-events": "workspace:*", diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 7d666397a3..693e4c8cdd 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -7,6 +7,7 @@ import { ColumnsPreviewType, DatagridPreviewProps } from "typings/DatagridProps" import { FaArrowsAltV } from "./components/icons/FaArrowsAltV"; import { FaEye } from "./components/icons/FaEye"; import { ColumnPreview } from "./helpers/ColumnPreview"; + import "./ui/DatagridPreview.scss"; declare module "mendix/preview/Selectable" { 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..e0b39fa933 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -0,0 +1,77 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { ColumnHeader } from "./ColumnHeader"; +import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDndVM } from "../model/hooks/injection-hooks"; +import { ColumnResizerProps } from "./ColumnResizer"; +import { observer } from "mobx-react-lite"; +import { DragHandle } from "./DragHandle"; +import { useSortable } from "@dnd-kit/sortable"; + +export interface ColumnContainerProps { + isLast?: boolean; + resizer: ReactElement; +} + +export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { + const { columnsFilterable, columnsDraggable, id: gridId } = useDatagridConfig(); + const columnsStore = useColumnsStore(); + const { columnFilters } = columnsStore; + const column = useColumn(); + const { canSort, columnId, columnIndex, canResize, sortDir, header } = column; + const caption = header.trim(); + const vm = useHeaderDndVM(); + const isDndEnabled = Boolean(columnsDraggable && column.canDrag); + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ + id: columnId, + disabled: !isDndEnabled + }); + const setHeaderRef = (ref: HTMLDivElement | null): void => { + column.setHeaderElementRef(ref); + setNodeRef(ref); + }; + const style = vm.getHeaderCellStyle(columnId, { transform, transition }); + const isLocked = !column.canDrag; + + return ( +
+ {isDndEnabled && ( + + )} +
+ + {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..7be4f1c0fc --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -0,0 +1,59 @@ +import classNames from "classnames"; +import { HTMLAttributes, KeyboardEvent, ReactElement, ReactNode } from "react"; +import { FaArrowsAltV } from "./icons/FaArrowsAltV"; +import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; +import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; +import { useColumn, useHeaderDndVM } from "../model/hooks/injection-hooks"; +import { observer } from "mobx-react-lite"; +import { SortDirection } from "../typings/sorting"; + +interface SortIconProps { + direction: SortDirection | undefined; +} + +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 = useHeaderDndVM(); + + return ( +
+ {caption.length > 0 ? caption : "\u00a0"} + {canSort ? : null} +
+ ); +}); + +function SortIcon({ direction }: SortIconProps): ReactNode { + switch (direction) { + 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/ColumnHeaderDragPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeaderDragPreview.tsx new file mode 100644 index 0000000000..1b0b2cb046 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeaderDragPreview.tsx @@ -0,0 +1,40 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { useColumn, useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; +import { ColumnHeader } from "./ColumnHeader"; +import { DragHandleIcon } from "./DragHandleIcon"; + +/** + * Drag preview content for column header reordering. + * + * Rendered by @dnd-kit DragOverlay in a portal, so we provide the same selector context + * used by the datagrid SCSS to make it look like a real header. + */ +export function ColumnHeaderDragPreview(): ReactElement { + const { columnsFilterable, id: gridId } = useDatagridConfig(); + const { columnFilters } = useColumnsStore(); + const column = useColumn(); + const { columnId, columnIndex, header, size } = column; + const caption = header.trim(); + + return ( +
+
+
+ +
+ + {columnsFilterable && ( +
{columnFilters[columnIndex]?.renderFilterWidgets()}
+ )} +
+
+
+
+ ); +} 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/DragHandle.tsx b/packages/pluggableWidgets/datagrid-web/src/components/DragHandle.tsx new file mode 100644 index 0000000000..4293774f81 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/DragHandle.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +import { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; +import { FaGripVertical } from "./icons/FaGripVertical"; + +interface DragHandleProps { + setActivatorNodeRef: (element: HTMLElement | null) => void; + listeners?: DraggableSyntheticListeners; + attributes?: DraggableAttributes; +} +export function DragHandle({ setActivatorNodeRef, listeners, attributes }: DragHandleProps): ReactElement { + return ( + + + + ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/DragHandleIcon.tsx b/packages/pluggableWidgets/datagrid-web/src/components/DragHandleIcon.tsx new file mode 100644 index 0000000000..160eff94f4 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/DragHandleIcon.tsx @@ -0,0 +1,15 @@ +import { ReactElement } from "react"; +import { FaGripVertical } from "./icons/FaGripVertical"; + +/** + * Visual-only drag handle. + * + * For preview purposes only; does not implement drag-and-drop functionality. + */ +export function DragHandleIcon(): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx index 39b86c6d3a..21cb353996 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx @@ -1,54 +1,68 @@ -import { ReactElement, useState } from "react"; -import { useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; -import { ColumnId } from "../typings/GridColumn"; +import { ReactElement, useMemo } from "react"; +import { useColumnsStore, useDatagridConfig, useHeaderDndVM } from "../model/hooks/injection-hooks"; 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"; +import { observer } from "mobx-react-lite"; +import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { ColumnHeaderDragPreview } from "./ColumnHeaderDragPreview"; -export function GridHeader(): ReactElement { +export const GridHeader = observer(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>(); + const vm = useHeaderDndVM(); + const items = useMemo(() => columns.filter(c => c.canDrag).map(c => c.columnId), [columns]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); if (!columnsStore.loaded) { return ; } return ( -
-
- - {columns.map(column => ( - -
columnsStore.setIsResizing(true)} - onResizeEnds={() => columnsStore.setIsResizing(false)} - setColumnWidth={(width: number) => column.setSize(width)} - /> - } - setDropTarget={setDragOver} - setIsDragging={setIsDragging} - /> + + +
+
+ + {columns.map(column => ( + + } /> + + ))} + {columnsHidable && ( + + )} +
+
+
+ + + {vm.activeColumn ? ( + + - ))} - {columnsHidable && ( - - )} -
-
+ ) : null} + + ); -} +}); 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 67% 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..f179c0f9a5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnContainer.spec.tsx @@ -3,14 +3,29 @@ import "@testing-library/jest-dom"; import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ContainerProvider } from "brandi-react"; +import { ReactElement } from "react"; import { createDatagridContainer } from "../../model/containers/createDatagridContainer"; 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"; +import { DndContext } from "@dnd-kit/core"; +import { horizontalListSortingStrategy, SortableContext } from "@dnd-kit/sortable"; + +describe("ColumnContainer", () => { + function renderWithProviders(container: any, col: any, element: ReactElement): ReturnType { + return render( + + + + {element} + + + + ); + } -describe("Header", () => { it("renders the structure correctly", () => { const props = mockContainerProps({ columns: [column("Column 1")] @@ -19,13 +34,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); expect(component.asFragment()).toMatchSnapshot(); }); @@ -41,13 +50,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); expect(component.asFragment()).toMatchSnapshot(); }); @@ -63,13 +66,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
resizer} /> - - - ); + const component = renderWithProviders(container, col, resizer} />); expect(component.asFragment()).toMatchSnapshot(); }); @@ -85,13 +82,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); expect(component.asFragment()).toMatchSnapshot(); }); @@ -107,13 +98,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); expect(component.asFragment()).toMatchSnapshot(); }); @@ -131,13 +116,7 @@ describe("Header", () => { const col = columns.visibleColumns[0]; const spy = jest.spyOn(col, "toggleSort"); - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); const button = component.getByLabelText("sort Column 1"); expect(button).toBeInTheDocument(); @@ -156,22 +135,7 @@ describe("Header", () => { const columns = container.get(CORE_TOKENS.columnsStore); const col = columns.visibleColumns[0]; - const component = render( - - -
- - - ); + const component = renderWithProviders(container, col, } />); 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__/ColumnContainer.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap new file mode 100644 index 0000000000..4f7e5b2eb9 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColumnContainer renders the structure correctly 1`] = ` + +
+
+
+ + Column 1 + +
+
+
+
+ +
+ +`; + +exports[`ColumnContainer renders the structure correctly when draggable 1`] = ` + +
+ + + +
+
+ + Column 1 + +
+
+
+
+ +
+ +`; + +exports[`ColumnContainer renders the structure correctly when filterable with custom filter 1`] = ` + +
+
+
+ + Column 1 + +
+
+
+ Custom filter +
+
+
+
+ +
+ +`; + +exports[`ColumnContainer renders the structure correctly when resizable 1`] = ` + +
+
+
+ + Column 1 + +
+
+
+
+ +
+ +`; + +exports[`ColumnContainer renders the structure correctly when sortable 1`] = ` + +
+
+
+ + Column 1 + + +
+
+
+
+ +
+ +`; + +exports[`ColumnContainer renders the structure correctly when value is empty 1`] = ` + +
+
+
+ +   + +
+
+
+
+ +
+ +`; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap deleted file mode 100644 index 6deadade40..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap +++ /dev/null @@ -1,197 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Header renders the structure correctly 1`] = ` - -
-
-
- - Column 1 - -
-
-
-
- -`; - -exports[`Header renders the structure correctly when draggable 1`] = ` - -
-
-
- - Column 1 - -
-
-
-
- -`; - -exports[`Header renders the structure correctly when filterable with custom filter 1`] = ` - -
-
-
- - Column 1 - -
-
-
- Custom filter -
-
-
-
-
-`; - -exports[`Header renders the structure correctly when resizable 1`] = ` - -
-
-
- - Column 1 - -
-
-
-
- -`; - -exports[`Header renders the structure correctly when sortable 1`] = ` - -
-
-
- - Column 1 - - -
-
-
-
- -`; - -exports[`Header renders the structure correctly when value is empty 1`] = ` - -
-
-
- -   - -
-
-
-
- -`; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/icons/FaGripVertical.tsx b/packages/pluggableWidgets/datagrid-web/src/components/icons/FaGripVertical.tsx new file mode 100644 index 0000000000..b0e198d488 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/icons/FaGripVertical.tsx @@ -0,0 +1,15 @@ +import { ReactElement } from "react"; + +/** + * Custom drag handle icon with 6 aligned dots in 3 rows + */ +export function FaGripVertical(): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.store.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.store.ts new file mode 100644 index 0000000000..9efe3794e0 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.store.ts @@ -0,0 +1,33 @@ +import { makeAutoObservable } from "mobx"; +import { ColumnId } from "../../typings/GridColumn"; + +export type DropPlacement = "before" | "after"; + +export class HeaderDndStore { + activeId: ColumnId | undefined = undefined; + overId: ColumnId | undefined = undefined; + placement: DropPlacement | undefined = undefined; + + constructor() { + makeAutoObservable(this); + } + + get isDragging(): boolean { + return Boolean(this.activeId); + } + + setActive(id: ColumnId | undefined): void { + this.activeId = id; + } + + setOver(overId: ColumnId | undefined, placement: DropPlacement | undefined): void { + this.overId = overId; + this.placement = placement; + } + + clear(): void { + this.activeId = undefined; + this.overId = undefined; + this.placement = undefined; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.viewModel.ts new file mode 100644 index 0000000000..45036e8621 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDnd.viewModel.ts @@ -0,0 +1,168 @@ +import { makeAutoObservable } from "mobx"; +import { + closestCenter, + CollisionDetection, + DragCancelEvent, + DragEndEvent, + DragOverEvent, + DragStartEvent, + UniqueIdentifier +} from "@dnd-kit/core"; +import { CSS, Transform } from "@dnd-kit/utilities"; +import { CSSProperties } from "react"; +import { ColumnId, GridColumn } from "../../typings/GridColumn"; +import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; +import { DropPlacement, HeaderDndStore } from "./HeaderDnd.store"; + +export class HeaderDndViewModel { + constructor( + private store: HeaderDndStore, + private columnsStore: ColumnGroupStore + ) { + makeAutoObservable(this, {}, { autoBind: true }); + } + + get activeId(): ColumnId | undefined { + return this.store.activeId; + } + + get overId(): ColumnId | undefined { + return this.store.overId; + } + + get placement(): DropPlacement | undefined { + return this.store.placement; + } + + get isDragging(): boolean { + return this.store.isDragging; + } + + get activeColumn(): GridColumn | undefined { + const activeId = this.store.activeId; + if (!activeId) { + return undefined; + } + return this.columnsStore.availableColumns.find(c => c.columnId === activeId); + } + + /** + * dnd-kit collision detection implementation. + * + * Kept here so barrier rules live with the DnD state/logic, not the view. + */ + collisionDetection(args: Parameters[0]): ReturnType { + // Use the full set of droppable containers for collision detection so + // dnd-kit's sorting/animation calculations consider locked columns' positions. + // We still prevent final drops on locked columns in `onDragOver`/`onDragEnd`. + return closestCenter(args); + } + + /** + * Derive the inline style for a header cell based on sortable state. + * Only draggable columns receive transform/transition styles to avoid + * showing locked columns moving during a drag. + */ + getHeaderCellStyle( + columnId: ColumnId, + options: { transform: Transform | null; transition: string | undefined } + ): CSSProperties { + const columns = this.columnsStore.visibleColumns; + const idx = columns.findIndex(c => c.columnId === columnId); + // If column doesn't exist or is locked (non-draggable) + // do not apply transform/transition so it remains visually fixed during dnd. + if (idx < 0 || !columns[idx].canDrag) { + return {}; + } + + return { + ...(options.transform ? { transform: CSS.Transform.toString(options.transform) } : null), + ...(options.transition ? { transition: options.transition } : null) + }; + } + + /** + * Ids that are allowed to be used as "over" targets for the current active drag. + * Returns undefined when not currently dragging. + */ + private toColumnId(id: UniqueIdentifier | undefined): ColumnId | undefined { + return typeof id === "string" ? (id as ColumnId) : undefined; + } + + private computePlacement(active: ColumnId, over: ColumnId): DropPlacement | undefined { + // Compute placement based on the indices of movable columns only so + // locked (non-draggable / non-sortable) columns remain stationary during drags. + const columns = this.columnsStore.visibleColumns; + const movable = columns.filter(c => c.canDrag); + const activeIndex = movable.findIndex(c => c.columnId === active); + const overIndex = movable.findIndex(c => c.columnId === over); + if (activeIndex < 0 || overIndex < 0) { + return undefined; + } + return overIndex > activeIndex ? "after" : "before"; + } + + private isOverAllowed(over: ColumnId): boolean { + const columns = this.columnsStore.visibleColumns; + const overIndex = columns.findIndex(c => c.columnId === over); + if (overIndex < 0) { + return false; + } + + // Can't drop onto a non-draggable column. + const col = columns[overIndex]; + return Boolean(col.canDrag); + } + + onDragStart(e: DragStartEvent): void { + const activeId = this.toColumnId(e.active.id); + if (activeId) { + this.store.setActive(activeId); + } + } + + onDragOver(e: DragOverEvent): void { + const activeId = this.toColumnId(e.active.id); + const overId = this.toColumnId(e.over?.id); + + if (!activeId) { + this.store.setOver(undefined, undefined); + return; + } + + if (!overId || activeId === overId) { + this.store.setOver(undefined, undefined); + return; + } + + if (!this.isOverAllowed(overId)) { + this.store.setOver(undefined, undefined); + return; + } + + this.store.setOver(overId, this.computePlacement(activeId, overId)); + } + + onDragEnd(e: DragEndEvent): void { + const activeId = this.toColumnId(e.active.id); + const overId = this.toColumnId(e.over?.id); + + if (!activeId) { + this.store.clear(); + return; + } + + if (overId && activeId !== overId && this.isOverAllowed(overId)) { + const placement = this.computePlacement(activeId, overId); + if (placement) { + this.columnsStore.swapColumns(activeId, [overId, placement]); + } + } + + this.store.clear(); + } + + onDragCancel(_e: DragCancelEvent): void { + this.store.clear(); + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDnd.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDnd.viewModel.spec.ts new file mode 100644 index 0000000000..df3486ed35 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDnd.viewModel.spec.ts @@ -0,0 +1,59 @@ +import { HeaderDndStore } from "../HeaderDnd.store"; +import { HeaderDndViewModel } from "../HeaderDnd.viewModel"; +import { ColumnId } from "../../../typings/GridColumn"; + +function col(id: string, canDrag: boolean): any { + return { columnId: id as ColumnId, canDrag }; +} + +describe("HeaderDndViewModel", () => { + it("does not swap across a non-draggable column barrier", () => { + const store = new HeaderDndStore(); + const columnsStore: any = { + visibleColumns: [col("A", true), col("B", false), col("C", true), col("D", true)], + availableColumns: [col("A", true), col("B", false), col("C", true), col("D", true)], + swapColumns: jest.fn() + }; + + const vm = new HeaderDndViewModel(store, columnsStore); + + // Try to drag C (right side) over A (left side). With the updated + // behavior draggable columns may swap across non-draggable columns. + vm.onDragEnd({ active: { id: "C" }, over: { id: "A" } } as any); + + expect(columnsStore.swapColumns).toHaveBeenCalledTimes(1); + expect(columnsStore.swapColumns).toHaveBeenCalledWith("C", ["A", "before"]); + }); + + it("allows swapping within the same draggable segment", () => { + const store = new HeaderDndStore(); + const columnsStore: any = { + visibleColumns: [col("A", true), col("B", false), col("C", true), col("D", true)], + availableColumns: [col("A", true), col("B", false), col("C", true), col("D", true)], + swapColumns: jest.fn() + }; + + const vm = new HeaderDndViewModel(store, columnsStore); + + // Drag D over C (same segment). Should reorder. + vm.onDragEnd({ active: { id: "D" }, over: { id: "C" } } as any); + + expect(columnsStore.swapColumns).toHaveBeenCalledTimes(1); + expect(columnsStore.swapColumns).toHaveBeenCalledWith("D", ["C", "before"]); + }); + + it("does not allow dropping onto a non-draggable column", () => { + const store = new HeaderDndStore(); + const columnsStore: any = { + visibleColumns: [col("A", true), col("B", false), col("C", true)], + availableColumns: [col("A", true), col("B", false), col("C", true)], + swapColumns: jest.fn() + }; + + const vm = new HeaderDndViewModel(store, columnsStore); + + vm.onDragEnd({ active: { id: "C" }, over: { id: "B" } } as any); + + expect(columnsStore.swapColumns).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts index e8cf112bd2..7288debf7c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts @@ -127,10 +127,58 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore, swapColumns(source: ColumnId, [target, placement]: [ColumnId, "after" | "before"]): void { const columnSource = this._allColumnsById.get(source)!; const columnTarget = this._allColumnsById.get(target)!; - columnSource.orderWeight = columnTarget.orderWeight + (placement === "after" ? 1 : -1); - // normalize columns - this._allColumnsOrdered.forEach((column, idx) => { + // Reorder only among movable (draggable) columns so that locked + // (non-draggable) columns keep their original positions. + const allOrdered = this._allColumnsOrdered; + const movable = allOrdered.filter(c => c.canDrag); + + const srcMovIdx = movable.findIndex(c => c.columnId === source); + const tgtMovIdx = movable.findIndex(c => c.columnId === target); + + // Fallback to simple relative weight if either column is not in movable set + if (srcMovIdx < 0 || tgtMovIdx < 0) { + columnSource.orderWeight = columnTarget.orderWeight + (placement === "after" ? 1 : -1); + this._allColumnsOrdered.forEach((column, idx) => { + column.orderWeight = idx * 10; + }); + return; + } + + // Build new movable order with source removed and inserted at target position + const newMovable = movable.slice(); + // remove source + newMovable.splice(srcMovIdx, 1); + + // compute insert index relative to original movable indices + const originalTgtIdx = tgtMovIdx; + let insertIdx: number; + if (srcMovIdx < originalTgtIdx) { + // removing source shifted target left by 1 + insertIdx = originalTgtIdx - 1 + (placement === "after" ? 1 : 0); + } else { + insertIdx = originalTgtIdx + (placement === "after" ? 1 : 0); + } + + // clamp + if (insertIdx < 0) insertIdx = 0; + if (insertIdx > newMovable.length) insertIdx = newMovable.length; + + newMovable.splice(insertIdx, 0, columnSource); + + // Merge back into the full ordered list: replace movable slots in-order + const merged: ColumnStore[] = []; + let movableCursor = 0; + for (const col of allOrdered) { + if (col.canDrag) { + merged.push(newMovable[movableCursor++]); + } else { + merged.push(col); + } + } + + // normalize weights based on merged order + merged.forEach((column, idx) => { column.orderWeight = idx * 10; }); } 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..709d670aa8 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 { HeaderDndStore } from "../../features/column/HeaderDnd.store"; +import { HeaderDndViewModel } from "../../features/column/HeaderDnd.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 ); +// header drag and drop (dnd-kit) +injected(HeaderDndStore); +injected(HeaderDndViewModel, DG.headerDndStore, CORE.columnsStore); + export class DatagridContainer extends Container { id = `DatagridContainer@${generateUUID()}`; constructor(root: Container) { @@ -94,6 +100,9 @@ export class DatagridContainer extends Container { this.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope(); // Columns store this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); + // Header drag and drop (dnd-kit) store/view model + this.bind(DG.headerDndStore).toInstance(HeaderDndStore).inSingletonScope(); + this.bind(DG.headerDndVM).toInstance(HeaderDndViewModel).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..feea4e8dd2 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 [useHeaderDndVM] = createInjectionHooks(DG.headerDndVM); 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..14fb470b94 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 { HeaderDndStore } from "../features/column/HeaderDnd.store"; +import { HeaderDndViewModel } from "../features/column/HeaderDnd.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"), + headerDndStore: token("@store:HeaderDndStore"), + headerDndVM: token("@viewmodel:HeaderDndViewModel"), cellEventsHandler: token("@service:CellEventsController") }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684f01279c..1d50053016 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1275,6 +1275,15 @@ importers: packages/pluggableWidgets/datagrid-web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@floating-ui/react': specifier: ^0.26.27 version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3861,6 +3870,28 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + react-dom: '>=18.0.0 <19.0.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=18.0.0 <19.0.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11985,6 +12016,31 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@eslint-community/eslint-utils@4.9.0(eslint@7.32.0)': dependencies: eslint: 7.32.0