Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d598dea
refactor: migrate event controllers from DI to React Context for simp…
iobuhov Nov 20, 2025
a956adb
refactor: migrate event handlers to container
iobuhov Nov 20, 2025
c52e732
refactor: extract drag & drop state and logic to mobx
yordan-st Nov 20, 2025
dc6e482
refactor: update components to use new state management
yordan-st Nov 20, 2025
81f4362
refactor: remove obsolete tests and snapshots, create for new component
yordan-st Nov 20, 2025
e71e11a
refactor: rewrite columnreszier to use injection hooks
yordan-st Nov 20, 2025
d262d79
refactor: enhance ColumnResizer test structure and update snapshot
yordan-st Nov 20, 2025
b6a9887
refactor: fix failing test
yordan-st Nov 21, 2025
07b07ff
feat: enhance drag-and-drop functionality with DragHandle component
yordan-st Nov 21, 2025
d5b7355
refactor: fix lint errors
yordan-st Nov 21, 2025
3fd49c9
refactor: standardize use of brandi
yordan-st Nov 27, 2025
910477b
refactor: improve naming, consistency and clean up
yordan-st Nov 28, 2025
c5efe6d
refactor: ensure consistent naming, prop destructuring, update tests
yordan-st Dec 1, 2025
65329f7
fix: update SelectActionHandler initialization to use null instead of…
yordan-st Dec 2, 2025
d66a1bf
fix: restore sort icon state, add dragndropdesign mode and react icon
yordan-st Dec 8, 2025
ab01b65
chore: update unit test snapshot
yordan-st Dec 9, 2025
e232706
fix: restore individual column reorder and reflect in preview, revert…
yordan-st Dec 11, 2025
1fca667
test: update e2e screenshots baseline
leonardomendix Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,38 @@ $root: ".widget-datagrid";
align-self: center;
}

/* Drag handle */
.drag-handle {
cursor: grab;
pointer-events: auto;
position: relative;
width: 14px;
padding: 0;
flex-grow: 0;
flex-shrink: 0;
display: flex;
justify-content: center;
align-self: normal;
z-index: 1;

&:hover {
background-color: var(--brand-primary-50, $brand-light);
svg {
color: var(--brand-primary, $brand-primary);
}
}
&:active {
cursor: grabbing;
}
svg {
margin: 0;
}
}

.drag-handle + .column-caption {
padding-inline-start: 4px;
}

&:focus:not(:focus-visible) {
outline: none;
}
Expand Down
28 changes: 12 additions & 16 deletions packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,23 @@ 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");
});

test("changes order of data to ASC when clicking sort option", async ({ page }) => {
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");
});

Expand All @@ -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");
});
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { GUID, ObjectItem } from "mendix";
import { Selectable } from "mendix/preview/Selectable";
import { createContext, CSSProperties, PropsWithChildren, ReactElement, ReactNode, useContext } from "react";
import { ColumnsPreviewType, DatagridPreviewProps } from "typings/DatagridProps";
import { DragHandle } from "./components/DragHandle";
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" {
Expand Down Expand Up @@ -157,7 +159,7 @@ function GridHeader(): ReactNode {
}

function ColumnHeader({ column }: { column: ColumnsPreviewType }): ReactNode {
const { columnsFilterable, columnsSortable, columnsHidable } = useProps();
const { columnsFilterable, columnsSortable, columnsHidable, columnsDraggable } = useProps();
const columnPreview = new ColumnPreview(column, 0);
const caption = columnPreview.header;
const canSort = columnsSortable && columnPreview.canSort;
Expand All @@ -172,6 +174,9 @@ function ColumnHeader({ column }: { column: ColumnsPreviewType }): ReactNode {
>
<div className="column-container">
<div className="column-header">
{columnsDraggable && columnPreview.canDrag && (
<DragHandle draggable={false} onDragStart={() => {}} onDragEnd={() => {}} />
)}
<span>{caption.length > 0 ? caption : "\u00a0"}</span>
{canSort && <FaArrowsAltV />}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ColumnResizerProps>;
}

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 (
<div
aria-sort={getAriaSort(canSort, sortDir)}
className={classNames("th", {
[`drop-${vm.dropTarget?.[1]}`]: columnId === vm.dropTarget?.[0],
dragging: columnId === vm.dragging?.[1],
"dragging-over-self": columnId === vm.dragging?.[1] && !vm.dropTarget
})}
role="columnheader"
style={!canSort ? { cursor: "unset" } : undefined}
title={caption}
ref={ref => column.setHeaderElementRef(ref)}
data-column-id={columnId}
onDrop={vm.isDraggable ? vm.handleOnDrop : undefined}
onDragEnter={vm.isDraggable ? vm.handleDragEnter : undefined}
onDragOver={vm.isDraggable ? vm.handleDragOver : undefined}
>
<div className={classNames("column-container")} id={`${gridId}-column${columnId}`}>
<ColumnHeader />
{columnsFilterable && (
<div className="filter" style={{ pointerEvents: vm.dragging ? "none" : undefined }}>
{columnFilters[columnIndex]?.renderFilterWidgets()}
</div>
)}
</div>
{canResize ? props.resizer : null}
</div>
);
});

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";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import classNames from "classnames";
import { HTMLAttributes, KeyboardEvent, ReactElement, ReactNode } from "react";
import { DragHandle } from "./DragHandle";
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";
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 = useHeaderDragnDropVM();

