diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 36635bfbe1..9ad253ede0 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We added a "Custom row key" property in the Advanced section to provide stable row identifiers when using view entities, preventing scroll position loss during data refresh. +### Fixed + +- We fixed an issue where the vertical scrollbar disappeared after hiding a wide column while virtual scrolling was enabled. +- We fixed an issue where only the first page loaded when the grid had enough columns to require horizontal scrolling. + ## [3.9.0] - 2026-03-23 ### Changed 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 7b9893c7f7..2499558714 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -79,7 +79,14 @@ const _01_coreBindings: BindingGroup = { DG.exportProgressService, SA_TOKENS.selectionDialogVM ); - injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction, DG.pageSize); + injected( + GridSizeStore, + CORE.atoms.hasMoreItems, + DG.paginationConfig, + DG.setPageAction, + DG.pageSize, + CORE.atoms.visibleColumnsCount + ); }, define(container: Container) { container.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope(); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/__tests__/useInfiniteControl.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/__tests__/useInfiniteControl.spec.tsx new file mode 100644 index 0000000000..9e3001fd13 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/__tests__/useInfiniteControl.spec.tsx @@ -0,0 +1,98 @@ +import { renderHook, act } from "@testing-library/react"; +import { createRef, UIEvent } from "react"; + +jest.mock("@mendix/widget-plugin-hooks/useOnScreen", () => ({ + useOnScreen: () => true +})); + +const mockBumpPage = jest.fn(); +const mockLockGridContainerHeight = jest.fn(); +const mockGridContainerRef = createRef(); + +jest.mock("../injection-hooks", () => ({ + useGridSizeStore: () => ({ + hasVirtualScrolling: true, + gridContainerRef: mockGridContainerRef, + bumpPage: mockBumpPage, + lockGridContainerHeight: mockLockGridContainerHeight + }) +})); + +import { useInfiniteControl } from "../useInfiniteControl"; +import { VIRTUAL_SCROLLING_OFFSET } from "../../stores/GridSize.store"; + +function makeScrollEvent(overrides: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + scrollLeft?: number; +}): UIEvent { + const { scrollTop, scrollHeight, clientHeight, scrollLeft = 0 } = overrides; + return { + target: { scrollTop, scrollHeight, clientHeight, scrollLeft } + } as unknown as UIEvent; +} + +describe("useInfiniteControl — trackTableScrolling", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockBumpPage.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("does not call bumpPage when scrollTop is 0, even if scroll position satisfies bottom condition", () => { + const { result } = renderHook(() => useInfiniteControl()); + const [trackTableScrolling] = result.current; + + // scrollTop === 0 → guard blocks bumpPage regardless of height math + const e = makeScrollEvent({ + scrollTop: 0, + scrollHeight: 500, + clientHeight: 500 + }); + + act(() => { + trackTableScrolling!(e); + }); + + expect(mockBumpPage).not.toHaveBeenCalled(); + }); + + it("calls bumpPage when scrollTop > 0 and scroll position reaches the bottom", () => { + const { result } = renderHook(() => useInfiniteControl()); + const [trackTableScrolling] = result.current; + + // scrollTop > 0 and Math.floor(scrollHeight - OFFSET - scrollTop) <= clientHeight + 2 + const scrollHeight = 500; + const clientHeight = 400; + const scrollTop = scrollHeight - VIRTUAL_SCROLLING_OFFSET - clientHeight; // exactly at bottom + + const e = makeScrollEvent({ scrollTop, scrollHeight, clientHeight }); + + act(() => { + trackTableScrolling!(e); + }); + + expect(mockBumpPage).toHaveBeenCalledTimes(1); + }); + + it("does not call bumpPage when scrolled but not yet at the bottom", () => { + const { result } = renderHook(() => useInfiniteControl()); + const [trackTableScrolling] = result.current; + + const e = makeScrollEvent({ + scrollTop: 10, + scrollHeight: 500, + clientHeight: 100 + }); + + act(() => { + trackTableScrolling!(e); + }); + + expect(mockBumpPage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx index c7165b417c..52405f04cb 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx @@ -32,8 +32,9 @@ export function useInfiniteControl(): [trackTableScrolling: ((e: any) => void) | * causing mismatch by 1 pixel point, thus, add magic number 2 as buffer. */ const bottom = + target.scrollTop > 0 && Math.floor(target.scrollHeight - VIRTUAL_SCROLLING_OFFSET - target.scrollTop) <= - Math.floor(target.clientHeight) + 2; + Math.floor(target.clientHeight) + 2; if (bottom) { gridSizeStore.bumpPage(); } 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 77c9e08d89..6ed786649d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts @@ -13,25 +13,30 @@ export class GridSizeStore { gridContainerHeight?: number; - private lockedAtPageSize?: number; + private lockedAtLayoutKey?: string; constructor( private readonly hasMoreItemsAtom: ComputedAtom, private readonly paginationConfig: PaginationConfig, private readonly setPageAction: SetPageAction, - private readonly pageSizeAtom: ComputedAtom + private readonly pageSizeAtom: ComputedAtom, + private readonly visibleColumnsCountAtom: ComputedAtom ) { - makeAutoObservable(this, { + makeAutoObservable(this, { gridContainerRef: false, gridBodyRef: false, gridHeaderRef: false, - lockedAtPageSize: false, + lockedAtLayoutKey: false, gridContainerHeight: observable, lockGridContainerHeight: action }); } + private get layoutKey(): string { + return `${this.pageSizeAtom.get()}-${this.visibleColumnsCountAtom.get()}`; + } + get hasMoreItems(): boolean { return this.hasMoreItemsAtom.get() ?? false; } @@ -80,37 +85,33 @@ export class GridSizeStore { return; } - // Reset the locked height when page size changes so layout is recomputed - // for the new number of rows (e.g. switching from 10 → 5 rows). - const currentPageSize = this.pageSizeAtom.get(); - if (this.gridContainerHeight !== undefined && this.lockedAtPageSize !== currentPageSize) { - this.gridContainerHeight = undefined; - this.lockedAtPageSize = undefined; + // Single cache key encodes all layout inputs. Any change (page size, column count, + // or future inputs) invalidates the lock in one place. + const currentKey = this.layoutKey; + if (this.lockedAtLayoutKey !== currentKey) { + this.lockedAtLayoutKey = undefined; } const gridContainer = this.gridContainerRef.current; - if (!gridContainer || this.gridContainerHeight !== undefined) { + if (!gridContainer || this.lockedAtLayoutKey !== undefined) { return; } const bodyViewportHeight = this.computeBodyViewport(); const headerViewportHeight = this.computeHeaderViewport(); - // Don't lock height before the grid body has rendered content. - // clientHeight is 0 when the element has no layout yet, which would - // produce a negative height and break scrolling. + // Don't lock before the grid body has rendered content — clientHeight is 0 + // before layout, which would produce a negative height and break scrolling. if (bodyViewportHeight <= 0) { return; } const fullHeight = bodyViewportHeight + headerViewportHeight; - // If content already overflows the container (fixed-height grid), do not subtract the - // pre-fetch offset — that would hide the last rows and trigger the next page too early. - // Only subtract the offset when the grid does not yet overflow (auto-height grid) so - // that we create a small synthetic overflow that makes the body scrollable. - const overflows = gridContainer.scrollHeight > fullHeight; + // clientHeight is vertical-only (excludes horizontal scrollbar overflow). + // scrollHeight would be inflated by many-column grids and produce false positives. + const overflows = gridContainer.clientHeight < fullHeight; this.gridContainerHeight = fullHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET); - this.lockedAtPageSize = currentPageSize; + this.lockedAtLayoutKey = currentKey; } } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/__tests__/GridSize.store.spec.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/__tests__/GridSize.store.spec.ts new file mode 100644 index 0000000000..f2fee5917f --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/__tests__/GridSize.store.spec.ts @@ -0,0 +1,201 @@ +import { MutableRefObject } from "react"; +import { computed, configure, observable } from "mobx"; +import { SetPageAction } from "@mendix/widget-plugin-grid/pagination/main"; +import { PaginationConfig } from "../../../features/pagination/pagination.config"; +import { GridSizeStore, VIRTUAL_SCROLLING_OFFSET } from "../GridSize.store"; + +configure({ enforceActions: "never" }); + +function makePaginationConfig(pagination: "virtualScrolling" | "buttons" = "virtualScrolling"): PaginationConfig { + return { + pagination, + pagingPosition: "bottom", + paginationKind: "virtualScrolling.always", + showPagingButtons: "always", + showNumberOfRows: false, + constPageSize: 10, + initPageSize: 10, + isLimitBased: false, + dynamicPageSizeEnabled: false, + dynamicPageEnabled: false, + customPaginationEnabled: false, + requestTotalCount: false + }; +} + +interface StoreOptions { + hasMoreItems?: boolean; + pageSize?: number; + columnCount?: number; + pagination?: "virtualScrolling" | "buttons"; +} + +function buildStore(options: StoreOptions = {}): { + store: GridSizeStore; + hasMoreItemsBox: ReturnType>; + pageSizeBox: ReturnType>; + columnCountBox: ReturnType>; + setPageAction: jest.Mock; +} { + const { hasMoreItems = true, pageSize = 10, columnCount = 3, pagination = "virtualScrolling" } = options; + + const hasMoreItemsBox = observable.box(hasMoreItems); + const pageSizeBox = observable.box(pageSize); + const columnCountBox = observable.box(columnCount); + const setPageAction = jest.fn() as unknown as SetPageAction; + + const store = new GridSizeStore( + computed(() => hasMoreItemsBox.get()), + makePaginationConfig(pagination), + setPageAction, + computed(() => pageSizeBox.get()), + computed(() => columnCountBox.get()) + ); + + return { + store, + hasMoreItemsBox, + pageSizeBox, + columnCountBox, + setPageAction: setPageAction as unknown as jest.Mock + }; +} + +function createMockRows(count: number, rowHeight: number): HTMLElement[] { + return Array.from({ length: count }, () => { + const row = document.createElement("div"); + const cell = document.createElement("div"); + Object.defineProperty(cell, "clientHeight", { value: rowHeight, configurable: true }); + row.appendChild(cell); + return row; + }); +} + +interface RefsOptions { + rowHeight?: number; + rowCount?: number; + headerHeight?: number; + containerClientHeight?: number; + bodyScrollHeight?: number; + bodyClientHeight?: number; +} + +function setupRefs(store: GridSizeStore, options: RefsOptions = {}): void { + const { + rowHeight = 40, + rowCount = 10, + headerHeight = 50, + containerClientHeight = 1000, + bodyScrollHeight, + bodyClientHeight + } = options; + + const container = document.createElement("div"); + Object.defineProperty(container, "clientHeight", { value: containerClientHeight, configurable: true }); + (store.gridContainerRef as MutableRefObject).current = container; + + const body = document.createElement("div"); + createMockRows(rowCount, rowHeight).forEach(r => body.appendChild(r)); + Object.defineProperty(body, "scrollHeight", { + value: bodyScrollHeight ?? rowHeight * rowCount, + configurable: true + }); + Object.defineProperty(body, "clientHeight", { + value: bodyClientHeight ?? containerClientHeight - headerHeight, + configurable: true + }); + (store.gridBodyRef as MutableRefObject).current = body; + + const header = document.createElement("div"); + const th = document.createElement("div"); + th.className = "th"; + Object.defineProperty(th, "offsetHeight", { value: headerHeight, configurable: true }); + header.appendChild(th); + (store.gridHeaderRef as MutableRefObject).current = header; +} + +describe("GridSizeStore.lockGridContainerHeight()", () => { + it("locks container height on first call when virtual scrolling is active and hasMoreItems", () => { + const { store } = buildStore({ pageSize: 3, columnCount: 2 }); + // bodyViewport = 3 rows × 40px = 120px, header = 50px → fullHeight = 170px + // containerClientHeight (1000) >= fullHeight (170) → no overflow → subtract VIRTUAL_SCROLLING_OFFSET + setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + + store.lockGridContainerHeight(); + + expect(store.gridContainerHeight).toBe(170 - VIRTUAL_SCROLLING_OFFSET); + }); + + it("is a no-op when hasVirtualScrolling is false", () => { + const { store } = buildStore({ pagination: "buttons" }); + setupRefs(store); + + store.lockGridContainerHeight(); + + expect(store.gridContainerHeight).toBeUndefined(); + }); + + it("is a no-op when hasMoreItems is false", () => { + const { store } = buildStore({ hasMoreItems: false }); + setupRefs(store); + + store.lockGridContainerHeight(); + + expect(store.gridContainerHeight).toBeUndefined(); + }); + + it("does not double-lock on repeated calls with same layout inputs", () => { + const { store } = buildStore({ pageSize: 3, columnCount: 2 }); + setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + + store.lockGridContainerHeight(); + const heightAfterFirst = store.gridContainerHeight; + + // Mutate refs to simulate different measurements — if re-locking, height would change + setupRefs(store, { rowHeight: 99, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + store.lockGridContainerHeight(); + + expect(store.gridContainerHeight).toBe(heightAfterFirst); + }); + + it("clears and recomputes height when page size changes", () => { + const { store, pageSizeBox } = buildStore({ pageSize: 3, columnCount: 2 }); + setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + store.lockGridContainerHeight(); + const heightWithPageSize3 = store.gridContainerHeight; + + pageSizeBox.set(5); + setupRefs(store, { rowHeight: 40, rowCount: 5, headerHeight: 50, containerClientHeight: 1000 }); + store.lockGridContainerHeight(); + + // bodyViewport = 5 × 40 = 200, header = 50 → fullHeight = 250, no overflow → subtract offset + expect(store.gridContainerHeight).toBe(250 - VIRTUAL_SCROLLING_OFFSET); + expect(store.gridContainerHeight).not.toBe(heightWithPageSize3); + }); + + it("does not subtract offset when container height is smaller than full content height (overflow case)", () => { + const { store } = buildStore({ pageSize: 3, columnCount: 2 }); + // fullHeight = 3×40 + 50 = 170px; container is only 100px → overflows + setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 100 }); + + store.lockGridContainerHeight(); + + expect(store.gridContainerHeight).toBe(170); // no offset subtracted + }); + + it("clears and recomputes height when visible column count changes", () => { + const { store, columnCountBox } = buildStore({ pageSize: 3, columnCount: 2 }); + setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + store.lockGridContainerHeight(); + const heightWith2Columns = store.gridContainerHeight; + + columnCountBox.set(1); + // Simulate rows getting shorter after hiding a column + setupRefs(store, { rowHeight: 20, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 }); + store.lockGridContainerHeight(); + + // bodyViewport = 3 × 20 = 60, header = 50 → fullHeight = 110, no overflow → subtract offset + expect(store.gridContainerHeight).toBe(110 - VIRTUAL_SCROLLING_OFFSET); + expect(store.gridContainerHeight).not.toBe(heightWith2Columns); + }); +});