diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index a6b988fe49..f5c7922c51 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -14,10 +14,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We added accessibility support for column headers when single selection is enabled, making sure the purpose of the column is announced. +- We added a new `Loaded rows` attribute that reflects the number of rows currently loaded for virtual scrolling and load-more pagination modes. + +- We exposed the `Page`, `Page size`, and `Total count` attributes for virtual scrolling and load-more pagination modes so they are kept in sync at all times. + ### Fixed - We fixed an issue with Data export crashing on some Android devices. +- We fixed an issue where the `Page` attribute was not updated when navigating pages using the default (buttons) paging controls. + +- We fixed an issue where configuring the `Total count` attribute had no effect for virtual scrolling and load-more pagination modes. + ## [3.8.1] - 2026-02-19 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index 8a390bba23..8fb49539d6 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -1,7 +1,7 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; import path from "path"; -import { test, expect } from "@playwright/test"; import * as XLSX from "xlsx"; -import AxeBuilder from "@axe-core/playwright"; test.afterEach("Cleanup session", async ({ page }) => { // Because the test isolation that will open a new session for every test executed, and that exceeds Mendix's license limit of 5 sessions, so we need to force logout after each test. @@ -144,7 +144,7 @@ test.describe("capabilities: hiding", () => { await page.locator(".column-selectors > li").nth(2).click(); await page.locator(".column-selectors > li").nth(1).click(); await expect(page.locator(".column-selectors input:checked")).toHaveCount(1); - await page.locator(".column-selectors > li").nth(0).click(); + await page.locator(".column-selectors > li").nth(0).click({ force: true }); await expect(page.locator(".column-selectors input:checked")).toHaveCount(1); // Trigger Enter keypress await page.locator(".column-selectors > li").nth(0).press("Enter"); diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png index e0933e3365..38feac2be0 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-virtual-scrolling-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 8458aadaa1..fa7e6690df 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -69,6 +69,7 @@ export function getProperties(values: DatagridPreviewProps, defaultProperties: P if (values.pagination === "buttons") { hidePropertyIn(defaultProperties, values, "showNumberOfRows"); + hidePropertyIn(defaultProperties, values, "loadedRowsValue"); if (values.useCustomPagination === false) { hidePropertyIn(defaultProperties, values, "customPagination"); @@ -82,13 +83,7 @@ export function getProperties(values: DatagridPreviewProps, defaultProperties: P hidePropertyIn(defaultProperties, values, "pagingPosition"); } - hidePropertiesIn(defaultProperties, values, [ - "dynamicPage", - "dynamicPageSize", - "useCustomPagination", - "customPagination", - "totalCountValue" - ]); + hidePropertiesIn(defaultProperties, values, ["useCustomPagination", "customPagination"]); } if (values.pagination !== "loadMore") { @@ -335,6 +330,19 @@ export const getPreview = ( ) ] : []; + const customPaginationWidgets = values.useCustomPagination + ? [ + rowLayout({ + columnSize: "fixed", + borders: true + })( + dropzone( + dropzone.placeholder("Custom pagination: Place widgets here"), + dropzone.hideDataSourceHeaderIf(canHideDataSourceHeader) + )(values.customPagination) + ) + ] + : []; return container()( gridTitle, @@ -342,6 +350,7 @@ export const getPreview = ( gridHeaderWidgets, columnHeaders, ...Array.from({ length: 5 }).map(() => columns), + ...customPaginationWidgets, ...customEmptyMessageWidgets ); }; diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index d471736ec2..5caa0fd525 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -105,10 +105,7 @@ function WidgetTopBar(): ReactElement {
{useTopCounter() ? : null}
-
- {usePagingTop() ? : null} - {useCustomPagination("top") ? : null} -
+
{usePagingTop() ? : null}
); @@ -142,7 +139,7 @@ function WidgetFooter(): ReactElement {
{usePagingBot() ? : null} - {useCustomPagination("bottom") ? : null} + {useCustomPagination() ? : null}
@@ -406,7 +403,7 @@ function usePagingBot(): boolean { return visible && props.pagingPosition !== "top"; } -function useCustomPagination(location: "top" | "bottom"): boolean { +function useCustomPagination(): boolean { const props = useProps(); - return props.useCustomPagination && (props.pagingPosition === location || props.pagingPosition === "both"); + return props.useCustomPagination; } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index ca8fa535b2..168ff9c703 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -342,6 +342,13 @@ + + Loaded rows + Read-only attribute reflecting the number of rows currently loaded. + + + + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 5433e25ce2..ee39956ce3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -13,7 +13,11 @@ export const WidgetFooter = observer(function WidgetFooter(): ReactElement | nul const selectionCounterVM = useSelectionCounterViewModel(); const customPagination = useCustomPagination(); - const showFooter = selectionCounterVM.isBottomCounterVisible || paging.paginationVisible || paging.loadMoreVisible; + const showFooter = + selectionCounterVM.isBottomCounterVisible || + paging.paginationVisible || + paging.loadMoreVisible || + pgConfig.customPaginationEnabled; if (!showFooter) { return null; @@ -39,7 +43,7 @@ export const WidgetFooter = observer(function WidgetFooter(): ReactElement | nul
- + {customPagination.get()} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx index b53024d735..627e30f9de 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx @@ -19,7 +19,7 @@ export const WidgetTopBar = observer(function WidgetTopBar(): ReactElement {
- +
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnSelector.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnSelector.spec.tsx index 726fc3461f..c1f7bdfe53 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnSelector.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/ColumnSelector.spec.tsx @@ -3,6 +3,17 @@ import userEvent from "@testing-library/user-event"; import { ColumnSelector, ColumnSelectorProps } from "../ColumnSelector"; import { ColumnId, GridColumn } from "../../typings/GridColumn"; +beforeAll(() => { + Object.defineProperty(global, "ResizeObserver", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn() + })) + }); +}); + jest.useFakeTimers(); describe("Column Selector", () => { diff --git a/packages/pluggableWidgets/datagrid-web/src/features/pagination/__tests__/pagination.config.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/pagination/__tests__/pagination.config.spec.ts new file mode 100644 index 0000000000..c8d388d45f --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/pagination/__tests__/pagination.config.spec.ts @@ -0,0 +1,104 @@ +import { MainGateProps } from "typings/MainGateProps"; +import { + dynamicPageEnabled, + dynamicPageSizeEnabled, + requestTotalCount, + resolveInitPageSize +} from "../pagination.config"; + +function makeProps(overrides = {}): Partial { + return { + pagination: "buttons", + showNumberOfRows: false, + pageSize: 10, + pagingPosition: "bottom", + useCustomPagination: false, + showPagingButtons: "always", + refreshIndicator: false, + refreshInterval: 0, + datasource: undefined, + columns: [], + filtersPlaceholder: undefined, + ...overrides + }; +} + +describe("pagination.config helpers", () => { + describe("requestTotalCount", () => { + it("returns true when totalCountValue attribute is mapped regardless of pagination mode", () => { + const props = makeProps({ totalCountValue: {}, pagination: "virtualScrolling" }); + expect(requestTotalCount(props as MainGateProps)).toBe(true); + }); + + it("returns true for buttons pagination even without attribute", () => { + const props = makeProps({ pagination: "buttons" }); + expect(requestTotalCount(props as MainGateProps)).toBe(true); + }); + + it("returns true when showNumberOfRows is true", () => { + const props = makeProps({ pagination: "virtualScrolling", showNumberOfRows: true }); + expect(requestTotalCount(props as MainGateProps)).toBe(true); + }); + + it("returns false for virtual scrolling without totalCountValue or showNumberOfRows", () => { + const props = makeProps({ pagination: "virtualScrolling", showNumberOfRows: false }); + expect(requestTotalCount(props as MainGateProps)).toBe(false); + }); + }); + + describe("dynamicPageEnabled", () => { + it("is true when dynamicPage attribute is mapped for buttons mode", () => { + const props = makeProps({ dynamicPage: {}, pagination: "buttons" }); + expect(dynamicPageEnabled(props as MainGateProps)).toBe(true); + }); + + it("is true when dynamicPage attribute is mapped for virtualScrolling mode", () => { + const props = makeProps({ dynamicPage: {}, pagination: "virtualScrolling" }); + expect(dynamicPageEnabled(props as MainGateProps)).toBe(true); + }); + + it("is true when dynamicPage attribute is mapped for loadMore mode", () => { + const props = makeProps({ dynamicPage: {}, pagination: "loadMore" }); + expect(dynamicPageEnabled(props as MainGateProps)).toBe(true); + }); + + it("is false when no dynamicPage attribute is provided", () => { + const props = makeProps({ pagination: "virtualScrolling" }); + expect(dynamicPageEnabled(props as MainGateProps)).toBe(false); + }); + }); + + describe("dynamicPageSizeEnabled", () => { + it("is true when dynamicPageSize attribute is mapped for buttons mode", () => { + const props = makeProps({ dynamicPageSize: {}, pagination: "buttons" }); + expect(dynamicPageSizeEnabled(props as MainGateProps)).toBe(true); + }); + + it("is true when dynamicPageSize attribute is mapped for virtualScrolling mode", () => { + const props = makeProps({ dynamicPageSize: {}, pagination: "virtualScrolling" }); + expect(dynamicPageSizeEnabled(props as MainGateProps)).toBe(true); + }); + + it("is true when dynamicPageSize attribute is mapped for loadMore mode", () => { + const props = makeProps({ dynamicPageSize: {}, pagination: "loadMore" }); + expect(dynamicPageSizeEnabled(props as MainGateProps)).toBe(true); + }); + + it("is false when no dynamicPageSize attribute is provided", () => { + const props = makeProps({ pagination: "loadMore" }); + expect(dynamicPageSizeEnabled(props as MainGateProps)).toBe(false); + }); + }); + + describe("resolveInitPageSize", () => { + it("returns 0 when dynamicPageSize attribute is set", () => { + const props = makeProps({ dynamicPageSize: {} }); + expect(resolveInitPageSize(props as MainGateProps)).toBe(0); + }); + + it("falls back to constPageSize when dynamicPageSize is not set", () => { + const props = makeProps({ pageSize: 10 }); + expect(resolveInitPageSize(props as MainGateProps)).toBe(10); + }); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/pagination/pagination.config.ts b/packages/pluggableWidgets/datagrid-web/src/features/pagination/pagination.config.ts index 9b66fc4efa..ee7462ca9b 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/pagination/pagination.config.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/pagination/pagination.config.ts @@ -8,6 +8,7 @@ export interface PaginationConfig { showPagingButtons: ShowPagingButtonsEnum; showNumberOfRows: boolean; constPageSize: number; + initPageSize: number; isLimitBased: boolean; dynamicPageSizeEnabled: boolean; dynamicPageEnabled: boolean; @@ -23,6 +24,7 @@ export function paginationConfig(props: MainGateProps): PaginationConfig { showPagingButtons: props.showPagingButtons, showNumberOfRows: props.showNumberOfRows, constPageSize: props.pageSize, + initPageSize: resolveInitPageSize(props), isLimitBased: isLimitBased(props), paginationKind: paginationKind(props), dynamicPageSizeEnabled: dynamicPageSizeEnabled(props), @@ -35,6 +37,19 @@ export function paginationConfig(props: MainGateProps): PaginationConfig { return Object.freeze(config); } +/** + * Resolves the initial page size for the first datasource fetch. + * Returns 0 when `dynamicPageSize` is configured so that no rows are fetched + * before the attribute value is available — the real limit is applied once + * `DynamicPaginationFeature` syncs the attribute on setup. + */ +export function resolveInitPageSize(props: MainGateProps): number { + if (props.dynamicPageSize !== undefined) { + return 0; + } + return props.pageSize; +} + export function paginationKind(props: MainGateProps): PaginationKind { if (props.useCustomPagination) { return "custom"; @@ -44,17 +59,17 @@ export function paginationKind(props: MainGateProps): PaginationKind { } export function dynamicPageSizeEnabled(props: MainGateProps): boolean { - return props.dynamicPageSize !== undefined && !isLimitBased(props); + return props.dynamicPageSize !== undefined; } export function dynamicPageEnabled(props: MainGateProps): boolean { - return props.dynamicPage !== undefined && !isLimitBased(props); + return props.dynamicPage !== undefined; } function isLimitBased(props: MainGateProps): boolean { return props.pagination === "virtualScrolling" || props.pagination === "loadMore"; } -function requestTotalCount(props: MainGateProps): boolean { - return props.pagination === "buttons" || props.showNumberOfRows; +export function requestTotalCount(props: MainGateProps): boolean { + return props.pagination === "buttons" || props.showNumberOfRows || props.totalCountValue !== 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 76fc142eb7..eb3d45505f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -19,6 +19,7 @@ import { dynamicPageAtom, dynamicPageSizeAtom, DynamicPaginationFeature, + loadedRowsAtom, PageControlService, pageSizeAtom, PaginationViewModel @@ -79,7 +80,7 @@ const _01_coreBindings: BindingGroup = { DG.exportProgressService, SA_TOKENS.selectionDialogVM ); - injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction); + injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction, DG.pageSize); }, define(container: Container) { container.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope(); @@ -191,9 +192,13 @@ const _06_paginationBindings: BindingGroup = { DG.dynamicPage, DG.dynamicPageSize, CORE.atoms.totalCount, + DG.currentPage, + DG.loadedRows, + CORE.mainGate, DG.pageControl ); injected(customPaginationAtom, CORE.mainGate); + injected(loadedRowsAtom, CORE.atoms.itemCount); }, define(container: Container) { container.bind(DG.currentPage).toInstance(currentPageAtom).inTransientScope(); @@ -201,6 +206,7 @@ const _06_paginationBindings: BindingGroup = { container.bind(DG.dynamicPage).toInstance(dynamicPageAtom).inTransientScope(); container.bind(DG.dynamicPageSize).toInstance(dynamicPageSizeAtom).inTransientScope(); container.bind(DG.dynamicPagination).toInstance(DynamicPaginationFeature).inSingletonScope(); + container.bind(DG.loadedRows).toInstance(loadedRowsAtom).inTransientScope(); container.bind(DG.pageSize).toInstance(pageSizeAtom).inTransientScope(); container.bind(DG.pageControl).toInstance(PageControlService).inSingletonScope(); container.bind(DG.paginationVM).toInstance(PaginationViewModel).inSingletonScope(); @@ -210,13 +216,15 @@ const _06_paginationBindings: BindingGroup = { init(container, { props }) { const config = paginationConfig(props); container.bind(DG.paginationConfig).toConstant(config); - container.bind(CORE.initPageSize).toConstant(config.constPageSize); + container.bind(CORE.initPageSize).toConstant(config.initPageSize); }, postInit(container) { const config = container.get(DG.paginationConfig); const query = container.get(DG.query); query.requestTotalCount(config.requestTotalCount); - query.setBaseLimit(config.constPageSize); + // Use the resolved initPageSize (dynamic value if provided, else constPageSize) + // so the very first datasource fetch uses the correct limit. + query.setBaseLimit(container.get(CORE.initPageSize)); container.get(DG.dynamicPagination); // Enable dynamic pagination feature } }; diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx index c7c68ecfe7..50da655ed0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx @@ -42,7 +42,8 @@ export function useInfiniteControl(): [trackBodyScrolling: ((e: any) => void) | ); useEffect(() => { - setTimeout(() => isVisible && gridSizeStore.lockGridBodyHeight(), 100); + const timer = setTimeout(() => isVisible && gridSizeStore.lockGridBodyHeight(), 100); + return () => clearTimeout(timer); }); useEffect(() => { 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 8adbec0793..b3a11b6688 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts @@ -16,15 +16,19 @@ export class GridSizeStore { gridBodyHeight?: number; columnSizes?: number[]; + private lockedAtPageSize?: number; + constructor( private readonly hasMoreItemsAtom: ComputedAtom, private readonly paginationConfig: PaginationConfig, - private readonly setPageAction: SetPageAction + private readonly setPageAction: SetPageAction, + private readonly pageSizeAtom: ComputedAtom ) { - makeAutoObservable(this, { + makeAutoObservable(this, { gridContainerRef: false, gridBodyRef: false, gridHeaderRef: false, + lockedAtPageSize: false, scrollBarSize: observable, setScrollBarSize: action, @@ -78,15 +82,58 @@ export class GridSizeStore { this.columnSizes = sizes; } + /** + * Computes the total viewport height of visible rows based on the current page size. + * @returns {number} Total height in pixels of visible rows, or 0 if no rows present. + */ + computeBodyViewport(): number { + const rows = Array.from(this.gridBodyRef.current?.children ?? []); + if (rows.length === 0) { + return 0; + } + + const pageSize = this.pageSizeAtom.get(); + const visibleRows = rows.slice(0, pageSize); + const totalHeight = visibleRows.reduce((sum, row) => { + const rowHeight = row.children[0]?.clientHeight ?? 0; + return sum + rowHeight; + }, 0); + return totalHeight; + } + lockGridBodyHeight(): void { if (!this.hasVirtualScrolling || !this.hasMoreItems) { 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.gridBodyHeight !== undefined && this.lockedAtPageSize !== currentPageSize) { + this.gridBodyHeight = undefined; + this.lockedAtPageSize = undefined; + } + const gridBody = this.gridBodyRef.current; if (!gridBody || this.gridBodyHeight !== undefined) { return; } - this.gridBodyHeight = gridBody.clientHeight - VIRTUAL_SCROLLING_OFFSET; + const viewportHeight = this.computeBodyViewport(); + + // 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. + if (viewportHeight <= 0) { + return; + } + + // 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 = gridBody.scrollHeight > viewportHeight; + this.gridBodyHeight = viewportHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET); + this.lockedAtPageSize = currentPageSize; } } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index cfe55f1015..4fd10657db 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -112,6 +112,7 @@ export const DG_TOKENS = { dynamicPage: token>("@computed:dynamicPage"), dynamicPageSize: token>("@computed:dynamicPageSize"), dynamicPagination: token("@feature:DynamicPaginationFeature"), + loadedRows: token>("@computed:loadedRows"), pageControl: token("@service:GridPageControl"), pageSize: token>("@computed:pageSize"), paginationConfig: token("@config:PaginationConfig"), diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 790d4402f3..5e8f15a46b 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -127,6 +127,7 @@ export interface DatagridContainerProps { dynamicPageSize?: EditableValue; dynamicPage?: EditableValue; totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder?: ReactNode; rowClass?: ListExpressionValue; @@ -193,6 +194,7 @@ export interface DatagridPreviewProps { dynamicPageSize: string; dynamicPage: string; totalCountValue: string; + loadedRowsValue: string; showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; rowClass: string; diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts index 0f907db9a7..dd1c49627c 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts @@ -42,6 +42,7 @@ export type MainGateProps = Pick< | "storeFiltersInPersonalization" | "style" | "totalCountValue" + | "loadedRowsValue" | "useCustomPagination" | "customPagination" >; diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md index a8b1216aa2..4169649161 100644 --- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md +++ b/packages/pluggableWidgets/gallery-web/CHANGELOG.md @@ -6,12 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed the pagination properties `Page attribute`, `Page size attribute`, and `Total count` not being shown in Studio Pro for Virtual Scrolling and Load More pagination modes. + ### Changed - We changed the structure of the gallery settings to align them with the data grid. ### Added +- We added the `Loaded rows` attribute to reflect the number of rows currently loaded. Available for Virtual Scrolling and Load More pagination modes. + - We added new attributes and a drop zone for custom pagination. - We added support for the auto-select feature in multi-selection and single-selection mode. diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts index 68edac1a97..976112701b 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts @@ -20,7 +20,11 @@ export function getProperties(values: GalleryPreviewProps, defaultProperties: Pr } if (values.itemSelection !== "Multi") { - hidePropertiesIn(defaultProperties, values, ["keepSelection", "selectionCountPosition"]); + hidePropertiesIn(defaultProperties, values, [ + "keepSelection", + "selectionCountPosition", + "clearSelectionButtonLabel" + ]); } const usePersonalization = values.storeFilters || values.storeSort; @@ -33,6 +37,7 @@ export function getProperties(values: GalleryPreviewProps, defaultProperties: Pr if (values.pagination === "buttons") { hidePropertyIn(defaultProperties, values, "showTotalCount"); + hidePropertyIn(defaultProperties, values, "loadedRowsValue"); } else { hidePropertyIn(defaultProperties, values, "showPagingButtons"); @@ -40,13 +45,7 @@ export function getProperties(values: GalleryPreviewProps, defaultProperties: Pr hidePropertyIn(defaultProperties, values, "pagingPosition"); } - hidePropertiesIn(defaultProperties, values, [ - "dynamicPage", - "dynamicPageSize", - "useCustomPagination", - "customPagination", - "totalCountValue" - ]); + hidePropertiesIn(defaultProperties, values, ["useCustomPagination", "customPagination"]); } if (values.pagination !== "loadMore") { diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 8d671a8634..12907738c4 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -181,6 +181,13 @@
+ + Loaded rows + Read-only attribute reflecting the number of rows currently loaded. + + + +
diff --git a/packages/pluggableWidgets/gallery-web/src/model/configs/GalleryPagination.config.ts b/packages/pluggableWidgets/gallery-web/src/model/configs/GalleryPagination.config.ts index 8c3139b681..cdf6d7bd44 100644 --- a/packages/pluggableWidgets/gallery-web/src/model/configs/GalleryPagination.config.ts +++ b/packages/pluggableWidgets/gallery-web/src/model/configs/GalleryPagination.config.ts @@ -3,6 +3,7 @@ import { GalleryGateProps } from "../../typings/GalleryGateProps"; export interface GalleryPaginationConfig { constPageSize: number; + initPageSize: number; customPaginationEnabled: boolean; dynamicPageEnabled: boolean; dynamicPageSizeEnabled: boolean; @@ -21,6 +22,7 @@ export type PaginationKind = `${PaginationEnum}.${ShowPagingButtonsEnum}` | "cus export function galleryPaginationConfig(props: GalleryGateProps): GalleryPaginationConfig { const config: GalleryPaginationConfig = { constPageSize: props.pageSize, + initPageSize: resolveInitPageSize(props), customPaginationEnabled: paginationKind(props) === "custom", dynamicPageEnabled: dynamicPageEnabled(props), dynamicPageSizeEnabled: dynamicPageSizeEnabled(props), @@ -37,6 +39,19 @@ export function galleryPaginationConfig(props: GalleryGateProps): GalleryPaginat return Object.freeze(config); } +/** + * Resolves the initial page size for the first datasource fetch. + * Returns 0 when `dynamicPageSize` is configured so that no rows are fetched + * before the attribute value is available — the real limit is applied once + * `DynamicPaginationFeature` syncs the attribute on setup. + */ +export function resolveInitPageSize(props: GalleryGateProps): number { + if (props.dynamicPageSize !== undefined) { + return 0; + } + return props.pageSize; +} + export function paginationKind(props: GalleryGateProps): PaginationKind { if (props.useCustomPagination) { return "custom"; @@ -50,13 +65,13 @@ function isLimitBased(props: GalleryGateProps): boolean { } export function dynamicPageSizeEnabled(props: GalleryGateProps): boolean { - return props.dynamicPageSize !== undefined && !isLimitBased(props); + return props.dynamicPageSize !== undefined; } export function dynamicPageEnabled(props: GalleryGateProps): boolean { - return props.dynamicPage !== undefined && !isLimitBased(props); + return props.dynamicPage !== undefined; } function requestTotalCount(props: GalleryGateProps): boolean { - return props.pagination === "buttons" || props.showTotalCount; + return props.pagination === "buttons" || props.showTotalCount || props.totalCountValue !== undefined; } diff --git a/packages/pluggableWidgets/gallery-web/src/model/configs/__tests__/GalleryPagination.config.spec.ts b/packages/pluggableWidgets/gallery-web/src/model/configs/__tests__/GalleryPagination.config.spec.ts index 5df79cba32..7d4a4722df 100644 --- a/packages/pluggableWidgets/gallery-web/src/model/configs/__tests__/GalleryPagination.config.spec.ts +++ b/packages/pluggableWidgets/gallery-web/src/model/configs/__tests__/GalleryPagination.config.spec.ts @@ -1,5 +1,6 @@ +import { GalleryContainerProps } from "../../../../typings/GalleryProps"; import { mockContainerProps } from "../../../utils/mock-container-props"; -import { galleryPaginationConfig } from "../GalleryPagination.config"; +import { galleryPaginationConfig, resolveInitPageSize } from "../GalleryPagination.config"; describe("galleryPaginationConfig", () => { it("should create a frozen config object based on props", () => { @@ -10,3 +11,19 @@ describe("galleryPaginationConfig", () => { // expect(config).toMatchSnapshot(); }); }); + +describe("resolveInitPageSize", () => { + function makeProps(overrides: Partial = {}): GalleryContainerProps { + return { ...mockContainerProps(), ...overrides }; + } + + it("returns 0 when dynamicPageSize attribute is set", () => { + const props = makeProps({ dynamicPageSize: {} as GalleryContainerProps["dynamicPageSize"] }); + expect(resolveInitPageSize(props)).toBe(0); + }); + + it("falls back to pageSize when dynamicPageSize is not set", () => { + const props = makeProps({ pageSize: 10 }); + expect(resolveInitPageSize(props)).toBe(10); + }); +}); diff --git a/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts b/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts index b99b961362..432930099c 100644 --- a/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts +++ b/packages/pluggableWidgets/gallery-web/src/model/containers/Gallery.container.ts @@ -226,6 +226,9 @@ const _08_paginationBindings: BindingGroup = { GY.paging.dynamicPage, GY.paging.dynamicPageSize, CORE.data.totalCount, + GY.paging.currentPage, + CORE.data.itemCount, + CORE.mainGate, GY.paging.pageControl ); injected(customPaginationAtom, CORE.mainGate); @@ -245,13 +248,15 @@ const _08_paginationBindings: BindingGroup = { init(container, { props }) { const config = galleryPaginationConfig(props); container.bind(GY.paging.paginationConfig).toConstant(config); - container.bind(CORE.initPageSize).toConstant(config.constPageSize); + container.bind(CORE.initPageSize).toConstant(config.initPageSize); }, postInit(container) { const config = container.get(GY.paging.paginationConfig); const query = container.get(GY.query); query.requestTotalCount(config.requestTotalCount); - query.setBaseLimit(config.constPageSize); + // Use the resolved initPageSize (dynamic value if provided, else constPageSize) + // so the very first datasource fetch uses the correct limit. + query.setBaseLimit(container.get(CORE.initPageSize)); container.get(GY.paging.dynamicPagination); // Enable dynamic pagination feature } }; diff --git a/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts b/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts index e2063040d3..7def252c93 100644 --- a/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts +++ b/packages/pluggableWidgets/gallery-web/src/typings/GalleryGateProps.ts @@ -30,6 +30,7 @@ export type GalleryGateProps = Pick< | "content" | "ariaLabelItem" | "totalCountValue" + | "loadedRowsValue" | "clearSelectionButtonLabel" | "selectedCountTemplateSingular" | "selectedCountTemplatePlural" diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index 4feffd44ad..79fd429316 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -58,6 +58,7 @@ export interface GalleryContainerProps { dynamicPageSize?: EditableValue; dynamicPage?: EditableValue; totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder?: ReactNode; itemClass?: ListExpressionValue; @@ -113,6 +114,7 @@ export interface GalleryPreviewProps { dynamicPageSize: string; dynamicPage: string; totalCountValue: string; + loadedRowsValue: string; showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; itemClass: string; diff --git a/packages/shared/widget-plugin-grid/jest.config.cjs b/packages/shared/widget-plugin-grid/jest.config.cjs index 0212d4ef21..939b66d748 100644 --- a/packages/shared/widget-plugin-grid/jest.config.cjs +++ b/packages/shared/widget-plugin-grid/jest.config.cjs @@ -5,6 +5,10 @@ module.exports = { "@swc/jest", { jsc: { + parser: { + syntax: "typescript", + tsx: true + }, transform: { react: { runtime: "automatic" @@ -15,6 +19,7 @@ module.exports = { ] }, moduleNameMapper: { + "^big\\.js$": "/../../../node_modules/big.js/big.js", "(.+)\\.js": "$1" }, extensionsToTreatAsEsm: [".ts"] diff --git a/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts b/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts index 052d963dde..204085bf18 100644 --- a/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts +++ b/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts @@ -149,6 +149,10 @@ export class DatasourceService implements SetupComponent, QueryService { } setLimit(limit: number): void { + const currentLimit = this.datasource.limit; + if (limit === currentLimit) { + return; + } this.setFetching(true); this.datasource.setLimit(limit); } diff --git a/packages/shared/widget-plugin-grid/src/interfaces/GridPageControl.ts b/packages/shared/widget-plugin-grid/src/interfaces/GridPageControl.ts index 27008c6d17..66d0934225 100644 --- a/packages/shared/widget-plugin-grid/src/interfaces/GridPageControl.ts +++ b/packages/shared/widget-plugin-grid/src/interfaces/GridPageControl.ts @@ -2,4 +2,5 @@ export interface GridPageControl { setPage(page: number): void; setPageSize(pageSize: number): void; setTotalCount(totalCount: number): void; + setLoadedRows?(loadedRows: number): void; } diff --git a/packages/shared/widget-plugin-grid/src/pagination/DynamicPagination.feature.ts b/packages/shared/widget-plugin-grid/src/pagination/DynamicPagination.feature.ts index dd822f9083..6c95685497 100644 --- a/packages/shared/widget-plugin-grid/src/pagination/DynamicPagination.feature.ts +++ b/packages/shared/widget-plugin-grid/src/pagination/DynamicPagination.feature.ts @@ -1,15 +1,33 @@ -import { ComputedAtom, disposeBatch, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; -import { autorun, reaction } from "mobx"; +import { + ComputedAtom, + DerivedPropsGate, + disposeBatch, + SetupComponent, + SetupComponentHost +} from "@mendix/widget-plugin-mobx-kit/main"; +import { Big } from "big.js"; +import { EditableValue } from "mendix"; +import { autorun, reaction, untracked } from "mobx"; import { GridPageControl } from "../interfaces/GridPageControl"; +type FeatureGateProps = { + dynamicPage?: EditableValue; + dynamicPageSize?: EditableValue; + totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; +}; + export class DynamicPaginationFeature implements SetupComponent { id = "DynamicPaginationFeature"; constructor( host: SetupComponentHost, - private config: { dynamicPageSizeEnabled: boolean; dynamicPageEnabled: boolean }, + private config: { dynamicPageSizeEnabled: boolean; dynamicPageEnabled: boolean; isLimitBased: boolean }, private dynamicPage: ComputedAtom, private dynamicPageSize: ComputedAtom, private totalCount: ComputedAtom, + private currentPage: ComputedAtom, + private loadedRows: ComputedAtom, + private gate: DerivedPropsGate, private service: GridPageControl ) { host.add(this); @@ -19,36 +37,89 @@ export class DynamicPaginationFeature implements SetupComponent { const [add, disposeAll] = disposeBatch(); if (this.config.dynamicPageSizeEnabled) { - add( - reaction( - () => this.dynamicPageSize.get(), - pageSize => { - if (pageSize < 0) return; - this.service.setPageSize(pageSize); - }, - { delay: 250 } - ) - ); + add(this.syncPageSizeFromAttribute()); } if (this.config.dynamicPageEnabled) { - add( - reaction( - () => this.dynamicPage.get(), - page => { - if (page < 0) return; - this.service.setPage(page); - }, - { delay: 250 } - ) - ); - add( - autorun(() => { - this.service.setTotalCount(this.totalCount.get()); - }) - ); + add(this.syncPageFromAttribute()); + add(this.syncCurrentPageToAttribute()); } + add(this.syncTotalCountToAttribute()); + add(this.syncLoadedRowsToAttribute()); + return disposeAll; } + + /** + * Syncs dynamicPageSize attribute changes to internal pagination state. + * Debounces rapid changes and applies initial value immediately during setup. + */ + private syncPageSizeFromAttribute(): () => void { + return reaction( + () => this.dynamicPageSize.get(), + pageSize => { + if (pageSize <= 0) return; + this.service.setPageSize(pageSize); + }, + { delay: 250, fireImmediately: true } + ); + } + + /** + * Syncs dynamicPage attribute changes to internal pagination state. + * For limit-based pagination, skips initial sync to avoid conflicting with widget's base limit setup. + */ + private syncPageFromAttribute(): () => void { + return reaction( + () => this.dynamicPage.get(), + page => { + if (page < 0) return; + if (this.config.isLimitBased && page < 1) return; + this.service.setPage(page); + }, + { + delay: 250, + fireImmediately: !this.config.isLimitBased + } + ); + } + + /** + * Syncs internal totalCount state to the totalCountValue attribute. + * Skips sentinel value (-1) when datasource hasn't computed count yet. + */ + private syncTotalCountToAttribute(): () => void { + return autorun(() => { + const count = this.totalCount.get(); + if (count < 0) return; + this.service.setTotalCount(count); + }); + } + + /** + * Syncs internal currentPage state to the dynamicPage attribute. + * Uses untracked() for attribute reference to prevent re-running on every setProps() call. + * Converts 0-based internal page to 1-based attribute value for offset pagination. + */ + private syncCurrentPageToAttribute(): () => void { + return autorun(() => { + const page = this.currentPage.get(); + const dynamicPage = untracked(() => this.gate.props.dynamicPage); + if (!dynamicPage || dynamicPage.readOnly) return; + dynamicPage.setValue(new Big(this.config.isLimitBased ? page : page + 1)); + }); + } + + /** + * Syncs internal loadedRows state to the loadedRowsValue attribute. + * Skips sentinel value (-1) when count isn't yet available. + */ + private syncLoadedRowsToAttribute(): () => void { + return autorun(() => { + const count = this.loadedRows.get(); + if (count < 0) return; + this.service.setLoadedRows?.(count); + }); + } } diff --git a/packages/shared/widget-plugin-grid/src/pagination/PageControl.service.ts b/packages/shared/widget-plugin-grid/src/pagination/PageControl.service.ts index 8cbf987270..440cb76cc6 100644 --- a/packages/shared/widget-plugin-grid/src/pagination/PageControl.service.ts +++ b/packages/shared/widget-plugin-grid/src/pagination/PageControl.service.ts @@ -7,7 +7,10 @@ import { SetPageAction, SetPageSizeAction } from "./pagination.model"; export class PageControlService implements GridPageControl { constructor( private gate: DerivedPropsGate<{ + dynamicPage?: EditableValue; + dynamicPageSize?: EditableValue; totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; }>, private setPageSizeAction: SetPageSizeAction, private setPageAction: SetPageAction @@ -26,4 +29,10 @@ export class PageControlService implements GridPageControl { if (!value || value.readOnly) return; value.setValue(new Big(count)); } + + setLoadedRows(count: number): void { + const value = this.gate.props.loadedRowsValue; + if (!value || value.readOnly) return; + value.setValue(new Big(count)); + } } diff --git a/packages/shared/widget-plugin-grid/src/pagination/__tests__/DynamicPagination.feature.spec.ts b/packages/shared/widget-plugin-grid/src/pagination/__tests__/DynamicPagination.feature.spec.ts new file mode 100644 index 0000000000..315584ef0d --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/pagination/__tests__/DynamicPagination.feature.spec.ts @@ -0,0 +1,636 @@ +import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { editable } from "@mendix/widget-plugin-test-utils"; +import { Big } from "big.js"; +import { action, IObservableValue, makeObservable, observable, runInAction } from "mobx"; +import { EditableValue } from "mendix"; +import { GridPageControl } from "../../interfaces/GridPageControl"; +import { DynamicPaginationFeature } from "../DynamicPagination.feature"; + +type GateProps = { + dynamicPage?: EditableValue; + dynamicPageSize?: EditableValue; + totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; +}; + +type AtomsFixture = { + dynamicPage: IObservableValue; + dynamicPageSize: IObservableValue; + totalCount: IObservableValue; + currentPage: IObservableValue; + pageSize: IObservableValue; + loadedRows: IObservableValue; +}; + +type AttributesFixture = { + page: EditableValue; + pageSize: EditableValue; + totalCount: EditableValue; + loadedRows: EditableValue; +}; + +type FeatureConfig = { + dynamicPageEnabled: boolean; + dynamicPageSizeEnabled: boolean; + isLimitBased: boolean; +}; + +function lastArgToNumber(mockFn: jest.MockedFunction): number { + const calls = mockFn.mock.calls; + return calls[calls.length - 1][0].toNumber(); +} + +function makeHost(): SetupComponentHost { + return { + add: (_component: SetupComponent) => {}, + remove: (_component: SetupComponent) => {}, + setup: () => () => {} + } as SetupComponentHost; +} + +function createMockService(): jest.Mocked { + return { + setPage: jest.fn(), + setPageSize: jest.fn(), + setTotalCount: jest.fn(), + setLoadedRows: jest.fn() + }; +} + +function createAtoms(overrides?: Partial>): AtomsFixture { + return { + dynamicPage: observable.box(overrides?.dynamicPage ?? -1), + dynamicPageSize: observable.box(overrides?.dynamicPageSize ?? -1), + totalCount: observable.box(overrides?.totalCount ?? 0), + currentPage: observable.box(overrides?.currentPage ?? 0), + pageSize: observable.box(overrides?.pageSize ?? 10), + loadedRows: observable.box(overrides?.loadedRows ?? 0) + }; +} + +function createAttributes(overrides?: Partial): AttributesFixture { + return { + page: overrides?.page ?? editable(), + pageSize: overrides?.pageSize ?? editable(), + totalCount: overrides?.totalCount ?? editable(), + loadedRows: overrides?.loadedRows ?? editable() + }; +} + +function createFeature( + config: FeatureConfig, + atoms: AtomsFixture, + attributes: AttributesFixture, + service: GridPageControl +): { feature: DynamicPaginationFeature; dispose: () => void } { + const gate = new ObservableGate({ + dynamicPage: attributes.page, + dynamicPageSize: attributes.pageSize, + totalCountValue: attributes.totalCount, + loadedRowsValue: attributes.loadedRows + }); + + const feature = new DynamicPaginationFeature( + makeHost(), + config, + atoms.dynamicPage, + atoms.dynamicPageSize, + atoms.totalCount, + atoms.currentPage, + atoms.loadedRows, + gate, + service + ); + + return { feature, dispose: feature.setup() }; +} + +class ObservableGate implements DerivedPropsGate { + props: T; + constructor(initialProps: T) { + this.props = initialProps; + makeObservable(this, { props: observable.ref, setProps: action }); + } + setProps(props: T): void { + this.props = props; + } +} + +describe("DynamicPaginationFeature", () => { + let service: jest.Mocked; + let atoms: AtomsFixture; + let attributes: AttributesFixture; + let dispose: () => void; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + if (dispose) { + dispose(); + dispose = undefined!; + } + jest.useRealTimers(); + }); + + describe("Basic synchronization", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("syncs inbound pageSize to service", () => { + runInAction(() => atoms.dynamicPageSize.set(5)); + jest.advanceTimersByTime(250); + expect(service.setPageSize).toHaveBeenCalledWith(5); + }); + + it("syncs inbound page to service", () => { + runInAction(() => atoms.dynamicPage.set(2)); + jest.advanceTimersByTime(250); + expect(service.setPage).toHaveBeenCalledWith(2); + }); + + it("syncs totalCount to service immediately", () => { + runInAction(() => atoms.totalCount.set(123)); + expect(service.setTotalCount).toHaveBeenCalledWith(123); + }); + + it("syncs currentPage outbound to dynamicPage attribute (1-based)", () => { + runInAction(() => atoms.currentPage.set(3)); + expect(lastArgToNumber(attributes.page.setValue as jest.MockedFunction)).toBe(4); + }); + + it("syncs loadedRows to service immediately", () => { + runInAction(() => atoms.loadedRows.set(77)); + expect(service.setLoadedRows).toHaveBeenCalledWith(77); + }); + }); + + describe("Validation guards", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("ignores zero inbound pageSize", () => { + runInAction(() => atoms.dynamicPageSize.set(0)); + jest.advanceTimersByTime(250); + expect(service.setPageSize).not.toHaveBeenCalledWith(0); + }); + + it("ignores negative inbound pageSize", () => { + runInAction(() => atoms.dynamicPageSize.set(-1)); + jest.advanceTimersByTime(250); + expect(service.setPageSize).not.toHaveBeenCalledWith(-1); + }); + + it("ignores sentinel -1 for totalCount", () => { + jest.clearAllMocks(); + runInAction(() => atoms.totalCount.set(-1)); + expect(service.setTotalCount).not.toHaveBeenCalled(); + }); + + it("ignores negative loadedRows", () => { + runInAction(() => atoms.loadedRows.set(-1)); + expect(service.setLoadedRows).not.toHaveBeenCalledWith(-1); + }); + }); + + describe("Configuration", () => { + it("skips page/pageSize sync when disabled, but still syncs totalCount/loadedRows", () => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: false, dynamicPageSizeEnabled: false, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + + jest.clearAllMocks(); + + runInAction(() => { + atoms.dynamicPage.set(2); + atoms.dynamicPageSize.set(25); + atoms.totalCount.set(300); + atoms.loadedRows.set(40); + }); + + jest.advanceTimersByTime(250); + + expect(service.setPage).not.toHaveBeenCalled(); + expect(service.setPageSize).not.toHaveBeenCalled(); + expect(attributes.page.setValue).not.toHaveBeenCalled(); + expect(attributes.pageSize.setValue).not.toHaveBeenCalled(); + expect(service.setTotalCount).toHaveBeenCalledWith(300); + expect(service.setLoadedRows).toHaveBeenCalledWith(40); + }); + }); + + describe("Edge cases", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("does not overwrite attribute when gate.props changes but currentPage unchanged", () => { + (attributes.page.setValue as jest.MockedFunction).mockClear(); + + // Simulate setProps creating a new props object (same attr instances, new wrapper) + const gate = new ObservableGate({ + dynamicPage: attributes.page, + dynamicPageSize: attributes.pageSize, + totalCountValue: attributes.totalCount, + loadedRowsValue: attributes.loadedRows + }); + + runInAction(() => + gate.setProps({ + dynamicPage: attributes.page, + dynamicPageSize: attributes.pageSize, + totalCountValue: attributes.totalCount, + loadedRowsValue: attributes.loadedRows + }) + ); + + // currentPage atom is still 0 — the autorun must NOT re-fire from a gate.props + // reference change alone (untracked() guards against this) + expect(attributes.page.setValue).not.toHaveBeenCalled(); + }); + }); + + describe("Offset-based pagination", () => { + describe("Initialization", () => { + let localDispose: (() => void) | undefined; + + afterEach(() => { + if (localDispose) { + localDispose(); + localDispose = undefined; + } + }); + + it("applies externally-provided pageSize immediately on setup", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPageSize: 5 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPageSize).toHaveBeenCalledWith(5); + expect(svc.setPageSize).toHaveBeenCalledTimes(1); + }); + + it("applies externally-provided page immediately on setup", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPage: 4 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPage).toHaveBeenCalledWith(4); + expect(svc.setPage).toHaveBeenCalledTimes(1); + }); + + it("applies both page and pageSize when both provided", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPage: 4, dynamicPageSize: 5 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPageSize).toHaveBeenCalledWith(5); + expect(svc.setPage).toHaveBeenCalledWith(4); + }); + + it("does not call setPageSize for sentinel -1", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPageSize: -1 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPageSize).not.toHaveBeenCalled(); + }); + + it("does not call setPage for sentinel -1", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPage: -1 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPage).not.toHaveBeenCalled(); + }); + + it("does not call setPageSize for zero", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + createAtoms({ dynamicPageSize: 0 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPageSize).not.toHaveBeenCalled(); + }); + }); + + describe("Runtime behavior", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("debounces rapid page changes (250ms)", () => { + runInAction(() => atoms.dynamicPage.set(2)); + jest.advanceTimersByTime(100); + expect(service.setPage).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(150); + expect(service.setPage).toHaveBeenCalledWith(2); + }); + + it("converts 0-based currentPage to 1-based attribute value", () => { + runInAction(() => atoms.currentPage.set(0)); + expect(lastArgToNumber(attributes.page.setValue as jest.MockedFunction)).toBe(1); + + runInAction(() => atoms.currentPage.set(5)); + expect(lastArgToNumber(attributes.page.setValue as jest.MockedFunction)).toBe(6); + }); + }); + }); + + describe("Limit-based pagination (virtual scroll)", () => { + describe("Initialization", () => { + let localDispose: (() => void) | undefined; + + afterEach(() => { + if (localDispose) { + localDispose(); + localDispose = undefined; + } + }); + + it("does NOT apply stale dynamicPage on first load (fireImmediately: false)", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: true }, + createAtoms({ dynamicPage: 5, pageSize: 5 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + // Regression: a persisted/default dynamicPage=5 with pageSize=5 would cause + // setLimit(5*5)=25, loading 25 items instead of the expected 5. + expect(svc.setPage).not.toHaveBeenCalled(); + }); + + it("does NOT apply dynamicPage=0 on first load", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: true }, + createAtoms({ dynamicPage: 0, pageSize: 5 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPage).not.toHaveBeenCalled(); + }); + + it("still applies dynamicPageSize immediately (fireImmediately: true)", () => { + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: true }, + createAtoms({ dynamicPageSize: 15, pageSize: 10 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + // dynamicPageSize fireImmediately remains true for all modes + expect(svc.setPageSize).toHaveBeenCalledWith(15); + expect(svc.setPageSize).toHaveBeenCalledTimes(1); + }); + + it("picks up subsequent dynamicPage changes after ignored initial value", () => { + const svc = createMockService(); + const testAtoms = createAtoms({ dynamicPage: 5, pageSize: 5 }); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: true }, + testAtoms, + createAttributes(), + svc + ); + localDispose = result.dispose; + + // Stale initial value NOT applied (fireImmediately: false) + expect(svc.setPage).not.toHaveBeenCalled(); + + // Simulate user scroll bumping page + runInAction(() => testAtoms.dynamicPage.set(2)); + jest.advanceTimersByTime(250); + + expect(svc.setPage).toHaveBeenCalledWith(2); + expect(svc.setPage).toHaveBeenCalledTimes(1); + }); + + it("offset-based pagination still applies dynamicPage immediately on setup", () => { + // Ensure we didn't break offset-based (buttons) behavior + const svc = createMockService(); + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: false, isLimitBased: false }, + createAtoms({ dynamicPage: 4 }), + createAttributes(), + svc + ); + localDispose = result.dispose; + + expect(svc.setPage).toHaveBeenCalledWith(4); + expect(svc.setPage).toHaveBeenCalledTimes(1); + }); + }); + + describe("Runtime behavior", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms({ currentPage: 1 }); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: false, isLimitBased: true }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("writes currentPage as-is to attribute (no +1 for limit-based)", () => { + jest.clearAllMocks(); + runInAction(() => atoms.currentPage.set(2)); + expect(lastArgToNumber(attributes.page.setValue as jest.MockedFunction)).toBe(2); + }); + + it("does not trigger infinite loop (outbound doesn't trigger inbound)", () => { + // Regression: if outbound wrote page+1, the inbound reaction would see a new + // attribute value and call setPage again, creating an infinite load loop. + jest.clearAllMocks(); + + // Simulate virtual scroll bumping page 1 → 2 + runInAction(() => atoms.dynamicPage.set(2)); + jest.advanceTimersByTime(250); + + // Only one setPage call expected — no feedback loop + expect(service.setPage).toHaveBeenCalledTimes(1); + expect(service.setPage).toHaveBeenCalledWith(2); + }); + + it("rejects dynamicPage=0 (invalid for virtual scroll)", () => { + jest.clearAllMocks(); + + runInAction(() => atoms.dynamicPage.set(0)); + jest.advanceTimersByTime(250); + + // setLimit(0) is invalid for virtual scroll + expect(service.setPage).not.toHaveBeenCalled(); + }); + + it("accepts dynamicPage>0 after initialization", () => { + jest.clearAllMocks(); + + runInAction(() => atoms.dynamicPage.set(3)); + jest.advanceTimersByTime(250); + + expect(service.setPage).toHaveBeenCalledWith(3); + expect(service.setPage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("Timing and debounce", () => { + beforeEach(() => { + service = createMockService(); + atoms = createAtoms(); + attributes = createAttributes(); + + const result = createFeature( + { dynamicPageEnabled: true, dynamicPageSizeEnabled: true, isLimitBased: false }, + atoms, + attributes, + service + ); + dispose = result.dispose; + }); + + it("debounces page changes to 250ms", () => { + runInAction(() => atoms.dynamicPage.set(3)); + + jest.advanceTimersByTime(100); + expect(service.setPage).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + expect(service.setPage).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(50); + expect(service.setPage).toHaveBeenCalledWith(3); + }); + + it("debounces pageSize changes to 250ms", () => { + runInAction(() => atoms.dynamicPageSize.set(20)); + + jest.advanceTimersByTime(249); + expect(service.setPageSize).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(service.setPageSize).toHaveBeenCalledWith(20); + }); + + it("applies latest value after debounce with rapid consecutive changes", () => { + // MobX reaction delay doesn't restart the timer, it uses the latest value when timer fires + runInAction(() => atoms.dynamicPage.set(2)); + jest.advanceTimersByTime(200); + + // Second change before first debounce completes - updates the value to be used + runInAction(() => atoms.dynamicPage.set(3)); + + // Advance to complete the 250ms debounce from the first change + jest.advanceTimersByTime(50); + + // Should be called once with the latest value (3, not 2) + expect(service.setPage).toHaveBeenCalledTimes(1); + expect(service.setPage).toHaveBeenCalledWith(3); + }); + + it("does not debounce totalCount (immediate sync)", () => { + runInAction(() => atoms.totalCount.set(100)); + // No timer advancement needed + expect(service.setTotalCount).toHaveBeenCalledWith(100); + }); + + it("does not debounce loadedRows (immediate sync)", () => { + runInAction(() => atoms.loadedRows.set(50)); + // No timer advancement needed + expect(service.setLoadedRows).toHaveBeenCalledWith(50); + }); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/pagination/__tests__/PageControl.service.spec.ts b/packages/shared/widget-plugin-grid/src/pagination/__tests__/PageControl.service.spec.ts new file mode 100644 index 0000000000..adbb1aec59 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/pagination/__tests__/PageControl.service.spec.ts @@ -0,0 +1,95 @@ +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { Big } from "big.js"; +import { EditableValue } from "mendix"; +import { SetPageAction, SetPageSizeAction } from "../pagination.model"; +import { PageControlService } from "../PageControl.service"; + +type GateProps = { + dynamicPage?: EditableValue; + dynamicPageSize?: EditableValue; + totalCountValue?: EditableValue; + loadedRowsValue?: EditableValue; +}; + +function makeGate(props: Partial = {}): DerivedPropsGate { + return { props } as DerivedPropsGate; +} + +function argToNumber(mockFn: jest.MockedFunction, callIndex = 0): number { + return mockFn.mock.calls[callIndex][0].toNumber(); +} + +describe("PageControlService", () => { + let setPage: jest.MockedFunction; + let setPageSize: jest.MockedFunction; + + beforeEach(() => { + setPage = jest.fn(); + setPageSize = jest.fn(); + }); + + describe("setPage", () => { + it("delegates to setPageAction", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + service.setPage(3); + expect(setPage).toHaveBeenCalledWith(3); + }); + + it("does not throw when dynamicPage attribute is not mapped", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + expect(() => service.setPage(0)).not.toThrow(); + }); + }); + + describe("setPageSize", () => { + it("delegates to setPageSizeAction", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + service.setPageSize(25); + expect(setPageSize).toHaveBeenCalledWith(25); + }); + + it("does not throw when dynamicPageSize attribute is not mapped", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + expect(() => service.setPageSize(10)).not.toThrow(); + }); + }); + + describe("setTotalCount", () => { + it("writes totalCountValue when attribute is mapped", () => { + const attr = new EditableValueBuilder().build(); + const service = new PageControlService(makeGate({ totalCountValue: attr }), setPageSize, setPage); + + service.setTotalCount(123); + expect(argToNumber(attr.setValue as jest.MockedFunction)).toBe(123); + }); + + it("does not throw when totalCountValue attribute is not mapped", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + expect(() => service.setTotalCount(5)).not.toThrow(); + }); + }); + + describe("setLoadedRows", () => { + it("writes loadedRowsValue when attribute is mapped", () => { + const attr = new EditableValueBuilder().build(); + const service = new PageControlService(makeGate({ loadedRowsValue: attr }), setPageSize, setPage); + + service.setLoadedRows(77); + expect(argToNumber(attr.setValue as jest.MockedFunction)).toBe(77); + }); + + it("does not throw when loadedRowsValue attribute is not mapped", () => { + const service = new PageControlService(makeGate(), setPageSize, setPage); + expect(() => service.setLoadedRows(10)).not.toThrow(); + }); + + it("skips write when loadedRowsValue attribute is readOnly", () => { + const attr = new EditableValueBuilder().isReadOnly().build(); + const service = new PageControlService(makeGate({ loadedRowsValue: attr }), setPageSize, setPage); + + service.setLoadedRows(10); + expect(attr.setValue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/pagination/__tests__/pagination.model.spec.ts b/packages/shared/widget-plugin-grid/src/pagination/__tests__/pagination.model.spec.ts new file mode 100644 index 0000000000..95164c7699 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/pagination/__tests__/pagination.model.spec.ts @@ -0,0 +1,150 @@ +import { computed, observable, runInAction } from "mobx"; +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { QueryService } from "../../interfaces/QueryService"; +import { createSetPageAction, createSetPageSizeAction } from "../pagination.model"; + +function makeQueryMock(): jest.Mocked> { + return { + setBaseLimit: jest.fn(), + setLimit: jest.fn(), + setOffset: jest.fn() + }; +} + +function makePageSizeStore(initial: number): { + pageSize: number; + setPageSize: jest.MockedFunction<(n: number) => void>; +} { + return { + pageSize: initial, + setPageSize: jest.fn() + }; +} + +function boxAtom(initial: number): ComputedAtom { + const box = observable.box(initial); + return computed(() => box.get()) as ComputedAtom; +} + +describe("createSetPageSizeAction", () => { + describe("offset-based pagination", () => { + it("calls setBaseLimit and repositions on the same page", () => { + const query = makeQueryMock(); + const store = makePageSizeStore(10); + const currentPage = boxAtom(2); + const setPage = jest.fn(); + const action = createSetPageSizeAction( + query as unknown as QueryService, + { isLimitBased: false }, + currentPage, + store, + setPage + ); + + action(5); + + expect(query.setBaseLimit).toHaveBeenCalledWith(5); + expect(store.setPageSize).toHaveBeenCalledWith(5); + expect(setPage).toHaveBeenCalledWith(2); + }); + }); + + describe("limit-based pagination (virtual scroll / loadMore)", () => { + it("calls setBaseLimit so sort/filter resets use the new page size", () => { + // Regression: previously setBaseLimit was only called for !isLimitBased, + // leaving baseLimit at constPageSize after a dynamic page size change. + // A subsequent sort/filter reset would then show constPageSize items + // instead of the dynamic page size. + const query = makeQueryMock(); + const store = makePageSizeStore(10); + const currentPage = boxAtom(1); + const setPage = jest.fn(); + const action = createSetPageSizeAction( + query as unknown as QueryService, + { isLimitBased: true }, + currentPage, + store, + setPage + ); + + action(5); + + expect(query.setBaseLimit).toHaveBeenCalledWith(5); + expect(store.setPageSize).toHaveBeenCalledWith(5); + expect(setPage).toHaveBeenCalledWith(1); + }); + + it("repositions based on currentPage at the time of the call", () => { + const query = makeQueryMock(); + const store = makePageSizeStore(10); + const pageBox = observable.box(3); + const currentPage = computed(() => pageBox.get()) as ComputedAtom; + const setPage = jest.fn(); + const action = createSetPageSizeAction( + query as unknown as QueryService, + { isLimitBased: true }, + currentPage, + store, + setPage + ); + + runInAction(() => action(5)); + + expect(setPage).toHaveBeenCalledWith(3); + }); + }); +}); + +describe("createSetPageAction", () => { + describe("offset-based", () => { + it("sets offset to page * pageSize", () => { + const query = makeQueryMock(); + const currentPage = boxAtom(0); + const pageSize = boxAtom(10); + const action = createSetPageAction( + query as unknown as QueryService, + { isLimitBased: false }, + currentPage, + pageSize + ); + + action(3); + + expect(query.setOffset).toHaveBeenCalledWith(30); + }); + + it("accepts a function updater for relative page changes", () => { + const query = makeQueryMock(); + const currentPage = boxAtom(2); + const pageSize = boxAtom(10); + const action = createSetPageAction( + query as unknown as QueryService, + { isLimitBased: false }, + currentPage, + pageSize + ); + + action(p => p + 1); + + expect(query.setOffset).toHaveBeenCalledWith(30); // page 3 * size 10 + }); + }); + + describe("limit-based (virtual scroll)", () => { + it("sets limit to page * pageSize", () => { + const query = makeQueryMock(); + const currentPage = boxAtom(1); + const pageSize = boxAtom(5); + const action = createSetPageAction( + query as unknown as QueryService, + { isLimitBased: true }, + currentPage, + pageSize + ); + + action(3); + + expect(query.setLimit).toHaveBeenCalledWith(15); + }); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/pagination/pagination.model.ts b/packages/shared/widget-plugin-grid/src/pagination/pagination.model.ts index babdcb79a3..a2199f8550 100644 --- a/packages/shared/widget-plugin-grid/src/pagination/pagination.model.ts +++ b/packages/shared/widget-plugin-grid/src/pagination/pagination.model.ts @@ -66,6 +66,11 @@ export function pageSizeAtom(store: { pageSize: number }): ComputedAtom return computed(() => store.pageSize); } +/** Atom that reflects the number of rows currently loaded in the datasource. */ +export function loadedRowsAtom(itemCount: ComputedAtom): ComputedAtom { + return computed(() => itemCount.get()) as ComputedAtom; +} + export type SetPageAction = (value: ((prevPage: number) => number) | number) => void; /** Main action to change page. */ @@ -90,7 +95,7 @@ export type SetPageSizeAction = (newSize: number) => void; /** Main action to change page size. */ export function createSetPageSizeAction( query: QueryService, - config: { isLimitBased: boolean }, + _config: { isLimitBased: boolean }, currentPage: ComputedAtom, pageSizeStore: { setPageSize: (n: number) => void }, setPageAction: SetPageAction @@ -98,10 +103,11 @@ export function createSetPageSizeAction( return action(function setPageSizeAction(newSize: number): void { const currentPageIndex = currentPage.get(); - // Update limit in case of offset-based pagination - if (!config.isLimitBased) { - query.setBaseLimit(newSize); - } + // Always sync baseLimit with the new page size so that sort/filter resets + // (which call resetLimit → datasource.setLimit(baseLimit)) use the correct value. + // Previously this was guarded to offset-based only, leaving virtual scroll's + // baseLimit stuck at constPageSize after a dynamic page size change. + query.setBaseLimit(newSize); pageSizeStore.setPageSize(newSize); setPageAction(currentPageIndex); });