From 340ab9d6f1f32633f49ea26805ce01181797f8d8 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 2 Dec 2025 17:36:40 +0100 Subject: [PATCH 1/4] feat: better horizontal virtual scrolling --- .../datawidgets/web/_datagrid.scss | 54 +++++++++++-- .../datagrid-web/CHANGELOG.md | 4 + .../datagrid-web/src/components/Grid.tsx | 9 ++- .../datagrid-web/src/components/GridBody.tsx | 10 +-- .../src/components/GridHeader.tsx | 5 +- .../datagrid-web/src/components/Header.tsx | 1 - .../src/components/MockHeader.tsx | 56 ++++++++++++++ .../datagrid-web/src/components/Widget.tsx | 2 + .../__snapshots__/Grid.spec.tsx.snap | 4 +- .../src/helpers/state/column/ColumnStore.tsx | 2 + .../model/containers/Datagrid.container.ts | 8 +- .../src/model/hooks/injection-hooks.ts | 1 + .../src/model/hooks/useBodyScroll.ts | 23 +----- .../src/model/hooks/useInfiniteControl.tsx | 76 +++++++++++++++++++ .../src/model/models/grid.model.ts | 51 +++++++++---- .../src/model/stores/GridSize.store.ts | 74 ++++++++++++++++++ .../datagrid-web/src/model/tokens.ts | 3 + 17 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/MockHeader.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 8ca532f190..c14055e9d9 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -395,6 +395,11 @@ $root: ".widget-datagrid"; display: grid !important; min-width: fit-content; margin-bottom: 0; + &.infinite-loading { + // in order to restrict the scroll to row area + // we need to prevent table itself to expanding beyond available position + min-width: 0; + } } } @@ -530,24 +535,57 @@ $root: ".widget-datagrid"; margin: var(--spacing-small, 8px) 0; } -.infinite-loading.widget-datagrid-grid-body { - // when virtual scroll is enabled we make area that holds rows scrollable - // (while the area that holds column headers still stays in place) - overflow-y: auto; +.infinite-loading { + .widget-datagrid-grid-head { + width: calc(var(--mx-grid-width) - var(--mx-grid-scrollbar-size)); + overflow-x: hidden; + } + .widget-datagrid-grid-head[data-scrolled-y="true"] { + box-shadow: 0 5px 5px -5px gray; + } + + .widget-datagrid-grid-body { + width: var(--mx-grid-width); + overflow-y: auto; + max-height: var(--mx-grid-body-height); + } + + .widget-datagrid-grid-head[data-scrolled-x="true"]:after { + content: ""; + position: absolute; + left: 0px; + width: 10px; + box-shadow: inset 5px 0 5px -5px gray; + top: 0; + bottom: 0; + } } -.widget-datagrid-grid-head, +.widget-datagrid-grid-head { + display: grid; + + // this head is not part of the grid, so it has dedicated column template --mx-grid-template-columns-head + // but it might not be available at the initial render, so we use template from the grid --mx-grid-template-columns + // using template from the grid might to misalignment from the grid itself, + // but in practice: + // - grid has no data at that moment, so misalignment is not visible. + // - as soon as the grid itself gets rendered --mx-grid-template-columns-head gets calculated + // and everything looks like it should. + grid-template-columns: var(--mx-grid-template-columns-head, var(--mx-grid-template-columns)); +} .widget-datagrid-grid-body { // this element has to position their children (columns or headers) // as grid and have those aligned with the parent grid display: grid; // this property makes sure we align our own grid columns // to the columns defined in the global grid - grid-template-columns: subgrid; + grid-template-columns: var(--mx-grid-template-columns); +} - // ensure that we cover all columns of original top level grid - // so our own columns get aligned with the parent +.grid-mock-header { + grid-template-columns: subgrid; grid-column: 1 / -1; + display: grid; } :where(#{$root}-paging-bottom, #{$root}-padding-top) { diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 1f7339aa51..721bfa9705 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- We improved virtual scrolling behavior when horizontal scrolling is present due to grid size. + ### Added - We added a new property for export to excel. The new property allows to set the cell export type and also the format for type number and date. diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx index 015fb3f539..0de2f7d770 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx @@ -1,16 +1,21 @@ +import classNames from "classnames"; import { observer } from "mobx-react-lite"; import { PropsWithChildren, ReactElement } from "react"; -import { useDatagridConfig, useGridStyle } from "../model/hooks/injection-hooks"; +import { useDatagridConfig, useGridSizeStore, useGridStyle } from "../model/hooks/injection-hooks"; export const Grid = observer(function Grid(props: PropsWithChildren): ReactElement { const config = useDatagridConfig(); + const gridSizeStore = useGridSizeStore(); + + // TODO: add check custom css styling is applie const style = useGridStyle().get(); return (
{props.children}
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index a8ededee80..c27b6337c6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -1,8 +1,8 @@ -import classNames from "classnames"; import { observer } from "mobx-react-lite"; import { Fragment, PropsWithChildren, ReactElement, ReactNode } from "react"; import { useDatagridConfig, + useGridSizeStore, useItemCount, useLoaderViewModel, usePaginationService, @@ -14,14 +14,14 @@ import { SpinnerLoader } from "./loader/SpinnerLoader"; export const GridBody = observer(function GridBody(props: PropsWithChildren): ReactElement { const { children } = props; - const { bodySize, containerRef, isInfinite, handleScroll } = useBodyScroll(); + const gridSizeStore = useGridSizeStore(); + const { handleScroll } = useBodyScroll(); return (
0 ? { maxHeight: `${bodySize}px` } : {}} + className={"widget-datagrid-grid-body table-content"} role="rowgroup" - ref={containerRef} + ref={gridSizeStore.gridBodyRef} onScroll={handleScroll} > {children} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx index 39b86c6d3a..a378cd42b7 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridHeader.tsx @@ -1,5 +1,5 @@ import { ReactElement, useState } from "react"; -import { useColumnsStore, useDatagridConfig } from "../model/hooks/injection-hooks"; +import { useColumnsStore, useDatagridConfig, useGridSizeStore } from "../model/hooks/injection-hooks"; import { ColumnId } from "../typings/GridColumn"; import { CheckboxColumnHeader } from "./CheckboxColumnHeader"; import { ColumnProvider } from "./ColumnProvider"; @@ -11,6 +11,7 @@ import { HeaderSkeletonLoader } from "./loader/HeaderSkeletonLoader"; export function GridHeader(): ReactElement { const { columnsHidable, id: gridId } = useDatagridConfig(); const columnsStore = useColumnsStore(); + const gridSizeStore = useGridSizeStore(); const columns = columnsStore.visibleColumns; const [dragOver, setDragOver] = useState<[ColumnId, "before" | "after"] | undefined>(undefined); const [isDragging, setIsDragging] = useState<[ColumnId | undefined, ColumnId, ColumnId | undefined] | undefined>(); @@ -20,7 +21,7 @@ export function GridHeader(): ReactElement { } return ( -
+
{columns.map(column => ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx index ed334d2ad9..1c396b3b75 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Header.tsx @@ -60,7 +60,6 @@ export function Header(props: HeaderProps): ReactElement { role="columnheader" style={!canSort ? { cursor: "unset" } : undefined} title={caption} - ref={ref => column.setHeaderElementRef(ref)} data-column-id={column.columnId} onDrop={draggableProps.onDrop} onDragEnter={draggableProps.onDragEnter} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/MockHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/MockHeader.tsx new file mode 100644 index 0000000000..9035326a1a --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/MockHeader.tsx @@ -0,0 +1,56 @@ +import { ReactNode, useCallback, useEffect, useRef } from "react"; +import { useColumnsStore, useDatagridConfig, useGridSizeStore } from "../model/hooks/injection-hooks"; + +function getColumnSizes(container: HTMLDivElement | null): Map { + const sizes = new Map(); + if (container) { + container.querySelectorAll("[data-column-id]").forEach(c => { + const columnId = c.dataset.columnId; + if (!columnId) { + console.debug("getColumnSizes: can't find id on:", c); + return; + } + sizes.set(columnId, c.offsetWidth); + }); + } + + return sizes; +} + +export function MockHeader(): ReactNode { + const columnsStore = useColumnsStore(); + const config = useDatagridConfig(); + const gridSizeStore = useGridSizeStore(); + const headerRef = useRef(null); + const resizeCallback = useCallback(() => { + gridSizeStore.updateColumnSizes(getColumnSizes(headerRef.current).values().toArray()); + }, [headerRef, gridSizeStore]); + + useEffect(() => { + const observer = new ResizeObserver(resizeCallback); + + if (headerRef.current) { + observer.observe(headerRef.current); + } + return () => { + observer.disconnect(); + }; + }, [resizeCallback, headerRef]); + + return ( +
+ {config.checkboxColumnEnabled &&
} + {columnsStore.visibleColumns.map(c => ( +
c.setHeaderElementRef(ref)} + >
+ ))} + {config.selectorColumnEnabled &&
} +
+ ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index bce68e5755..9d11e7b5d7 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -13,6 +13,7 @@ import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; import { WidgetTopBar } from "./WidgetTopBar"; +import { MockHeader } from "./MockHeader"; export function Widget(props: { onExportCancel?: () => void }): ReactElement { return ( @@ -25,6 +26,7 @@ export function Widget(props: { onExportCancel?: () => void }): ReactElement { + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap index a6e74b1f27..d1b9c62bea 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap @@ -5,7 +5,7 @@ exports[`Grid renders without crashing 1`] = `
Test
@@ -17,7 +17,7 @@ exports[`Grid renders without selector column 1`] = `
Test
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx index 7a48f5b404..9ee3cf83ee 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnStore.tsx @@ -28,6 +28,8 @@ export class ColumnStore implements GridColumn { private baseInfo: BaseColumnInfo; private parentStore: IColumnParentStore; + // this holds size of the column that it had prior to resizing started + // this is needed to prevent personalization being saved while resizing private frozenSize: number | undefined; // dynamic props from PW API 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..172275ab3e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -32,6 +32,7 @@ import { DerivedLoaderController } from "../services/DerivedLoaderController"; import { PaginationController } from "../services/PaginationController"; import { SelectionGate } from "../services/SelectionGate.service"; import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../tokens"; +import { GridSizeStore } from "../stores/GridSize.store"; // base injected(ColumnGroupStore, CORE.setupService, CORE.mainGate, CORE.config, DG.filterHost); @@ -40,6 +41,7 @@ injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval. injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query); injected(GridBasicData, CORE.mainGate); injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.selectionDialogVM); +injected(GridSizeStore, DG.paginationService); // loader injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig); @@ -58,7 +60,7 @@ injected(GridPersonalizationStore, CORE.setupService, CORE.mainGate, CORE.column // selection injected(SelectionGate, CORE.mainGate); injected(createSelectionHelper, CORE.setupService, DG.selectionGate, CORE.config.optional); -injected(gridStyleAtom, CORE.columnsStore, CORE.config); +injected(gridStyleAtom, CORE.columnsStore, CORE.config, DG.gridSizeStore); injected(rowClassProvider, CORE.mainGate); // row-interaction @@ -98,6 +100,8 @@ export class DatagridContainer extends Container { this.bind(DG.query).toInstance(DatasourceService).inSingletonScope(); // Pagination service this.bind(DG.paginationService).toInstance(PaginationController).inSingletonScope(); + // Grid sizing and scrolling store + this.bind(DG.gridSizeStore).toInstance(GridSizeStore).inSingletonScope(); // Datasource params service this.bind(DG.paramsService).toInstance(DatasourceParamsController).inSingletonScope(); // FilterAPI @@ -229,5 +233,7 @@ export class DatagridContainer extends Container { // Hydrate filters from props this.get(DG.combinedFilter).hydrate(props.datasource.filter); + + this.get(DG.gridSizeStore); } } 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..77bf97a510 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -9,6 +9,7 @@ export const [useExportProgressService] = createInjectionHooks(DG.exportProgress export const [useLoaderViewModel] = createInjectionHooks(DG.loaderVM); export const [useMainGate] = createInjectionHooks(CORE.mainGate); export const [usePaginationService] = createInjectionHooks(DG.paginationService); +export const [useGridSizeStore] = createInjectionHooks(DG.gridSizeStore); export const [useSelectionHelper] = createInjectionHooks(DG.selectionHelper); export const [useGridStyle] = createInjectionHooks(DG.gridColumnsStyle); export const [useQueryService] = createInjectionHooks(DG.query); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts index e5fd2eed3a..eb98f0e965 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useBodyScroll.ts @@ -1,27 +1,12 @@ -import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; -import { RefObject, UIEventHandler, useCallback } from "react"; -import { usePaginationService } from "./injection-hooks"; +import { UIEventHandler } from "react"; +import { useInfiniteControl } from "./useInfiniteControl"; export function useBodyScroll(): { handleScroll: UIEventHandler | undefined; - bodySize: number; - containerRef: RefObject; - isInfinite: boolean; } { - const paging = usePaginationService(); - const setPage = useCallback((cb: (n: number) => number) => paging.setPage(cb), [paging]); - - const isInfinite = paging.pagination === "virtualScrolling"; - const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ - hasMoreItems: paging.hasMoreItems, - isInfinite, - setPage - }); + const [trackBodyScrolling] = useInfiniteControl(); return { - handleScroll: isInfinite ? trackScrolling : undefined, - bodySize, - containerRef, - isInfinite + handleScroll: trackBodyScrolling }; } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx new file mode 100644 index 0000000000..d65023ea46 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx @@ -0,0 +1,76 @@ +import { RefObject, UIEvent, useCallback, useLayoutEffect } from "react"; +import { useOnScreen } from "@mendix/widget-plugin-hooks/useOnScreen"; +import { useGridSizeStore } from "@mendix/datagrid-web/src/model/hooks/injection-hooks"; + +const offsetBottom = 30; + +export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | undefined] { + const gridSizeStore = useGridSizeStore(); + + const isVisible = useOnScreen(gridSizeStore.gridBodyRef as RefObject); + + const trackBodyScrolling = useCallback( + (e: UIEvent) => { + const target = e.target as HTMLElement; + const head = gridSizeStore.gridHeaderRef.current; + if (head) { + // synchronize header position to the body as they are decoupled + // we don't use state to optimize speed as we + // don't want a re-render. + head.scrollTo({ left: target.scrollLeft }); + + // this is cosmetic, needed to provide nice shadows when body is scrolled + head.dataset.scrolledY = target.scrollTop > 0 ? "true" : "false"; + head.dataset.scrolledX = target.scrollLeft > 0 ? "true" : "false"; + } + + // we need to determine scrollbar width to calculate header size correctly in css + gridSizeStore.setScrollBarSize(target.offsetWidth - target.clientWidth); + + /** + * In Windows OS the result of first expression returns a non integer and result in never loading more, require floor to solve. + * note: Math floor sometimes result in incorrect integer value, + * causing mismatch by 1 pixel point, thus, add magic number 2 as buffer. + */ + const bottom = + Math.floor(target.scrollHeight - offsetBottom - target.scrollTop) <= + Math.floor(target.clientHeight) + 2; + if (bottom) { + gridSizeStore.bumpPage(); + } + }, + [gridSizeStore] + ); + + const gridBody = gridSizeStore.gridBodyRef.current; + const { isInfinite, gridBodyHeight, hasMoreItems } = gridSizeStore; + + const lockGridBodyHeight = useCallback((): void => { + if (isVisible && isInfinite && hasMoreItems && gridBodyHeight === undefined && gridBody) { + gridSizeStore.setGridBodyHeight(gridBody.clientHeight - offsetBottom); + } + }, [isVisible, isInfinite, hasMoreItems, gridBodyHeight, gridBody, gridSizeStore]); + + useLayoutEffect(() => { + setTimeout(() => lockGridBodyHeight(), 100); + }, [lockGridBodyHeight]); + + useLayoutEffect(() => { + const observeTarget = gridSizeStore.gridContainerRef.current; + if (!gridSizeStore.isInfinite || !observeTarget) return; + + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + gridSizeStore.setGridWidth(entry.target.clientWidth ? entry.target.clientWidth : undefined); + } + }); + + resizeObserver.observe(observeTarget); + + return () => { + resizeObserver.unobserve(observeTarget); + }; + }, [gridSizeStore]); + + return [gridSizeStore.isInfinite ? trackBodyScrolling : undefined]; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts index 559b2bf366..ed86635fe6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts @@ -3,23 +3,46 @@ import { computed } from "mobx"; import { CSSProperties } from "react"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { DatagridConfig } from "../configs/Datagrid.config"; +import { GridColumn } from "../../typings/GridColumn"; +import { GridSizeStore } from "../stores/GridSize.store"; -export function gridStyleAtom(columns: ColumnGroupStore, config: DatagridConfig): ComputedAtom { - return computed(() => { - return gridStyle(columns.visibleColumns, { - checkboxColumn: config.checkboxColumnEnabled, - selectorColumn: config.selectorColumnEnabled - }); - }); +export function gridStyleAtom( + columns: ColumnGroupStore, + config: DatagridConfig, + gridSizeStore: GridSizeStore +): ComputedAtom { + // todo: include custom variables from gridSizeStore; + + return computed( + () => + ({ + "--mx-grid-template-columns": templateColumns(columns.visibleColumns, { + checkboxColumn: config.checkboxColumnEnabled, + selectorColumn: config.selectorColumnEnabled + }), + "--mx-grid-template-columns-head": gridSizeStore.templateColumnsHead, + "--mx-grid-body-height": asPx(gridSizeStore.gridBodyHeight), + "--mx-grid-width": asPx(gridSizeStore.gridWidth), + "--mx-grid-scrollbar-size": asPx(gridSizeStore.scrollBarSize) + }) as CSSProperties + ); +} + +function asPx(v: number | undefined): string | undefined { + if (v === undefined) { + return undefined; + } + + return `${v}px`; } -function gridStyle( - columns: Array<{ getCssWidth(): string }>, +function templateColumns( + columns: GridColumn[], optional: { - checkboxColumn?: boolean; - selectorColumn?: boolean; + checkboxColumn: boolean; + selectorColumn: boolean; } -): CSSProperties { +): string { const columnSizes = columns.map(c => c.getCssWidth()); const sizes: string[] = []; @@ -34,7 +57,5 @@ function gridStyle( sizes.push("54px"); } - return { - gridTemplateColumns: sizes.join(" ") - }; + return sizes.join(" "); } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts new file mode 100644 index 0000000000..ef899cf4d4 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts @@ -0,0 +1,74 @@ +import { action, computed, makeAutoObservable, observable } from "mobx"; +import { createRef } from "react"; +import { PaginationController } from "../services/PaginationController"; + +export class GridSizeStore { + gridContainerRef = createRef(); + gridBodyRef = createRef(); + gridHeaderRef = createRef(); + + scrollBarSize?: number; + gridWidth?: number; + gridBodyHeight?: number; + columnSizes?: number[]; + + paging: PaginationController; + + constructor(paging: PaginationController) { + this.paging = paging; + + makeAutoObservable(this, { + gridContainerRef: false, + gridBodyRef: false, + gridHeaderRef: false, + + scrollBarSize: observable, + setScrollBarSize: action, + + gridWidth: observable, + setGridWidth: action, + + gridBodyHeight: observable, + setGridBodyHeight: action, + + columnSizes: observable, + updateColumnSizes: action, + + templateColumnsHead: computed + }); + } + + get hasMoreItems(): boolean { + return this.paging.hasMoreItems; + } + + get isInfinite(): boolean { + return this.paging.pagination === "virtualScrolling"; + } + + get templateColumnsHead(): string | undefined { + return this.columnSizes?.map(s => `${s}px`).join(" "); + } + + bumpPage(): void { + if (this.paging.hasMoreItems) { + return this.paging.setPage(page => page + 1); + } + } + + setScrollBarSize(size: number): void { + this.scrollBarSize = size; + } + + setGridWidth(size: number | undefined): void { + this.gridWidth = size; + } + + setGridBodyHeight(size: number): void { + this.gridBodyHeight = size; + } + + updateColumnSizes(sizes: number[]): void { + this.columnSizes = sizes; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index 26b3e38201..b9ffd203c9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -42,6 +42,7 @@ import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./servic import { PaginationConfig, PaginationController } from "./services/PaginationController"; import { TextsService } from "./services/Texts.service"; import { PageSizeStore } from "./stores/PageSize.store"; +import { GridSizeStore } from "./stores/GridSize.store"; /** Tokens to resolve dependencies from the container. */ @@ -104,6 +105,8 @@ export const DG_TOKENS = { paginationConfig: token("PaginationConfig"), paginationService: token("PaginationService"), + gridSizeStore: token("@store:GridSizeStore"), + parentChannelName: token("parentChannelName"), refreshInterval: token("refreshInterval"), From b8866a95a5b9b543a719643df6db35a4e4389402 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Thu, 4 Dec 2025 10:52:05 +0100 Subject: [PATCH 2/4] fix: better css variable naming --- .../themesource/datawidgets/web/_datagrid.scss | 16 ++++++++-------- .../__tests__/__snapshots__/Grid.spec.tsx.snap | 4 ++-- .../datagrid-web/src/model/models/grid.model.ts | 10 +++++----- 3 files changed, 15 insertions(+), 15 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 c14055e9d9..e72d3f0a9f 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -537,7 +537,7 @@ $root: ".widget-datagrid"; .infinite-loading { .widget-datagrid-grid-head { - width: calc(var(--mx-grid-width) - var(--mx-grid-scrollbar-size)); + width: calc(var(--widgets-grid-width) - var(--widgets-grid-scrollbar-size)); overflow-x: hidden; } .widget-datagrid-grid-head[data-scrolled-y="true"] { @@ -545,9 +545,9 @@ $root: ".widget-datagrid"; } .widget-datagrid-grid-body { - width: var(--mx-grid-width); + width: var(--widgets-grid-width); overflow-y: auto; - max-height: var(--mx-grid-body-height); + max-height: var(--widgets-grid-body-height); } .widget-datagrid-grid-head[data-scrolled-x="true"]:after { @@ -564,14 +564,14 @@ $root: ".widget-datagrid"; .widget-datagrid-grid-head { display: grid; - // this head is not part of the grid, so it has dedicated column template --mx-grid-template-columns-head - // but it might not be available at the initial render, so we use template from the grid --mx-grid-template-columns + // this head is not part of the grid, so it has dedicated column template --widgets-grid-template-columns-head + // but it might not be available at the initial render, so we use template from the grid --widgets-grid-template-columns // using template from the grid might to misalignment from the grid itself, // but in practice: // - grid has no data at that moment, so misalignment is not visible. - // - as soon as the grid itself gets rendered --mx-grid-template-columns-head gets calculated + // - as soon as the grid itself gets rendered --widgets-grid-template-columns-head gets calculated // and everything looks like it should. - grid-template-columns: var(--mx-grid-template-columns-head, var(--mx-grid-template-columns)); + grid-template-columns: var(--widgets-grid-template-columns-head, var(--widgets-grid-template-columns)); } .widget-datagrid-grid-body { // this element has to position their children (columns or headers) @@ -579,7 +579,7 @@ $root: ".widget-datagrid"; display: grid; // this property makes sure we align our own grid columns // to the columns defined in the global grid - grid-template-columns: var(--mx-grid-template-columns); + grid-template-columns: var(--widgets-grid-template-columns); } .grid-mock-header { diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap index d1b9c62bea..3751635dc2 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap @@ -5,7 +5,7 @@ exports[`Grid renders without crashing 1`] = `
Test
@@ -17,7 +17,7 @@ exports[`Grid renders without selector column 1`] = `
Test
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts index ed86635fe6..184ef37286 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts @@ -16,14 +16,14 @@ export function gridStyleAtom( return computed( () => ({ - "--mx-grid-template-columns": templateColumns(columns.visibleColumns, { + "--widgets-grid-template-columns": templateColumns(columns.visibleColumns, { checkboxColumn: config.checkboxColumnEnabled, selectorColumn: config.selectorColumnEnabled }), - "--mx-grid-template-columns-head": gridSizeStore.templateColumnsHead, - "--mx-grid-body-height": asPx(gridSizeStore.gridBodyHeight), - "--mx-grid-width": asPx(gridSizeStore.gridWidth), - "--mx-grid-scrollbar-size": asPx(gridSizeStore.scrollBarSize) + "--widgets-grid-template-columns-head": gridSizeStore.templateColumnsHead, + "--widgets-grid-body-height": asPx(gridSizeStore.gridBodyHeight), + "--widgets-grid-width": asPx(gridSizeStore.gridWidth), + "--widgets-grid-scrollbar-size": asPx(gridSizeStore.scrollBarSize) }) as CSSProperties ); } From 0dbe5f97783f6f4a09f59e33b0f904b053ce86ff Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Thu, 4 Dec 2025 11:00:04 +0100 Subject: [PATCH 3/4] chore: remove useless todo and better naming --- .../datagrid-web/src/components/Grid.tsx | 5 +++-- .../src/model/hooks/useInfiniteControl.tsx | 10 +++++----- .../datagrid-web/src/model/models/grid.model.ts | 2 -- .../datagrid-web/src/model/stores/GridSize.store.ts | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx index 0de2f7d770..9c3135bc33 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx @@ -7,12 +7,13 @@ export const Grid = observer(function Grid(props: PropsWithChildren): ReactEleme const config = useDatagridConfig(); const gridSizeStore = useGridSizeStore(); - // TODO: add check custom css styling is applie const style = useGridStyle().get(); return (
void) | ); const gridBody = gridSizeStore.gridBodyRef.current; - const { isInfinite, gridBodyHeight, hasMoreItems } = gridSizeStore; + const { hasVirtualScrolling, gridBodyHeight, hasMoreItems } = gridSizeStore; const lockGridBodyHeight = useCallback((): void => { - if (isVisible && isInfinite && hasMoreItems && gridBodyHeight === undefined && gridBody) { + if (isVisible && hasVirtualScrolling && hasMoreItems && gridBodyHeight === undefined && gridBody) { gridSizeStore.setGridBodyHeight(gridBody.clientHeight - offsetBottom); } - }, [isVisible, isInfinite, hasMoreItems, gridBodyHeight, gridBody, gridSizeStore]); + }, [isVisible, hasVirtualScrolling, hasMoreItems, gridBodyHeight, gridBody, gridSizeStore]); useLayoutEffect(() => { setTimeout(() => lockGridBodyHeight(), 100); @@ -57,7 +57,7 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | useLayoutEffect(() => { const observeTarget = gridSizeStore.gridContainerRef.current; - if (!gridSizeStore.isInfinite || !observeTarget) return; + if (!gridSizeStore.hasVirtualScrolling || !observeTarget) return; const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { @@ -72,5 +72,5 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | }; }, [gridSizeStore]); - return [gridSizeStore.isInfinite ? trackBodyScrolling : undefined]; + return [gridSizeStore.hasVirtualScrolling ? trackBodyScrolling : undefined]; } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts index 184ef37286..e8c99f06a3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts @@ -11,8 +11,6 @@ export function gridStyleAtom( config: DatagridConfig, gridSizeStore: GridSizeStore ): ComputedAtom { - // todo: include custom variables from gridSizeStore; - return computed( () => ({ diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts index ef899cf4d4..9ffa180d56 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts @@ -42,7 +42,7 @@ export class GridSizeStore { return this.paging.hasMoreItems; } - get isInfinite(): boolean { + get hasVirtualScrolling(): boolean { return this.paging.pagination === "virtualScrolling"; } From 2f7959dc8634ba6b6021c7751096f5712400f36a Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Thu, 4 Dec 2025 15:46:21 +0100 Subject: [PATCH 4/4] chore: reorganize code --- .../src/model/hooks/useInfiniteControl.tsx | 22 +++++------------ .../src/model/stores/GridSize.store.ts | 24 ++++++++++++------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx index f1013c2211..c9f7fa9b8d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx @@ -1,8 +1,7 @@ -import { RefObject, UIEvent, useCallback, useLayoutEffect } from "react"; +import { RefObject, UIEvent, useCallback, useEffect, useLayoutEffect } from "react"; import { useOnScreen } from "@mendix/widget-plugin-hooks/useOnScreen"; import { useGridSizeStore } from "@mendix/datagrid-web/src/model/hooks/injection-hooks"; - -const offsetBottom = 30; +import { VIRTUAL_SCROLLING_OFFSET } from "../stores/GridSize.store"; export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | undefined] { const gridSizeStore = useGridSizeStore(); @@ -33,7 +32,7 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | * causing mismatch by 1 pixel point, thus, add magic number 2 as buffer. */ const bottom = - Math.floor(target.scrollHeight - offsetBottom - target.scrollTop) <= + Math.floor(target.scrollHeight - VIRTUAL_SCROLLING_OFFSET - target.scrollTop) <= Math.floor(target.clientHeight) + 2; if (bottom) { gridSizeStore.bumpPage(); @@ -42,18 +41,9 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | [gridSizeStore] ); - const gridBody = gridSizeStore.gridBodyRef.current; - const { hasVirtualScrolling, gridBodyHeight, hasMoreItems } = gridSizeStore; - - const lockGridBodyHeight = useCallback((): void => { - if (isVisible && hasVirtualScrolling && hasMoreItems && gridBodyHeight === undefined && gridBody) { - gridSizeStore.setGridBodyHeight(gridBody.clientHeight - offsetBottom); - } - }, [isVisible, hasVirtualScrolling, hasMoreItems, gridBodyHeight, gridBody, gridSizeStore]); - - useLayoutEffect(() => { - setTimeout(() => lockGridBodyHeight(), 100); - }, [lockGridBodyHeight]); + useEffect(() => { + setTimeout(() => isVisible && gridSizeStore.lockGridBodyHeight(), 100); + }, [isVisible, gridSizeStore]); useLayoutEffect(() => { const observeTarget = gridSizeStore.gridContainerRef.current; diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts index 9ffa180d56..fee206c8df 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts @@ -2,6 +2,8 @@ import { action, computed, makeAutoObservable, observable } from "mobx"; import { createRef } from "react"; import { PaginationController } from "../services/PaginationController"; +export const VIRTUAL_SCROLLING_OFFSET = 30; + export class GridSizeStore { gridContainerRef = createRef(); gridBodyRef = createRef(); @@ -29,7 +31,7 @@ export class GridSizeStore { setGridWidth: action, gridBodyHeight: observable, - setGridBodyHeight: action, + lockGridBodyHeight: action, columnSizes: observable, updateColumnSizes: action, @@ -38,10 +40,6 @@ export class GridSizeStore { }); } - get hasMoreItems(): boolean { - return this.paging.hasMoreItems; - } - get hasVirtualScrolling(): boolean { return this.paging.pagination === "virtualScrolling"; } @@ -64,11 +62,19 @@ export class GridSizeStore { this.gridWidth = size; } - setGridBodyHeight(size: number): void { - this.gridBodyHeight = size; - } - updateColumnSizes(sizes: number[]): void { this.columnSizes = sizes; } + + lockGridBodyHeight(): void { + if (!this.hasVirtualScrolling || !this.paging.hasMoreItems) { + return; + } + const gridBody = this.gridBodyRef.current; + if (!gridBody || this.gridBodyHeight !== undefined) { + return; + } + + this.gridBodyHeight = gridBody.clientHeight - VIRTUAL_SCROLLING_OFFSET; + } }