return (
<div
className={classNames("column-header", { clickable: canSort }, `align-column-${alignment}`)}
style={{ pointerEvents: vm.dragging ? "none" : undefined }}
{...sortProps}
aria-label={canSort ? "sort " + caption : caption}
>
{vm.isDraggable && (
<DragHandle draggable={vm.isDraggable} onDragStart={vm.handleDragStart} onDragEnd={vm.handleDragEnd} />
)}
<span className="column-caption">{caption.length > 0 ? caption : "\u00a0"}</span>
{canSort ? <SortIcon direction={column.sortDir} /> : null}
</div>
);
});

function SortIcon({ direction }: SortIconProps): ReactNode {
switch (direction) {
case "asc":
return <FaLongArrowAltUp />;
case "desc":
return <FaLongArrowAltDown />;
default:
return <FaArrowsAltV />;
}
}

function getSortProps(toggleSort: () => void): HTMLAttributes<HTMLDivElement> {
return {
onClick: () => {
toggleSort();
},
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSort();
}
},
role: "button",
tabIndex: 0
};
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,41 @@
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<HTMLDivElement>(null);
const onStart = useEventCallback(onResizeStart);

const onStartDrag = useCallback(
(e: TouchEvent<HTMLDivElement> & MouseEvent<HTMLDivElement>): void => {
const mouseX = e.touches ? e.touches[0].screenX : e.screenX;
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) {
return;
}
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DragEvent, DragEventHandler, MouseEvent, ReactElement } from "react";
import { FaGripVertical } from "./icons/FaGripVertical";

interface DragHandleProps {
draggable: boolean;
onDragStart?: DragEventHandler<HTMLSpanElement>;
onDragEnd?: DragEventHandler<HTMLSpanElement>;
}
export function DragHandle({ draggable, onDragStart, onDragEnd }: DragHandleProps): ReactElement {
const handleMouseDown = (e: MouseEvent<HTMLSpanElement>): void => {
// Only stop propagation, don't prevent default - we need default for drag to work
e.stopPropagation();
};

const handleClick = (e: MouseEvent<HTMLSpanElement>): void => {
// Stop click events from bubbling to prevent sorting
e.stopPropagation();
e.preventDefault();
};

const handleDragStart = (e: DragEvent<HTMLSpanElement>): void => {
// Don't stop propagation here - let the drag start properly
if (onDragStart) {
onDragStart(e);
}
};

const handleDragEnd = (e: DragEvent<HTMLSpanElement>): void => {
if (onDragEnd) {
onDragEnd(e);
}
};

return (
<span
className="drag-handle"
draggable={draggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onMouseDown={handleMouseDown}
onClick={handleClick}
>
<FaGripVertical />
</span>
);
}
Original file line number Diff line number Diff line change
@@ -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 <HeaderSkeletonLoader size={columns.length} />;
Expand All @@ -25,19 +22,7 @@ export function GridHeader(): ReactElement {
<CheckboxColumnHeader key="headers_column_select_all" />
{columns.map(column => (
<ColumnProvider column={column} key={`${column.columnId}`}>
<Header
dropTarget={dragOver}
isDragging={isDragging}
resizer={
<ColumnResizer
onResizeStart={() => columnsStore.setIsResizing(true)}
onResizeEnds={() => columnsStore.setIsResizing(false)}
setColumnWidth={(width: number) => column.setSize(width)}
/>
}
setDropTarget={setDragOver}
setIsDragging={setIsDragging}
/>
<ColumnContainer resizer={<ColumnResizer />} />
</ColumnProvider>
))}
{columnsHidable && (
Expand Down
Loading
Loading