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);
});