From 54f32d045434611dfb4fa6d566b3ca4ba287c4fe Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:44:58 +0100 Subject: [PATCH 01/13] refactor: migrate event controllers from DI to React Context for simplified architecture --- .../datagrid-web/src/Datagrid.tsx | 57 ++++++++++++++++- .../src/components/RowsRenderer.tsx | 20 ++---- .../row-interaction/CellEventsController.ts | 61 ++++++++----------- .../datagrid-web/src/helpers/root-context.ts | 23 +++++++ .../src/helpers/useDataGridJSActions.ts | 6 +- .../model/containers/Datagrid.container.ts | 16 +---- .../src/model/containers/Root.container.ts | 2 +- .../src/model/hooks/injection-hooks.ts | 4 -- .../datagrid-web/src/model/tokens.ts | 4 +- .../datagrid-web/src/utils/test-utils.tsx | 1 + .../src/selection/select-action-handler.ts | 2 +- 11 files changed, 115 insertions(+), 81 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 3eeddd6be1..0fd98997a9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -1,22 +1,73 @@ +import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; +import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { ContainerProvider } from "brandi-react"; import { observer } from "mobx-react-lite"; import { ReactElement } from "react"; import { DatagridContainerProps } from "../typings/DatagridProps"; import { Widget } from "./components/Widget"; import { useDataExport } from "./features/data-export/useDataExport"; +import { useCellEventsController } from "./features/row-interaction/CellEventsController"; +import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; +import { LegacyContext } from "./helpers/root-context"; import { useDataGridJSActions } from "./helpers/useDataGridJSActions"; -import { useColumnsStore, useExportProgressService } from "./model/hooks/injection-hooks"; +import { + useColumnsStore, + useDatagridConfig, + useExportProgressService, + useMainGate, + useSelectActions, + useSelectionHelper +} from "./model/hooks/injection-hooks"; import { useDatagridContainer } from "./model/hooks/useDatagridContainer"; const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { + const config = useDatagridConfig(); + const gate = useMainGate(); const columnsStore = useColumnsStore(); const exportProgress = useExportProgressService(); + const items = gate.props.datasource.items ?? []; const [abortExport] = useDataExport(props, columnsStore, exportProgress); - useDataGridJSActions(); + const selectionHelper = useSelectionHelper(); - return ; + const selectActionHelper = useSelectActions(); + + const clickActionHelper = useClickActionHelper({ + onClickTrigger: props.onClickTrigger, + onClick: props.onClick + }); + + useDataGridJSActions(selectActionHelper); + + const visibleColumnsCount = config.checkboxColumnEnabled + ? columnsStore.visibleColumns.length + 1 + : columnsStore.visibleColumns.length; + + const focusController = useFocusTargetController({ + rows: items.length, + columns: visibleColumnsCount, + pageSize: props.pageSize + }); + + const cellEventsController = useCellEventsController(selectActionHelper, clickActionHelper, focusController); + + const checkboxEventsController = useCheckboxEventsController(selectActionHelper, focusController); + + return ( + + + + ); }); DatagridRoot.displayName = "DatagridComponent"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx index 11de6a4e44..65c620c21c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx @@ -1,15 +1,8 @@ import { KeyNavProvider } from "@mendix/widget-plugin-grid/keyboard-navigation/context"; import { observer } from "mobx-react-lite"; import { ReactElement } from "react"; -import { - useCellEventsHandler, - useColumnsStore, - useDatagridConfig, - useFocusService, - useRowClass, - useRows, - useSelectActions -} from "../model/hooks/injection-hooks"; +import { useLegacyContext } from "../helpers/root-context"; +import { useColumnsStore, useDatagridConfig, useRowClass, useRows } from "../model/hooks/injection-hooks"; import { Row } from "./Row"; export const RowsRenderer = observer(function RowsRenderer(): ReactElement { @@ -17,18 +10,15 @@ export const RowsRenderer = observer(function RowsRenderer(): ReactElement { const config = useDatagridConfig(); const { visibleColumns } = useColumnsStore(); const rowClass = useRowClass(); - const cellEventsController = useCellEventsHandler(); - const focusService = useFocusService(); - const selectActions = useSelectActions(); - + const { cellEventsController, focusController, selectActionHelper } = useLegacyContext(); return ( - + {rows.map((item, rowIndex) => { return ( + focusController: FocusTargetController ): CellEventsController { - // Placeholder function, actual implementation will depend on the specific context and services available. - const cellContextFactory = (item: ObjectItem): CellContext => ({ - type: "cell", - item, - pageSize: pageSize.get(), - selectionType: config.selectionType, - selectionMethod: config.selectionMethod, - selectionMode: config.selectionMode, - clickTrigger: clickHelper.clickTrigger - }); + const pageSize = 10; + return useMemo(() => { + const cellContextFactory = (item: ObjectItem): CellContext => ({ + item, + pageSize: selectHelper.pageSize, + selectionType: selectHelper.selectionType, + selectionMethod: selectHelper.selectionMethod, + selectionMode: selectHelper.selectionMode, + clickTrigger: clickHelper.clickTrigger + }); - return new CellEventsController( - cellContextFactory, - selectActions.select, - selectActions.selectPage, - selectActions.selectAdjacent, - clickHelper.onExecuteAction, - focusController.dispatch - ); + return new CellEventsController( + cellContextFactory, + selectHelper.onSelect, + selectHelper.onSelectAll, + selectHelper.onSelectAdjacent, + clickHelper.onExecuteAction, + focusController.dispatch + ); + }, [selectHelper, clickHelper, focusController]); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts new file mode 100644 index 0000000000..95bfeca571 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -0,0 +1,23 @@ +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; +import { SelectionHelperService } from "@mendix/widget-plugin-grid/main"; +import { createContext, useContext } from "react"; +import { SelectActionHelper } from "../model/services/GridSelectActionsProvider.service"; +import { EventsController } from "../typings/CellComponent"; + +export interface LegacyRootScope { + selectionHelper: SelectionHelperService | undefined; + selectActionHelper: SelectActionHelper; + cellEventsController: EventsController; + checkboxEventsController: EventsController; + focusController: FocusTargetController; +} + +export const LegacyContext = createContext(null); + +export const useLegacyContext = (): LegacyRootScope => { + const contextValue = useContext(LegacyContext); + if (!contextValue) { + throw new Error("useDatagridRootScope must be used within a root context provider"); + } + return contextValue; +}; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts index 78f70a245e..2c57da66bc 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts @@ -1,9 +1,9 @@ import { useOnClearSelectionEvent, useOnResetFiltersEvent } from "@mendix/widget-plugin-external-events/hooks"; -import { useDatagridConfig, useSelectActions } from "../model/hooks/injection-hooks"; +import { SelectActionsService } from "@mendix/widget-plugin-grid/main"; +import { useDatagridConfig } from "../model/hooks/injection-hooks"; -export function useDataGridJSActions(): void { +export function useDataGridJSActions(selectActions: SelectActionsService): void { const info = useDatagridConfig(); - const selectActions = useSelectActions(); useOnResetFiltersEvent(info.name, info.filtersChannelName); useOnClearSelectionEvent({ widgetName: info.name, 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..9fe1e18136 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -18,7 +18,6 @@ import { Container, injected } from "brandi"; import { MainGateProps } from "../../../typings/MainGateProps"; import { WidgetRootViewModel } from "../../features/base/WidgetRoot.viewModel"; import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel"; -import { createCellEventsController } from "../../features/row-interaction/CellEventsController"; import { creteCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController"; import { SelectAllModule } from "../../features/select-all/SelectAllModule.container"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; @@ -39,7 +38,7 @@ injected(DatasourceParamsController, CORE.setupService, DG.query, DG.combinedFil injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval.optional); injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query); injected(GridBasicData, CORE.mainGate); -injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.selectionDialogVM); +injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.progressService); // loader injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig); @@ -67,14 +66,6 @@ injected(createFocusController, CORE.setupService, DG.virtualLayout); injected(creteCheckboxEventsController, CORE.config, DG.selectActions, DG.focusService, CORE.atoms.pageSize); injected(layoutAtom, CORE.atoms.itemCount, CORE.atoms.columnCount, CORE.atoms.pageSize); injected(createClickActionHelper, CORE.setupService, CORE.mainGate); -injected( - createCellEventsController, - CORE.config, - DG.selectActions, - DG.focusService, - DG.clickActionHelper, - CORE.atoms.pageSize -); // selection counter injected( @@ -134,8 +125,6 @@ export class DatagridContainer extends Container { this.bind(DG.focusService).toInstance(createFocusController).inSingletonScope(); // Checkbox events service this.bind(DG.checkboxEventsHandler).toInstance(creteCheckboxEventsController).inSingletonScope(); - // Cell events service - this.bind(DG.cellEventsHandler).toInstance(createCellEventsController).inSingletonScope(); // Click action helper this.bind(DG.clickActionHelper).toInstance(createClickActionHelper).inSingletonScope(); } @@ -201,9 +190,6 @@ export class DatagridContainer extends Container { // Bind selection counter position this.bind(DG.selectionCounterCfg).toConstant({ position: props.selectionCounterPosition }); - // Bind selection type - this.bind(DG.selectionType).toConstant(config.selectionType); - this.postInit(props, config); return this; diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts index 30ee19b4b9..7b69a93ad9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts @@ -32,7 +32,7 @@ injected(visibleColumnsCountAtom, CORE.columnsStore); injected(isAllItemsPresentAtom, CORE.atoms.offset, CORE.atoms.hasMoreItems); injected(rowsAtom, CORE.mainGate); injected(pageSizeAtom, CORE.pageSizeStore); -injected(columnCount, CORE.atoms.visibleColumnsCount, CORE.config); +injected(columnCount, CORE.atoms.columnCount, CORE.config); // selection injected( 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..bedd18ddc1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -20,7 +20,3 @@ 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 [useClickActionHelper] = createInjectionHooks(DG.clickActionHelper); -export const [useFocusService] = createInjectionHooks(DG.focusService); -export const [useCheckboxEventsHandler] = createInjectionHooks(DG.checkboxEventsHandler); -export const [useCellEventsHandler] = createInjectionHooks(DG.cellEventsHandler); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index 26b3e38201..f5dd9f9720 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -26,7 +26,6 @@ import { CSSProperties, ReactNode } from "react"; import { MainGateProps } from "../../typings/MainGateProps"; import { WidgetRootViewModel } from "../features/base/WidgetRoot.viewModel"; import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel"; -import { CellEventsController } from "../features/row-interaction/CellEventsController"; import { CheckboxEventsController } from "../features/row-interaction/CheckboxEventsController"; import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel"; import { SelectionProgressDialogViewModel } from "../features/select-all/SelectionProgressDialog.viewModel"; @@ -130,8 +129,7 @@ export const DG_TOKENS = { virtualLayout: token>("@computed:virtualLayout"), clickActionHelper: token("@service:ClickActionHelper"), focusService: token("@service:FocusTargetController"), - checkboxEventsHandler: token("@service:CheckboxEventsController"), - cellEventsHandler: token("@service:CellEventsController") + checkboxEventsHandler: token("@service:CheckboxEventsController") }; /** "Select all" module tokens. */ diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index e8bc5eb194..baf257b70d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -2,6 +2,7 @@ import { dynamic, list, listAttribute, listExpression } from "@mendix/widget-plu import { ColumnsType, DatagridContainerProps } from "../../typings/DatagridProps"; import { ColumnStore } from "../helpers/state/column/ColumnStore"; import { IColumnParentStore } from "../helpers/state/ColumnGroupStore"; +import { SelectActionHelper } from "../model/services/GridSelectActionsProvider.service"; import { ColumnId, GridColumn } from "../typings/GridColumn"; export const column = (header = "Test", patch?: (col: ColumnsType) => void): ColumnsType => { 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 { From b99c478c41ab9d118fb7d1a0a21632ae5b249aed Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:47:40 +0100 Subject: [PATCH 02/13] refactor: migrate event handlers to container --- .../datagrid-web/src/Datagrid.tsx | 57 +---------------- .../src/components/RowsRenderer.tsx | 20 ++++-- .../row-interaction/CellEventsController.ts | 61 +++++++++++-------- .../datagrid-web/src/helpers/root-context.ts | 23 ------- .../src/helpers/useDataGridJSActions.ts | 6 +- .../model/containers/Datagrid.container.ts | 16 ++++- .../src/model/containers/Root.container.ts | 2 +- .../src/model/hooks/injection-hooks.ts | 4 ++ .../datagrid-web/src/model/tokens.ts | 4 +- 9 files changed, 80 insertions(+), 113 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 0fd98997a9..3eeddd6be1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -1,73 +1,22 @@ -import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; -import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; -import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { ContainerProvider } from "brandi-react"; import { observer } from "mobx-react-lite"; import { ReactElement } from "react"; import { DatagridContainerProps } from "../typings/DatagridProps"; import { Widget } from "./components/Widget"; import { useDataExport } from "./features/data-export/useDataExport"; -import { useCellEventsController } from "./features/row-interaction/CellEventsController"; -import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; -import { LegacyContext } from "./helpers/root-context"; import { useDataGridJSActions } from "./helpers/useDataGridJSActions"; -import { - useColumnsStore, - useDatagridConfig, - useExportProgressService, - useMainGate, - useSelectActions, - useSelectionHelper -} from "./model/hooks/injection-hooks"; +import { useColumnsStore, useExportProgressService } from "./model/hooks/injection-hooks"; import { useDatagridContainer } from "./model/hooks/useDatagridContainer"; const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { - const config = useDatagridConfig(); - const gate = useMainGate(); const columnsStore = useColumnsStore(); const exportProgress = useExportProgressService(); - const items = gate.props.datasource.items ?? []; const [abortExport] = useDataExport(props, columnsStore, exportProgress); - const selectionHelper = useSelectionHelper(); + useDataGridJSActions(); - const selectActionHelper = useSelectActions(); - - const clickActionHelper = useClickActionHelper({ - onClickTrigger: props.onClickTrigger, - onClick: props.onClick - }); - - useDataGridJSActions(selectActionHelper); - - const visibleColumnsCount = config.checkboxColumnEnabled - ? columnsStore.visibleColumns.length + 1 - : columnsStore.visibleColumns.length; - - const focusController = useFocusTargetController({ - rows: items.length, - columns: visibleColumnsCount, - pageSize: props.pageSize - }); - - const cellEventsController = useCellEventsController(selectActionHelper, clickActionHelper, focusController); - - const checkboxEventsController = useCheckboxEventsController(selectActionHelper, focusController); - - return ( - - - - ); + return ; }); DatagridRoot.displayName = "DatagridComponent"; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx index 65c620c21c..11de6a4e44 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx @@ -1,8 +1,15 @@ import { KeyNavProvider } from "@mendix/widget-plugin-grid/keyboard-navigation/context"; import { observer } from "mobx-react-lite"; import { ReactElement } from "react"; -import { useLegacyContext } from "../helpers/root-context"; -import { useColumnsStore, useDatagridConfig, useRowClass, useRows } from "../model/hooks/injection-hooks"; +import { + useCellEventsHandler, + useColumnsStore, + useDatagridConfig, + useFocusService, + useRowClass, + useRows, + useSelectActions +} from "../model/hooks/injection-hooks"; import { Row } from "./Row"; export const RowsRenderer = observer(function RowsRenderer(): ReactElement { @@ -10,15 +17,18 @@ export const RowsRenderer = observer(function RowsRenderer(): ReactElement { const config = useDatagridConfig(); const { visibleColumns } = useColumnsStore(); const rowClass = useRowClass(); - const { cellEventsController, focusController, selectActionHelper } = useLegacyContext(); + const cellEventsController = useCellEventsHandler(); + const focusService = useFocusService(); + const selectActions = useSelectActions(); + return ( - + {rows.map((item, rowIndex) => { return ( ): CellEventsController { - const pageSize = 10; - return useMemo(() => { - const cellContextFactory = (item: ObjectItem): CellContext => ({ - item, - pageSize: selectHelper.pageSize, - selectionType: selectHelper.selectionType, - selectionMethod: selectHelper.selectionMethod, - selectionMode: selectHelper.selectionMode, - clickTrigger: clickHelper.clickTrigger - }); + // Placeholder function, actual implementation will depend on the specific context and services available. + const cellContextFactory = (item: ObjectItem): CellContext => ({ + type: "cell", + item, + pageSize: pageSize.get(), + selectionType: config.selectionType, + selectionMethod: config.selectionMethod, + selectionMode: config.selectionMode, + clickTrigger: clickHelper.clickTrigger + }); - return new CellEventsController( - cellContextFactory, - selectHelper.onSelect, - selectHelper.onSelectAll, - selectHelper.onSelectAdjacent, - clickHelper.onExecuteAction, - focusController.dispatch - ); - }, [selectHelper, clickHelper, focusController]); + return new CellEventsController( + cellContextFactory, + selectActions.select, + selectActions.selectPage, + selectActions.selectAdjacent, + clickHelper.onExecuteAction, + focusController.dispatch + ); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts deleted file mode 100644 index 95bfeca571..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { SelectionHelperService } from "@mendix/widget-plugin-grid/main"; -import { createContext, useContext } from "react"; -import { SelectActionHelper } from "../model/services/GridSelectActionsProvider.service"; -import { EventsController } from "../typings/CellComponent"; - -export interface LegacyRootScope { - selectionHelper: SelectionHelperService | undefined; - selectActionHelper: SelectActionHelper; - cellEventsController: EventsController; - checkboxEventsController: EventsController; - focusController: FocusTargetController; -} - -export const LegacyContext = createContext(null); - -export const useLegacyContext = (): LegacyRootScope => { - const contextValue = useContext(LegacyContext); - if (!contextValue) { - throw new Error("useDatagridRootScope must be used within a root context provider"); - } - return contextValue; -}; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts index 2c57da66bc..78f70a245e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts @@ -1,9 +1,9 @@ import { useOnClearSelectionEvent, useOnResetFiltersEvent } from "@mendix/widget-plugin-external-events/hooks"; -import { SelectActionsService } from "@mendix/widget-plugin-grid/main"; -import { useDatagridConfig } from "../model/hooks/injection-hooks"; +import { useDatagridConfig, useSelectActions } from "../model/hooks/injection-hooks"; -export function useDataGridJSActions(selectActions: SelectActionsService): void { +export function useDataGridJSActions(): void { const info = useDatagridConfig(); + const selectActions = useSelectActions(); useOnResetFiltersEvent(info.name, info.filtersChannelName); useOnClearSelectionEvent({ widgetName: info.name, 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 9fe1e18136..fd99730e77 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -18,6 +18,7 @@ import { Container, injected } from "brandi"; import { MainGateProps } from "../../../typings/MainGateProps"; import { WidgetRootViewModel } from "../../features/base/WidgetRoot.viewModel"; import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel"; +import { createCellEventsController } from "../../features/row-interaction/CellEventsController"; import { creteCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController"; import { SelectAllModule } from "../../features/select-all/SelectAllModule.container"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; @@ -38,7 +39,7 @@ injected(DatasourceParamsController, CORE.setupService, DG.query, DG.combinedFil injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval.optional); injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query); injected(GridBasicData, CORE.mainGate); -injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.progressService); +injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.selectionDialogVM); // loader injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig); @@ -66,6 +67,14 @@ injected(createFocusController, CORE.setupService, DG.virtualLayout); injected(creteCheckboxEventsController, CORE.config, DG.selectActions, DG.focusService, CORE.atoms.pageSize); injected(layoutAtom, CORE.atoms.itemCount, CORE.atoms.columnCount, CORE.atoms.pageSize); injected(createClickActionHelper, CORE.setupService, CORE.mainGate); +injected( + createCellEventsController, + CORE.config, + DG.selectActions, + DG.focusService, + DG.clickActionHelper, + CORE.atoms.pageSize +); // selection counter injected( @@ -125,6 +134,8 @@ export class DatagridContainer extends Container { this.bind(DG.focusService).toInstance(createFocusController).inSingletonScope(); // Checkbox events service this.bind(DG.checkboxEventsHandler).toInstance(creteCheckboxEventsController).inSingletonScope(); + // Cell events service + this.bind(DG.cellEventsHandler).toInstance(createCellEventsController).inSingletonScope(); // Click action helper this.bind(DG.clickActionHelper).toInstance(createClickActionHelper).inSingletonScope(); } @@ -190,6 +201,9 @@ export class DatagridContainer extends Container { // Bind selection counter position this.bind(DG.selectionCounterCfg).toConstant({ position: props.selectionCounterPosition }); + // Bind selection type + this.bind(DG.selectionType).toConstant(config.selectionType); + this.postInit(props, config); return this; diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts index 7b69a93ad9..30ee19b4b9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts @@ -32,7 +32,7 @@ injected(visibleColumnsCountAtom, CORE.columnsStore); injected(isAllItemsPresentAtom, CORE.atoms.offset, CORE.atoms.hasMoreItems); injected(rowsAtom, CORE.mainGate); injected(pageSizeAtom, CORE.pageSizeStore); -injected(columnCount, CORE.atoms.columnCount, CORE.config); +injected(columnCount, CORE.atoms.visibleColumnsCount, CORE.config); // selection injected( 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 bedd18ddc1..337a03637f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -20,3 +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 [useClickActionHelper] = createInjectionHooks(DG.clickActionHelper); +export const [useFocusService] = createInjectionHooks(DG.focusService); +export const [useCheckboxEventsHandler] = createInjectionHooks(DG.checkboxEventsHandler); +export const [useCellEventsHandler] = createInjectionHooks(DG.cellEventsHandler); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index f5dd9f9720..26b3e38201 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -26,6 +26,7 @@ import { CSSProperties, ReactNode } from "react"; import { MainGateProps } from "../../typings/MainGateProps"; import { WidgetRootViewModel } from "../features/base/WidgetRoot.viewModel"; import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel"; +import { CellEventsController } from "../features/row-interaction/CellEventsController"; import { CheckboxEventsController } from "../features/row-interaction/CheckboxEventsController"; import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel"; import { SelectionProgressDialogViewModel } from "../features/select-all/SelectionProgressDialog.viewModel"; @@ -129,7 +130,8 @@ export const DG_TOKENS = { virtualLayout: token>("@computed:virtualLayout"), clickActionHelper: token("@service:ClickActionHelper"), focusService: token("@service:FocusTargetController"), - checkboxEventsHandler: token("@service:CheckboxEventsController") + checkboxEventsHandler: token("@service:CheckboxEventsController"), + cellEventsHandler: token("@service:CellEventsController") }; /** "Select all" module tokens. */ From b754be7cc3ccb1896ce7ab9a0bf38e8ba6142e32 Mon Sep 17 00:00:00 2001 From: Yordan Date: Thu, 20 Nov 2025 13:02:24 +0100 Subject: [PATCH 03/13] refactor: extract drag & drop state and logic to mobx --- .../features/column/ColumnHeader.viewModel.ts | 114 ++++++++++++++++++ .../features/column/HeaderDragnDrop.store.ts | 36 ++++++ .../model/containers/Datagrid.container.ts | 3 + .../src/model/hooks/injection-hooks.ts | 1 + .../datagrid-web/src/model/tokens.ts | 2 + 5 files changed, 156 insertions(+) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts new file mode 100644 index 0000000000..27be4adede --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts @@ -0,0 +1,114 @@ +import { makeAutoObservable } from "mobx"; +import { DragEvent, DragEventHandler } from "react"; +import { HeaderDragnDropStore } from "./HeaderDragnDrop.store"; +import { ColumnId } 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. + */ +export class ColumnHeaderViewModel { + private readonly dndStore: HeaderDragnDropStore; + private readonly columnsStore: ColumnGroupStore; + private readonly columnsDraggable: boolean; + + constructor(params: { dndStore: HeaderDragnDropStore; columnsStore: ColumnGroupStore; columnsDraggable: boolean }) { + this.dndStore = params.dndStore; + this.columnsStore = params.columnsStore; + this.columnsDraggable = params.columnsDraggable; + makeAutoObservable(this); + } + + get dropTarget(): [ColumnId, "before" | "after"] | undefined { + return this.dndStore.dragOver; + } + + get dragging(): [ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined { + return this.dndStore.isDragging; + } + + /** Handlers exposed to the component. */ + get draggableProps(): { + draggable?: boolean; + onDragStart?: DragEventHandler; + onDragOver?: DragEventHandler; + onDrop?: DragEventHandler; + onDragEnter?: DragEventHandler; + onDragEnd?: DragEventHandler; + } { + if (!this.columnsDraggable) { + return {}; + } + return { + draggable: true, + onDragStart: this.handleDragStart, + onDragOver: this.handleDragOver, + onDrop: this.handleOnDrop, + onDragEnter: this.handleDragEnter, + onDragEnd: this.handleDragEnd + }; + } + + private 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]); + }; + + private 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]); + } + }; + + private handleDragEnter = (e: DragEvent): void => { + e.preventDefault(); + }; + + private handleDragEnd = (): void => { + this.dndStore.clearDragState(); + }; + + private 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/HeaderDragnDrop.store.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts new file mode 100644 index 0000000000..14090ab1ae --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts @@ -0,0 +1,36 @@ +import { action, makeAutoObservable } from "mobx"; +import { ColumnId } from "../../typings/GridColumn"; + +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/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts index fd99730e77..47574ed046 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,7 @@ 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 { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore"; import { DatagridConfig } from "../configs/Datagrid.config"; @@ -94,6 +95,8 @@ 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(); // 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..3b370dac6c 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 [useHeaderDragDrop] = createInjectionHooks(DG.headerDragDrop); 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..cebac366c0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -33,6 +33,7 @@ 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 { DatasourceParamsController } from "../model/services/DatasourceParamsController"; import { GridColumn } from "../typings/GridColumn"; import { DatagridConfig } from "./configs/Datagrid.config"; @@ -131,6 +132,7 @@ export const DG_TOKENS = { clickActionHelper: token("@service:ClickActionHelper"), focusService: token("@service:FocusTargetController"), checkboxEventsHandler: token("@service:CheckboxEventsController"), + headerDragDrop: token("HeaderDragnDropStore"), cellEventsHandler: token("@service:CellEventsController") }; From 54fd9eee838caaa193b8eda9bf96befcda1f08d7 Mon Sep 17 00:00:00 2001 From: Yordan Date: Thu, 20 Nov 2025 13:04:03 +0100 Subject: [PATCH 04/13] refactor: update components to use new state management --- .../src/components/ColumnContainer.tsx | 128 ++++++++++ .../src/components/ColumnHeader.tsx | 29 +++ .../src/components/GridHeader.tsx | 13 +- .../datagrid-web/src/components/Header.tsx | 239 ------------------ 4 files changed, 160 insertions(+), 249 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/Header.tsx 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..1697f15102 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -0,0 +1,128 @@ +import classNames from "classnames"; +import { HTMLAttributes, KeyboardEvent, ReactElement, ReactNode, useMemo } from "react"; +import { FaArrowsAltV } from "./icons/FaArrowsAltV"; +import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; +import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; + +import ColumnHeader from "./ColumnHeader"; + +import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDragDrop } from "../model/hooks/injection-hooks"; +import { GridColumn } from "../typings/GridColumn"; +import { ColumnResizerProps } from "./ColumnResizer"; +import { ColumnHeaderViewModel } from "../features/column/ColumnHeader.viewModel"; +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 columnsStore = useColumnsStore(); + const column = useColumn(); + const { canDrag, canSort } = column; + + const headerDragDropStore = useHeaderDragDrop(); + const columnHeaderVM = useMemo( + () => + new ColumnHeaderViewModel({ + dndStore: headerDragDropStore, + columnsStore, + columnsDraggable: canDrag + }), + [headerDragDropStore, columnsStore, canDrag] + ); + const draggableProps = columnHeaderVM.draggableProps; + const dropTarget = columnHeaderVM.dropTarget; + const isDragging = columnHeaderVM.dragging; + + 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} + > +
+ + {canSort ? : null} + + {columnsFilterable && ( +
+ {columnsStore.columnFilters[column.columnIndex]?.renderFilterWidgets()} +
+ )} +
+ {column.canResize ? props.resizer : null} +
+ ); +}); + +function SortIcon(): ReactNode { + const column = useColumn(); + 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/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx new file mode 100644 index 0000000000..a27a18e7af --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -0,0 +1,29 @@ +import classNames from "classnames"; +import { HTMLAttributes, ReactElement, ReactNode } from "react"; + +export interface ColumnHeaderProps { + children?: ReactNode; + sortProps?: HTMLAttributes | null; + canSort: boolean; + caption: string; + isDragging?: [string | undefined, string, string | undefined] | undefined; + columnAlignment?: "left" | "center" | "right"; +} + +export default function ColumnHeader(props: ColumnHeaderProps): ReactElement { + return ( +
+ {props.caption.length > 0 ? props.caption : "\u00a0"} + {props.children} +
+ ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx index 39b86c6d3a..7b4579930b 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,9 +22,7 @@ export function GridHeader(): ReactElement { {columns.map(column => ( -
columnsStore.setIsResizing(true)} @@ -35,8 +30,6 @@ export function GridHeader(): ReactElement { setColumnWidth={(width: number) => column.setSize(width)} /> } - setDropTarget={setDragOver} - setIsDragging={setIsDragging} /> ))} 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 - }; -} From 262d89115dc01346139257f10526b59b8e6bc2f7 Mon Sep 17 00:00:00 2001 From: Yordan Date: Thu, 20 Nov 2025 13:05:33 +0100 Subject: [PATCH 05/13] refactor: remove obsolete tests and snapshots, create for new component --- .../src/components/__tests__/Header.spec.tsx | 177 ----------- .../__snapshots__/Header.spec.tsx.snap | 197 ------------ .../__tests__/ColumnHeader.viewModel.spec.ts | 299 ++++++++++++++++++ 3 files changed, 299 insertions(+), 374 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Header.spec.tsx.snap create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx deleted file mode 100644 index defbdfd369..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { dynamic } from "@mendix/widget-plugin-test-utils"; -import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ContainerProvider } from "brandi-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 { ColumnResizer } from "../ColumnResizer"; -import { Header, HeaderProps } from "../Header"; - -describe("Header", () => { - it("renders the structure correctly", () => { - const props = mockContainerProps({ - columns: [column("Column 1")] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - const component = render( - - -
- - - ); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when sortable", () => { - const columnsType = column("Column 1", col => { - col.sortable = true; - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - const component = render( - - -
- - - ); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when resizable", () => { - const columnsType = column("Column 1", col => { - col.resizable = true; - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - const component = render( - - -
resizer} /> - - - ); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when draggable", () => { - const columnsType = column("Column 1", col => { - col.draggable = true; - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - const component = render( - - -
- - - ); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly when filterable with custom filter", () => { - const columnsType = column("Column 1", col => { - col.filter =
Custom filter
; - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - const component = render( - - -
- - - ); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("calls sort function when sortable", async () => { - const user = userEvent.setup(); - const columnsType = column("Column 1", col => { - col.sortable = true; - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - const spy = jest.spyOn(col, "toggleSort"); - - const component = render( - - -
- - - ); - const button = component.getByLabelText("sort Column 1"); - - expect(button).toBeInTheDocument(); - await user.click(button); - expect(spy).toHaveBeenCalled(); - }); - - it("renders the structure correctly when value is empty", () => { - const columnsType = column("Column 1", col => { - col.header = dynamic(" "); - }); - const props = mockContainerProps({ - columns: [columnsType] - }); - const [container] = createDatagridContainer(props); - const columns = container.get(CORE_TOKENS.columnsStore); - const col = columns.visibleColumns[0]; - - 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__/__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/features/column/__tests__/ColumnHeader.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts new file mode 100644 index 0000000000..bb8b0dde39 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts @@ -0,0 +1,299 @@ +import { DragEvent } from "react"; +import { ColumnHeaderViewModel } from "../ColumnHeader.viewModel"; +import { HeaderDragnDropStore } from "../HeaderDragnDrop.store"; +import { ColumnId } from "../../../typings/GridColumn"; + +describe("ColumnHeaderViewModel", () => { + let dndStore: HeaderDragnDropStore; + let mockColumnsStore: any; + + beforeEach(() => { + dndStore = new HeaderDragnDropStore(); + mockColumnsStore = { + swapColumns: jest.fn() + }; + }); + + describe("when columnsDraggable is false", () => { + it("returns empty draggableProps", () => { + const vm = new ColumnHeaderViewModel({ + dndStore, + columnsStore: mockColumnsStore, + columnsDraggable: false + }); + + expect(vm.draggableProps).toEqual({}); + }); + }); + + describe("when columnsDraggable is true", () => { + let vm: ColumnHeaderViewModel; + + beforeEach(() => { + vm = new ColumnHeaderViewModel({ + dndStore, + columnsStore: mockColumnsStore, + columnsDraggable: true + }); + }); + + it("returns draggable props with handlers", () => { + const props = vm.draggableProps; + + expect(props.draggable).toBe(true); + expect(props.onDragStart).toBeDefined(); + expect(props.onDragOver).toBeDefined(); + expect(props.onDrop).toBeDefined(); + expect(props.onDragEnter).toBeDefined(); + expect(props.onDragEnd).toBeDefined(); + }); + + describe("dropTarget", () => { + it("returns undefined initially", () => { + expect(vm.dropTarget).toBeUndefined(); + }); + + it("returns value from dndStore", () => { + dndStore.setDragOver(["col1" as ColumnId, "after"]); + expect(vm.dropTarget).toEqual(["col1", "after"]); + }); + }); + + describe("dragging", () => { + it("returns undefined initially", () => { + expect(vm.dragging).toBeUndefined(); + }); + + it("returns value from dndStore", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + expect(vm.dragging).toEqual(["col0", "col1", "col2"]); + }); + }); + + describe("handleDragStart", () => { + it("sets dragging state with column siblings", () => { + const mockElement = createMockElement("col1", "col0", "col2"); + const event = createMockDragEvent(mockElement); + + vm.draggableProps.onDragStart?.(event); + + expect(dndStore.isDragging).toEqual(["col0", "col1", "col2"]); + }); + + it("handles missing previous sibling", () => { + const mockElement = createMockElement("col1", undefined, "col2"); + const event = createMockDragEvent(mockElement); + + vm.draggableProps.onDragStart?.(event); + + expect(dndStore.isDragging).toEqual([undefined, "col1", "col2"]); + }); + + it("handles missing next sibling", () => { + const mockElement = createMockElement("col1", "col0", undefined); + const event = createMockDragEvent(mockElement); + + vm.draggableProps.onDragStart?.(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.draggableProps.onDragStart?.(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.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("does nothing when columnId is missing", () => { + const event = createMockDragOverEvent("", 100, 50); + + vm.draggableProps.onDragOver?.(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.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("sets dropTarget to before when hovering over left sibling", () => { + const event = createMockDragOverEvent("col0", 100, 50); + + vm.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toEqual(["col0", "before"]); + }); + + it("sets dropTarget to after when hovering over right sibling", () => { + const event = createMockDragOverEvent("col2", 100, 50); + + vm.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toEqual(["col2", "after"]); + }); + + it("sets dropTarget to before when hovering on left side of non-sibling column", () => { + const event = createMockDragOverEvent("col5", 100, 30); + + vm.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toEqual(["col5", "before"]); + }); + + it("sets dropTarget to after when hovering on right side of non-sibling column", () => { + const event = createMockDragOverEvent("col5", 100, 70); + + vm.draggableProps.onDragOver?.(event); + + expect(dndStore.dragOver).toEqual(["col5", "after"]); + }); + + it("does not update dropTarget if it hasn't changed", () => { + dndStore.setDragOver(["col5" as ColumnId, "after"]); + const setDragOverSpy = jest.spyOn(dndStore, "setDragOver"); + const event = createMockDragOverEvent("col5", 100, 70); + + vm.draggableProps.onDragOver?.(event); + + expect(setDragOverSpy).not.toHaveBeenCalled(); + }); + + it("prevents default behavior", () => { + const event = createMockDragOverEvent("col2", 100, 50); + + vm.draggableProps.onDragOver?.(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe("handleDragEnter", () => { + it("prevents default behavior", () => { + const event = { preventDefault: jest.fn() } as any; + + vm.draggableProps.onDragEnter?.(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.draggableProps.onDragEnd?.({} as any); + + 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.draggableProps.onDrop?.({} 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.draggableProps.onDrop?.({} as any); + + expect(dndStore.isDragging).toBeUndefined(); + expect(dndStore.dragOver).toBeUndefined(); + }); + + it("does not call swapColumns when not dragging", () => { + dndStore.setDragOver(["col3" as ColumnId, "after"]); + + vm.draggableProps.onDrop?.({} as any); + + expect(mockColumnsStore.swapColumns).not.toHaveBeenCalled(); + }); + + it("does not call swapColumns when no dropTarget", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + + vm.draggableProps.onDrop?.({} as any); + + expect(mockColumnsStore.swapColumns).not.toHaveBeenCalled(); + }); + + it("clears drag state even when drop is invalid", () => { + dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); + + vm.draggableProps.onDrop?.({} 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; +} From b4a467a818fcd25d6ff5ab222301fb211e846d6c Mon Sep 17 00:00:00 2001 From: Yordan Date: Thu, 20 Nov 2025 14:30:27 +0100 Subject: [PATCH 06/13] refactor: rewrite columnreszier to use injection hooks --- .../src/components/ColumnResizer.tsx | 28 ++++++++----------- .../src/components/GridHeader.tsx | 10 +------ 2 files changed, 12 insertions(+), 26 deletions(-) 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 7b4579930b..029dfc3bae 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx @@ -22,15 +22,7 @@ export function GridHeader(): ReactElement { {columns.map(column => ( - columnsStore.setIsResizing(true)} - onResizeEnds={() => columnsStore.setIsResizing(false)} - setColumnWidth={(width: number) => column.setSize(width)} - /> - } - /> + } /> ))} {columnsHidable && ( From b731bac2b0e680ffebcecd236046332fafc7ffde Mon Sep 17 00:00:00 2001 From: Yordan Date: Thu, 20 Nov 2025 14:49:41 +0100 Subject: [PATCH 07/13] refactor: enhance ColumnResizer test structure and update snapshot --- .../__tests__/ColumnResizer.spec.tsx | 18 +++++++++++++++++- .../__snapshots__/ColumnResizer.spec.tsx.snap | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) 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__/ColumnResizer.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap index 35e61c875c..531e05370c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap @@ -13,6 +13,7 @@ exports[`Column Resizer renders the structure correctly 1`] = ` class="column-resizer-bar" />
+
, "container":
@@ -24,6 +25,7 @@ exports[`Column Resizer renders the structure correctly 1`] = ` class="column-resizer-bar" />
+
, "debug": [Function], "findAllByAltText": [Function], From 838cc4712580c0483c1bb4cdb3fbff689a408a39 Mon Sep 17 00:00:00 2001 From: Yordan Date: Fri, 21 Nov 2025 10:25:26 +0100 Subject: [PATCH 08/13] refactor: fix failing test --- .../__tests__/__snapshots__/ColumnResizer.spec.tsx.snap | 2 -- packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap index 531e05370c..35e61c875c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnResizer.spec.tsx.snap @@ -13,7 +13,6 @@ exports[`Column Resizer renders the structure correctly 1`] = ` class="column-resizer-bar" />
-
, "container":
@@ -25,7 +24,6 @@ exports[`Column Resizer renders the structure correctly 1`] = ` class="column-resizer-bar" />
- , "debug": [Function], "findAllByAltText": [Function], diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index baf257b70d..e8bc5eb194 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -2,7 +2,6 @@ import { dynamic, list, listAttribute, listExpression } from "@mendix/widget-plu import { ColumnsType, DatagridContainerProps } from "../../typings/DatagridProps"; import { ColumnStore } from "../helpers/state/column/ColumnStore"; import { IColumnParentStore } from "../helpers/state/ColumnGroupStore"; -import { SelectActionHelper } from "../model/services/GridSelectActionsProvider.service"; import { ColumnId, GridColumn } from "../typings/GridColumn"; export const column = (header = "Test", patch?: (col: ColumnsType) => void): ColumnsType => { From fa90ebae152d54944877714a07a93712faa3d84d Mon Sep 17 00:00:00 2001 From: Yordan Date: Fri, 21 Nov 2025 15:19:22 +0100 Subject: [PATCH 09/13] feat: enhance drag-and-drop functionality with DragHandle component --- .../src/components/ColumnContainer.tsx | 64 ++++++++++++++++--- .../src/components/ColumnHeader.tsx | 1 - 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx index 1697f15102..859d7fa41c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { HTMLAttributes, KeyboardEvent, ReactElement, ReactNode, useMemo } from "react"; +import { DragEventHandler, HTMLAttributes, KeyboardEvent, ReactElement, ReactNode, useMemo } from "react"; import { FaArrowsAltV } from "./icons/FaArrowsAltV"; import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; @@ -16,6 +16,11 @@ export interface ColumnContainerProps { isLast?: boolean; resizer: ReactElement; } +interface DragHandleProps { + draggable: boolean; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; +} export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { const { columnsFilterable, id: gridId } = useDatagridConfig(); @@ -57,13 +62,7 @@ export const ColumnContainer = observer(function ColumnContainer(props: ColumnCo onDragEnter={draggableProps.onDragEnter} onDragOver={draggableProps.onDragOver} > -
+
+ {draggableProps.draggable && ( + + )} + + {caption.length > 0 ? caption : "\u00a0"} + {canSort ? : null} {columnsFilterable && ( @@ -84,6 +93,45 @@ export const ColumnContainer = observer(function ColumnContainer(props: ColumnCo ); }); +function DragHandle({ draggable, onDragStart, onDragEnd }: DragHandleProps): ReactElement { + const handleMouseDown = (e: React.MouseEvent) => { + // Only stop propagation, don't prevent default - we need default for drag to work + e.stopPropagation(); + }; + + const handleClick = (e: React.MouseEvent) => { + // Stop click events from bubbling to prevent sorting + e.stopPropagation(); + e.preventDefault(); + }; + + const handleDragStart = (e: React.DragEvent) => { + // Don't stop propagation here - let the drag start properly + if (onDragStart) { + onDragStart(e); + } + }; + + const handleDragEnd = (e: React.DragEvent) => { + if (onDragEnd) { + onDragEnd(e); + } + }; + + return ( + + ⠿ + + ); +} + function SortIcon(): ReactNode { const column = useColumn(); switch (column.sortDir) { diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx index a27a18e7af..710d244461 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -22,7 +22,6 @@ export default function ColumnHeader(props: ColumnHeaderProps): ReactElement { {...props.sortProps} aria-label={props.canSort ? "sort " + props.caption : props.caption} > - {props.caption.length > 0 ? props.caption : "\u00a0"} {props.children}
); From e90088b879816deef983751e129ad28bceff7764 Mon Sep 17 00:00:00 2001 From: Yordan Date: Fri, 21 Nov 2025 15:31:35 +0100 Subject: [PATCH 10/13] refactor: fix lint errors --- .../datawidgets/web/_datagrid.scss | 18 ++++++++++++++++++ .../src/components/ColumnContainer.tsx | 19 ++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) 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..9227652f4f 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,24 @@ $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); + } + } + &: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 index 859d7fa41c..060a620475 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -1,5 +1,14 @@ import classNames from "classnames"; -import { DragEventHandler, HTMLAttributes, KeyboardEvent, ReactElement, ReactNode, useMemo } from "react"; +import { + DragEvent, + DragEventHandler, + HTMLAttributes, + KeyboardEvent, + MouseEvent, + ReactElement, + ReactNode, + useMemo +} from "react"; import { FaArrowsAltV } from "./icons/FaArrowsAltV"; import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; @@ -94,25 +103,25 @@ export const ColumnContainer = observer(function ColumnContainer(props: ColumnCo }); function DragHandle({ draggable, onDragStart, onDragEnd }: DragHandleProps): ReactElement { - const handleMouseDown = (e: React.MouseEvent) => { + const handleMouseDown = (e: MouseEvent): void => { // Only stop propagation, don't prevent default - we need default for drag to work e.stopPropagation(); }; - const handleClick = (e: React.MouseEvent) => { + const handleClick = (e: MouseEvent): void => { // Stop click events from bubbling to prevent sorting e.stopPropagation(); e.preventDefault(); }; - const handleDragStart = (e: React.DragEvent) => { + const handleDragStart = (e: DragEvent): void => { // Don't stop propagation here - let the drag start properly if (onDragStart) { onDragStart(e); } }; - const handleDragEnd = (e: React.DragEvent) => { + const handleDragEnd = (e: DragEvent): void => { if (onDragEnd) { onDragEnd(e); } From bb74b1642437260c53283c1249e072533c1c124f Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Thu, 27 Nov 2025 16:31:18 +0100 Subject: [PATCH 11/13] refactor: standardize use of brandi --- .../datawidgets/web/_datagrid.scss | 4 + .../src/components/ColumnContainer.tsx | 161 +++-------------- .../src/components/ColumnHeader.tsx | 116 ++++++++++-- .../src/components/__tests__/Header.spec.tsx | 168 ++++++++++++++++++ ...wModel.ts => HeaderDragnDrop.viewModel.ts} | 34 ++-- ...c.ts => HeaderDragnDrop.viewModel.spec.ts} | 24 +-- .../model/containers/Datagrid.container.ts | 10 +- .../src/model/hooks/injection-hooks.ts | 2 +- .../datagrid-web/src/model/tokens.ts | 5 +- 9 files changed, 333 insertions(+), 191 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx rename packages/pluggableWidgets/datagrid-web/src/features/column/{ColumnHeader.viewModel.ts => HeaderDragnDrop.viewModel.ts} (79%) rename packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/{ColumnHeader.viewModel.spec.ts => HeaderDragnDrop.viewModel.spec.ts} (95%) 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 9227652f4f..47659c9882 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -149,6 +149,10 @@ $root: ".widget-datagrid"; } } + .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 index 060a620475..32c5e0db01 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -1,164 +1,59 @@ import classNames from "classnames"; -import { - DragEvent, - DragEventHandler, - HTMLAttributes, - KeyboardEvent, - MouseEvent, - ReactElement, - ReactNode, - useMemo -} from "react"; -import { FaArrowsAltV } from "./icons/FaArrowsAltV"; -import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; -import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; - -import ColumnHeader from "./ColumnHeader"; - -import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDragDrop } from "../model/hooks/injection-hooks"; -import { GridColumn } from "../typings/GridColumn"; +import { ReactElement } from "react"; +import { ColumnHeader } from "./ColumnHeader"; +import { useColumn, useColumnsStore, useDatagridConfig, useColumnHeaderVM } from "../model/hooks/injection-hooks"; import { ColumnResizerProps } from "./ColumnResizer"; -import { ColumnHeaderViewModel } from "../features/column/ColumnHeader.viewModel"; import { observer } from "mobx-react-lite"; export interface ColumnContainerProps { isLast?: boolean; resizer: ReactElement; } -interface DragHandleProps { - draggable: boolean; - onDragStart?: DragEventHandler; - onDragEnd?: DragEventHandler; -} export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { const { columnsFilterable, id: gridId } = useDatagridConfig(); - const columnsStore = useColumnsStore(); - const column = useColumn(); - const { canDrag, canSort } = column; + const { columnFilters } = useColumnsStore(); + const { canSort, columnId, setHeaderElementRef, columnIndex, canResize, sortDir, header } = useColumn(); + const { draggableProps, dropTarget, dragging } = useColumnHeaderVM(); - const headerDragDropStore = useHeaderDragDrop(); - const columnHeaderVM = useMemo( - () => - new ColumnHeaderViewModel({ - dndStore: headerDragDropStore, - columnsStore, - columnsDraggable: canDrag - }), - [headerDragDropStore, columnsStore, canDrag] - ); - const draggableProps = columnHeaderVM.draggableProps; - const dropTarget = columnHeaderVM.dropTarget; - const isDragging = columnHeaderVM.dragging; - - const sortProps = canSort ? getSortProps(column) : null; - const caption = column.header.trim(); + const caption = header.trim(); return (
column.setHeaderElementRef(ref)} - data-column-id={column.columnId} + ref={ref => setHeaderElementRef(ref)} + data-column-id={columnId} onDrop={draggableProps.onDrop} onDragEnter={draggableProps.onDragEnter} onDragOver={draggableProps.onDragOver} > -
- - {draggableProps.draggable && ( - - )} - - {caption.length > 0 ? caption : "\u00a0"} - - {canSort ? : null} - +
+ {columnsFilterable && ( -
- {columnsStore.columnFilters[column.columnIndex]?.renderFilterWidgets()} +
+ {columnFilters[columnIndex]?.renderFilterWidgets()}
)}
- {column.canResize ? props.resizer : null} + {canResize ? props.resizer : 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 getAriaSort(canSort: boolean, column: GridColumn): "ascending" | "descending" | "none" | undefined { +function getAriaSort(canSort: boolean, sortDir: string | undefined): "ascending" | "descending" | "none" | undefined { if (!canSort) { return undefined; } - switch (column.sortDir) { + switch (sortDir) { case "asc": return "ascending"; case "desc": @@ -167,19 +62,3 @@ function getAriaSort(canSort: boolean, column: GridColumn): "ascending" | "desce 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/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx index 710d244461..ec01888225 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -1,28 +1,106 @@ import classNames from "classnames"; -import { HTMLAttributes, ReactElement, ReactNode } from "react"; - -export interface ColumnHeaderProps { - children?: ReactNode; - sortProps?: HTMLAttributes | null; - canSort: boolean; - caption: string; - isDragging?: [string | undefined, string, string | undefined] | undefined; - columnAlignment?: "left" | "center" | "right"; +import { DragEventHandler, DragEvent, HTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from "react"; +import { FaArrowsAltV } from "./icons/FaArrowsAltV"; +import { FaLongArrowAltDown } from "./icons/FaLongArrowAltDown"; +import { FaLongArrowAltUp } from "./icons/FaLongArrowAltUp"; +import { useColumn, useColumnHeaderVM } from "../model/hooks/injection-hooks"; +import { observer } from "mobx-react-lite"; + +interface DragHandleProps { + draggable: boolean; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; } -export default function ColumnHeader(props: ColumnHeaderProps): ReactElement { +export const ColumnHeader = observer(function ColumnHeader(): ReactElement { + const { draggableProps, dragging } = useColumnHeaderVM(); + const { header, canSort, alignment, toggleSort } = useColumn(); + const caption = header.trim(); + const sortProps = canSort ? getSortProps(toggleSort) : null; + return (
- {props.children} + {draggableProps.draggable && ( + + )} + {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/__tests__/Header.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx new file mode 100644 index 0000000000..63167f0fe9 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Header.spec.tsx @@ -0,0 +1,168 @@ +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ContainerProvider } from "brandi-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"; + +describe("ColumnContainer", () => { + it("renders the structure correctly", () => { + const props = mockContainerProps({ + columns: [column("Column 1")] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + } /> + + + ); + + expect(component.asFragment()).toMatchSnapshot(); + }); + + it("renders the structure correctly when sortable", () => { + const columnsType = column("Column 1", col => { + col.sortable = true; + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + } /> + + + ); + + expect(component.asFragment()).toMatchSnapshot(); + }); + + it("renders the structure correctly when resizable", () => { + const columnsType = column("Column 1", col => { + col.resizable = true; + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + resizer
} /> + + + ); + + expect(component.asFragment()).toMatchSnapshot(); + }); + + it("renders the structure correctly when draggable", () => { + const columnsType = column("Column 1", col => { + col.draggable = true; + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + } /> + + + ); + + expect(component.asFragment()).toMatchSnapshot(); + }); + + it("renders the structure correctly when filterable with custom filter", () => { + const columnsType = column("Column 1", col => { + col.filter =
Custom filter
; + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + } /> + + + ); + + expect(component.asFragment()).toMatchSnapshot(); + }); + + it("calls sort function when sortable", async () => { + const user = userEvent.setup(); + const columnsType = column("Column 1", col => { + col.sortable = true; + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + const spy = jest.spyOn(col, "toggleSort"); + + const component = render( + + + } /> + + + ); + const button = component.getByLabelText("sort Column 1"); + + expect(button).toBeInTheDocument(); + await user.click(button); + expect(spy).toHaveBeenCalled(); + }); + + it("renders the structure correctly when value is empty", () => { + const columnsType = column("Column 1", col => { + col.header = dynamic(" "); + }); + const props = mockContainerProps({ + columns: [columnsType] + }); + const [container] = createDatagridContainer(props); + const columns = container.get(CORE_TOKENS.columnsStore); + const col = columns.visibleColumns[0]; + + const component = render( + + + } /> + + + ); + expect(component.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts similarity index 79% rename from packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts rename to packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts index 27be4adede..775e0621da 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/column/ColumnHeader.viewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts @@ -1,22 +1,20 @@ import { makeAutoObservable } from "mobx"; import { DragEvent, DragEventHandler } from "react"; import { HeaderDragnDropStore } from "./HeaderDragnDrop.store"; -import { ColumnId } from "../../typings/GridColumn"; +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. */ -export class ColumnHeaderViewModel { - private readonly dndStore: HeaderDragnDropStore; - private readonly columnsStore: ColumnGroupStore; - private readonly columnsDraggable: boolean; - - constructor(params: { dndStore: HeaderDragnDropStore; columnsStore: ColumnGroupStore; columnsDraggable: boolean }) { - this.dndStore = params.dndStore; - this.columnsStore = params.columnsStore; - this.columnsDraggable = params.columnsDraggable; +export class HeaderDragnDropViewModel { + constructor( + private dndStore: HeaderDragnDropStore, + private columnsStore: ColumnGroupStore, + private config: { columnsDraggable: boolean }, + private column: GridColumn + ) { makeAutoObservable(this); } @@ -28,6 +26,10 @@ export class ColumnHeaderViewModel { return this.dndStore.isDragging; } + get isDraggable(): boolean { + return this.config.columnsDraggable && this.column.canDrag; + } + /** Handlers exposed to the component. */ get draggableProps(): { draggable?: boolean; @@ -37,7 +39,7 @@ export class ColumnHeaderViewModel { onDragEnter?: DragEventHandler; onDragEnd?: DragEventHandler; } { - if (!this.columnsDraggable) { + if (!this.isDraggable) { return {}; } return { @@ -50,7 +52,7 @@ export class ColumnHeaderViewModel { }; } - private handleDragStart = (e: DragEvent): void => { + handleDragStart = (e: DragEvent): void => { const elt = (e.target as HTMLDivElement).closest(".th") as HTMLDivElement; if (!elt) { return; @@ -61,7 +63,7 @@ export class ColumnHeaderViewModel { this.dndStore.setIsDragging([columnAtTheLeft, columnId, columnAtTheRight]); }; - private handleDragOver = (e: DragEvent): void => { + handleDragOver = (e: DragEvent): void => { const dragging = this.dragging; if (!dragging) { return; @@ -93,15 +95,15 @@ export class ColumnHeaderViewModel { } }; - private handleDragEnter = (e: DragEvent): void => { + handleDragEnter = (e: DragEvent): void => { e.preventDefault(); }; - private handleDragEnd = (): void => { + handleDragEnd = (): void => { this.dndStore.clearDragState(); }; - private handleOnDrop = (_e: DragEvent): void => { + handleOnDrop = (_e: DragEvent): void => { const dragging = this.dragging; const dropTarget = this.dropTarget; this.handleDragEnd(); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts similarity index 95% rename from packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts rename to packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts index bb8b0dde39..91490e3e39 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/ColumnHeader.viewModel.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts @@ -1,40 +1,42 @@ import { DragEvent } from "react"; -import { ColumnHeaderViewModel } from "../ColumnHeader.viewModel"; +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("returns empty draggableProps", () => { - const vm = new ColumnHeaderViewModel({ + const vm = new HeaderDragnDropViewModel( dndStore, - columnsStore: mockColumnsStore, - columnsDraggable: false - }); + mockColumnsStore, + { columnsDraggable: false }, + mockColumn + ); expect(vm.draggableProps).toEqual({}); }); }); describe("when columnsDraggable is true", () => { - let vm: ColumnHeaderViewModel; + let vm: HeaderDragnDropViewModel; beforeEach(() => { - vm = new ColumnHeaderViewModel({ - dndStore, - columnsStore: mockColumnsStore, - columnsDraggable: true - }); + vm = new HeaderDragnDropViewModel(dndStore, mockColumnsStore, { columnsDraggable: true }, mockColumn); }); it("returns draggable props with handlers", () => { 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 47574ed046..c4d31a11f2 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -22,7 +22,7 @@ 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"; @@ -85,6 +85,9 @@ injected( DG.selectionCounterCfg.optional ); +// drag and drop +injected(HeaderDragnDropViewModel, DG.headerDragDrop, CORE.columnsStore, CORE.config, CORE.column); + export class DatagridContainer extends Container { id = `DatagridContainer@${generateUUID()}`; constructor(root: Container) { @@ -96,7 +99,7 @@ export class DatagridContainer extends Container { // Columns store this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); // Drag and Drop store - this.bind(DG.headerDragDrop).toInstance(HeaderDragnDropStore).inSingletonScope(); + this.bind(DG.columnHeaderVM).toInstance(HeaderDragnDropViewModel).inSingletonScope(); // Query service this.bind(DG.query).toInstance(DatasourceService).inSingletonScope(); // Pagination service @@ -167,6 +170,9 @@ export class DatagridContainer extends Container { // Config this.bind(CORE.config).toConstant(config); + // Columns draggable setting + this.bind(DG.columnsDraggable).toConstant(config.columnsDraggable); + // Connect select all module this.bind(SA_TOKENS.progressService).toConstant(selectAllModule.get(SA_TOKENS.progressService)); this.bind(SA_TOKENS.selectionDialogVM).toConstant(selectAllModule.get(SA_TOKENS.selectionDialogVM)); 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 3b370dac6c..2233a8d0b8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -20,7 +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 [useHeaderDragDrop] = createInjectionHooks(DG.headerDragDrop); +export const [useColumnHeaderVM] = createInjectionHooks(DG.columnHeaderVM); 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 cebac366c0..94b783c7f8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -34,6 +34,7 @@ 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"; @@ -132,7 +133,9 @@ export const DG_TOKENS = { clickActionHelper: token("@service:ClickActionHelper"), focusService: token("@service:FocusTargetController"), checkboxEventsHandler: token("@service:CheckboxEventsController"), - headerDragDrop: token("HeaderDragnDropStore"), + headerDragDrop: token("@store:HeaderDragnDropStore"), + columnsDraggable: token("@const:columnsDraggable"), + columnHeaderVM: token("ColumnHeaderViewModel"), cellEventsHandler: token("@service:CellEventsController") }; From 4851062cff4aa7c6e38c498224111fb25453378d Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 28 Nov 2025 14:48:57 +0100 Subject: [PATCH 12/13] refactor: improve naming, consistency and clean up --- .../src/components/ColumnContainer.tsx | 17 ++++++------- .../src/components/ColumnHeader.tsx | 12 +++------ .../features/column/HeaderDragnDrop.store.ts | 5 ++++ .../column/HeaderDragnDrop.viewModel.ts | 25 ++----------------- .../model/containers/Datagrid.container.ts | 9 ++++--- .../src/model/hooks/injection-hooks.ts | 2 +- .../datagrid-web/src/model/tokens.ts | 3 +-- 7 files changed, 26 insertions(+), 47 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx index 32c5e0db01..49a4776142 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -14,31 +14,30 @@ export const ColumnContainer = observer(function ColumnContainer(props: ColumnCo const { columnsFilterable, id: gridId } = useDatagridConfig(); const { columnFilters } = useColumnsStore(); const { canSort, columnId, setHeaderElementRef, columnIndex, canResize, sortDir, header } = useColumn(); - const { draggableProps, dropTarget, dragging } = useColumnHeaderVM(); - + const vm = useColumnHeaderVM(); const caption = header.trim(); return (
setHeaderElementRef(ref)} data-column-id={columnId} - onDrop={draggableProps.onDrop} - onDragEnter={draggableProps.onDragEnter} - onDragOver={draggableProps.onDragOver} + onDrop={vm.handleOnDrop} + onDragEnter={vm.handleDragEnter} + onDragOver={vm.handleDragOver} >
{columnsFilterable && ( -
+
{columnFilters[columnIndex]?.renderFilterWidgets()}
)} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx index ec01888225..6c0a5436be 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -13,24 +13,20 @@ interface DragHandleProps { } export const ColumnHeader = observer(function ColumnHeader(): ReactElement { - const { draggableProps, dragging } = useColumnHeaderVM(); const { header, canSort, alignment, toggleSort } = useColumn(); const caption = header.trim(); const sortProps = canSort ? getSortProps(toggleSort) : null; + const vm = useColumnHeaderVM(); return (
- {draggableProps.draggable && ( - + {vm.isDraggable && ( + )} {caption.length > 0 ? caption : "\u00a0"} {canSort ? : null} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts index 14090ab1ae..54fc13b982 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.store.ts @@ -1,6 +1,11 @@ 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; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts index 775e0621da..fed47eca29 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/column/HeaderDragnDrop.viewModel.ts @@ -1,5 +1,5 @@ import { makeAutoObservable } from "mobx"; -import { DragEvent, DragEventHandler } from "react"; +import { DragEvent } from "react"; import { HeaderDragnDropStore } from "./HeaderDragnDrop.store"; import { ColumnId, GridColumn } from "../../typings/GridColumn"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; @@ -7,6 +7,7 @@ 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( @@ -30,28 +31,6 @@ export class HeaderDragnDropViewModel { return this.config.columnsDraggable && this.column.canDrag; } - /** Handlers exposed to the component. */ - get draggableProps(): { - draggable?: boolean; - onDragStart?: DragEventHandler; - onDragOver?: DragEventHandler; - onDrop?: DragEventHandler; - onDragEnter?: DragEventHandler; - onDragEnd?: DragEventHandler; - } { - if (!this.isDraggable) { - return {}; - } - return { - draggable: true, - onDragStart: this.handleDragStart, - onDragOver: this.handleDragOver, - onDrop: this.handleOnDrop, - onDragEnter: this.handleDragEnter, - onDragEnd: this.handleDragEnd - }; - } - handleDragStart = (e: DragEvent): void => { const elt = (e.target as HTMLDivElement).closest(".th") as HTMLDivElement; if (!elt) { 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 c4d31a11f2..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,7 @@ 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"; @@ -86,6 +87,7 @@ injected( ); // drag and drop +injected(HeaderDragnDropStore); injected(HeaderDragnDropViewModel, DG.headerDragDrop, CORE.columnsStore, CORE.config, CORE.column); export class DatagridContainer extends Container { @@ -99,7 +101,9 @@ export class DatagridContainer extends Container { // Columns store this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); // Drag and Drop store - this.bind(DG.columnHeaderVM).toInstance(HeaderDragnDropViewModel).inSingletonScope(); + 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 @@ -170,9 +174,6 @@ export class DatagridContainer extends Container { // Config this.bind(CORE.config).toConstant(config); - // Columns draggable setting - this.bind(DG.columnsDraggable).toConstant(config.columnsDraggable); - // Connect select all module this.bind(SA_TOKENS.progressService).toConstant(selectAllModule.get(SA_TOKENS.progressService)); this.bind(SA_TOKENS.selectionDialogVM).toConstant(selectAllModule.get(SA_TOKENS.selectionDialogVM)); 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 2233a8d0b8..99d66f6dbc 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -20,7 +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 [useColumnHeaderVM] = createInjectionHooks(DG.columnHeaderVM); +export const [useColumnHeaderVM] = 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 94b783c7f8..1bb6a87af4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -134,8 +134,7 @@ export const DG_TOKENS = { focusService: token("@service:FocusTargetController"), checkboxEventsHandler: token("@service:CheckboxEventsController"), headerDragDrop: token("@store:HeaderDragnDropStore"), - columnsDraggable: token("@const:columnsDraggable"), - columnHeaderVM: token("ColumnHeaderViewModel"), + headerDragnDropVM: token("@viewmodel:ColumnHeaderViewModel"), cellEventsHandler: token("@service:CellEventsController") }; From 5f254d18bb1a589a7ac523a6de57fd9097b54ccd Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 1 Dec 2025 15:39:59 +0100 Subject: [PATCH 13/13] refactor: ensure consistent naming, prop destructuring, update tests --- .../datawidgets/web/_datagrid.scss | 3 + .../src/components/ColumnContainer.tsx | 9 +- .../src/components/ColumnHeader.tsx | 11 +- ...ader.spec.tsx => ColumnContainer.spec.tsx} | 0 .../ColumnContainer.spec.tsx.snap | 214 ++++++++++++++++++ .../HeaderDragnDrop.viewModel.spec.ts | 122 ++-------- .../src/model/hooks/injection-hooks.ts | 2 +- 7 files changed, 246 insertions(+), 115 deletions(-) rename packages/pluggableWidgets/datagrid-web/src/components/__tests__/{Header.spec.tsx => ColumnContainer.spec.tsx} (100%) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/ColumnContainer.spec.tsx.snap 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 47659c9882..c178feb8db 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -147,6 +147,9 @@ $root: ".widget-datagrid"; background-color: var(--brand-primary-50, $brand-light); color: var(--brand-primary, $brand-primary); } + :active { + cursor: grabbing; + } } .drag-handle + .column-caption { diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx index 49a4776142..cd08a6aa89 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnContainer.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import { ReactElement } from "react"; import { ColumnHeader } from "./ColumnHeader"; -import { useColumn, useColumnsStore, useDatagridConfig, useColumnHeaderVM } from "../model/hooks/injection-hooks"; +import { useColumn, useColumnsStore, useDatagridConfig, useHeaderDragnDropVM } from "../model/hooks/injection-hooks"; import { ColumnResizerProps } from "./ColumnResizer"; import { observer } from "mobx-react-lite"; @@ -13,8 +13,9 @@ export interface ColumnContainerProps { export const ColumnContainer = observer(function ColumnContainer(props: ColumnContainerProps): ReactElement { const { columnsFilterable, id: gridId } = useDatagridConfig(); const { columnFilters } = useColumnsStore(); - const { canSort, columnId, setHeaderElementRef, columnIndex, canResize, sortDir, header } = useColumn(); - const vm = useColumnHeaderVM(); + const column = useColumn(); + const { canSort, columnId, columnIndex, canResize, sortDir, header } = column; + const vm = useHeaderDragnDropVM(); const caption = header.trim(); return ( @@ -28,7 +29,7 @@ export const ColumnContainer = observer(function ColumnContainer(props: ColumnCo role="columnheader" style={!canSort ? { cursor: "unset" } : undefined} title={caption} - ref={ref => setHeaderElementRef(ref)} + ref={ref => column.setHeaderElementRef(ref)} data-column-id={columnId} onDrop={vm.handleOnDrop} onDragEnter={vm.handleDragEnter} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx index 6c0a5436be..0ae6ee3e39 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ColumnHeader.tsx @@ -1,9 +1,9 @@ import classNames from "classnames"; -import { DragEventHandler, DragEvent, HTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from "react"; +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, useColumnHeaderVM } from "../model/hooks/injection-hooks"; +import { useColumn, useHeaderDragnDropVM } from "../model/hooks/injection-hooks"; import { observer } from "mobx-react-lite"; interface DragHandleProps { @@ -13,10 +13,11 @@ interface DragHandleProps { } export const ColumnHeader = observer(function ColumnHeader(): ReactElement { - const { header, canSort, alignment, toggleSort } = useColumn(); + const column = useColumn(); + const { header, canSort, alignment } = column; const caption = header.trim(); - const sortProps = canSort ? getSortProps(toggleSort) : null; - const vm = useColumnHeaderVM(); + const sortProps = canSort ? getSortProps(() => column.toggleSort()) : null; + const vm = useHeaderDragnDropVM(); return (
+
+
+
+ + 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/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/column/__tests__/HeaderDragnDrop.viewModel.spec.ts index 91490e3e39..a11aa24123 100644 --- 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 @@ -20,7 +20,7 @@ describe("ColumnHeaderViewModel", () => { }); describe("when columnsDraggable is false", () => { - it("returns empty draggableProps", () => { + it("is not draggable", () => { const vm = new HeaderDragnDropViewModel( dndStore, mockColumnsStore, @@ -28,7 +28,7 @@ describe("ColumnHeaderViewModel", () => { mockColumn ); - expect(vm.draggableProps).toEqual({}); + expect(vm.isDraggable).toBe(false); }); }); @@ -39,37 +39,8 @@ describe("ColumnHeaderViewModel", () => { vm = new HeaderDragnDropViewModel(dndStore, mockColumnsStore, { columnsDraggable: true }, mockColumn); }); - it("returns draggable props with handlers", () => { - const props = vm.draggableProps; - - expect(props.draggable).toBe(true); - expect(props.onDragStart).toBeDefined(); - expect(props.onDragOver).toBeDefined(); - expect(props.onDrop).toBeDefined(); - expect(props.onDragEnter).toBeDefined(); - expect(props.onDragEnd).toBeDefined(); - }); - - describe("dropTarget", () => { - it("returns undefined initially", () => { - expect(vm.dropTarget).toBeUndefined(); - }); - - it("returns value from dndStore", () => { - dndStore.setDragOver(["col1" as ColumnId, "after"]); - expect(vm.dropTarget).toEqual(["col1", "after"]); - }); - }); - - describe("dragging", () => { - it("returns undefined initially", () => { - expect(vm.dragging).toBeUndefined(); - }); - - it("returns value from dndStore", () => { - dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); - expect(vm.dragging).toEqual(["col0", "col1", "col2"]); - }); + it("is draggable", () => { + expect(vm.isDraggable).toBe(true); }); describe("handleDragStart", () => { @@ -77,7 +48,7 @@ describe("ColumnHeaderViewModel", () => { const mockElement = createMockElement("col1", "col0", "col2"); const event = createMockDragEvent(mockElement); - vm.draggableProps.onDragStart?.(event); + vm.handleDragStart(event); expect(dndStore.isDragging).toEqual(["col0", "col1", "col2"]); }); @@ -86,7 +57,7 @@ describe("ColumnHeaderViewModel", () => { const mockElement = createMockElement("col1", undefined, "col2"); const event = createMockDragEvent(mockElement); - vm.draggableProps.onDragStart?.(event); + vm.handleDragStart(event); expect(dndStore.isDragging).toEqual([undefined, "col1", "col2"]); }); @@ -95,7 +66,7 @@ describe("ColumnHeaderViewModel", () => { const mockElement = createMockElement("col1", "col0", undefined); const event = createMockDragEvent(mockElement); - vm.draggableProps.onDragStart?.(event); + vm.handleDragStart(event); expect(dndStore.isDragging).toEqual(["col0", "col1", undefined]); }); @@ -107,7 +78,7 @@ describe("ColumnHeaderViewModel", () => { } } as any; - vm.draggableProps.onDragStart?.(event); + vm.handleDragStart(event); expect(dndStore.isDragging).toBeUndefined(); }); @@ -122,7 +93,7 @@ describe("ColumnHeaderViewModel", () => { dndStore.clearDragState(); const event = createMockDragOverEvent("col2", 100, 50); - vm.draggableProps.onDragOver?.(event); + vm.handleDragOver(event); expect(dndStore.dragOver).toBeUndefined(); }); @@ -130,7 +101,7 @@ describe("ColumnHeaderViewModel", () => { it("does nothing when columnId is missing", () => { const event = createMockDragOverEvent("", 100, 50); - vm.draggableProps.onDragOver?.(event); + vm.handleDragOver(event); expect(dndStore.dragOver).toBeUndefined(); }); @@ -139,7 +110,7 @@ describe("ColumnHeaderViewModel", () => { dndStore.setDragOver(["col2" as ColumnId, "after"]); const event = createMockDragOverEvent("col1", 100, 50); - vm.draggableProps.onDragOver?.(event); + vm.handleDragOver(event); expect(dndStore.dragOver).toBeUndefined(); }); @@ -147,7 +118,7 @@ describe("ColumnHeaderViewModel", () => { it("sets dropTarget to before when hovering over left sibling", () => { const event = createMockDragOverEvent("col0", 100, 50); - vm.draggableProps.onDragOver?.(event); + vm.handleDragOver(event); expect(dndStore.dragOver).toEqual(["col0", "before"]); }); @@ -155,51 +126,17 @@ describe("ColumnHeaderViewModel", () => { it("sets dropTarget to after when hovering over right sibling", () => { const event = createMockDragOverEvent("col2", 100, 50); - vm.draggableProps.onDragOver?.(event); + vm.handleDragOver(event); expect(dndStore.dragOver).toEqual(["col2", "after"]); }); - - it("sets dropTarget to before when hovering on left side of non-sibling column", () => { - const event = createMockDragOverEvent("col5", 100, 30); - - vm.draggableProps.onDragOver?.(event); - - expect(dndStore.dragOver).toEqual(["col5", "before"]); - }); - - it("sets dropTarget to after when hovering on right side of non-sibling column", () => { - const event = createMockDragOverEvent("col5", 100, 70); - - vm.draggableProps.onDragOver?.(event); - - expect(dndStore.dragOver).toEqual(["col5", "after"]); - }); - - it("does not update dropTarget if it hasn't changed", () => { - dndStore.setDragOver(["col5" as ColumnId, "after"]); - const setDragOverSpy = jest.spyOn(dndStore, "setDragOver"); - const event = createMockDragOverEvent("col5", 100, 70); - - vm.draggableProps.onDragOver?.(event); - - expect(setDragOverSpy).not.toHaveBeenCalled(); - }); - - it("prevents default behavior", () => { - const event = createMockDragOverEvent("col2", 100, 50); - - vm.draggableProps.onDragOver?.(event); - - expect(event.preventDefault).toHaveBeenCalled(); - }); }); describe("handleDragEnter", () => { it("prevents default behavior", () => { const event = { preventDefault: jest.fn() } as any; - vm.draggableProps.onDragEnter?.(event); + vm.handleDragEnter(event); expect(event.preventDefault).toHaveBeenCalled(); }); @@ -210,7 +147,7 @@ describe("ColumnHeaderViewModel", () => { dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); dndStore.setDragOver(["col2" as ColumnId, "after"]); - vm.draggableProps.onDragEnd?.({} as any); + vm.handleDragEnd(); expect(dndStore.isDragging).toBeUndefined(); expect(dndStore.dragOver).toBeUndefined(); @@ -222,7 +159,7 @@ describe("ColumnHeaderViewModel", () => { dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); dndStore.setDragOver(["col3" as ColumnId, "after"]); - vm.draggableProps.onDrop?.({} as any); + vm.handleOnDrop({} as any); expect(mockColumnsStore.swapColumns).toHaveBeenCalledWith("col1", ["col3", "after"]); }); @@ -231,32 +168,7 @@ describe("ColumnHeaderViewModel", () => { dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); dndStore.setDragOver(["col3" as ColumnId, "after"]); - vm.draggableProps.onDrop?.({} as any); - - expect(dndStore.isDragging).toBeUndefined(); - expect(dndStore.dragOver).toBeUndefined(); - }); - - it("does not call swapColumns when not dragging", () => { - dndStore.setDragOver(["col3" as ColumnId, "after"]); - - vm.draggableProps.onDrop?.({} as any); - - expect(mockColumnsStore.swapColumns).not.toHaveBeenCalled(); - }); - - it("does not call swapColumns when no dropTarget", () => { - dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); - - vm.draggableProps.onDrop?.({} as any); - - expect(mockColumnsStore.swapColumns).not.toHaveBeenCalled(); - }); - - it("clears drag state even when drop is invalid", () => { - dndStore.setIsDragging(["col0" as ColumnId, "col1" as ColumnId, "col2" as ColumnId]); - - vm.draggableProps.onDrop?.({} as any); + vm.handleOnDrop({} as any); expect(dndStore.isDragging).toBeUndefined(); expect(dndStore.dragOver).toBeUndefined(); 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 99d66f6dbc..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,7 +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 [useColumnHeaderVM] = createInjectionHooks(DG.headerDragnDropVM); +export const [useHeaderDragnDropVM] = createInjectionHooks(DG.headerDragnDropVM); export const [useClickActionHelper] = createInjectionHooks(DG.clickActionHelper); export const [useFocusService] = createInjectionHooks(DG.focusService); export const [useCheckboxEventsHandler] = createInjectionHooks(DG.checkboxEventsHandler);