Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- Pagination attributes (`Page`, `Page Size`, `Total Count`) now sync correctly in all pagination modes and are exposed even when not using custom pagination.
- Added read-only `Loaded Rows` attribute for virtual scrolling and ensured the total count is requested whenever a `totalCountValue` attribute is mapped.
- Updated editor configuration to show/hide attributes appropriately across modes and cleaned up pagination config logic.

## [3.8.1] - 2026-02-19

### Fixed
Expand Down
43 changes: 43 additions & 0 deletions packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,49 @@ test.describe("manual column width", () => {
});
});

// pagination attributes sync tests
// pages referenced here must be created in the playground project with appropriate
// datagrids and Mendix attributes bound to textboxes named accordingly.
test.describe("pagination attribute synchronization", () => {
test("buttons pagination updates page/pageSize/totalCount attributes", async ({ page }) => {
await page.goto("/p/pagination-attributes");
await page.waitForLoadState("networkidle");
await page.locator(".mx-name-dataGrid1").waitFor({ state: "visible" });
const pageInput = page.locator(".mx-name-PageTextBox input");
const sizeInput = page.locator(".mx-name-PageSizeTextBox input");
const totalInput = page.locator(".mx-name-TotalCountTextBox input");

// initial values should be populated
await expect(pageInput).toHaveValue("1");
await expect(sizeInput).toHaveValue("10");
await expect(totalInput).not.toHaveValue("");

// navigate to next page via pagination control
await page.locator(".mx-name-dataGrid1 .pagination-next").click();
await expect(pageInput).toHaveValue("2");
});

test("virtual scroll pagination updates totalCount and loadedRows attributes", async ({ page }) => {
await page.goto("/p/virtual-scroll-attributes");
await page.waitForLoadState("networkidle");
await expect(page.locator(".mx-name-dataGrid21")).toBeVisible();
const totalInput = page.locator(".mx-name-TotalCountTextBox input");
const loadedInput = page.locator(".mx-name-LoadedRowsTextBox input");

// start empty
await expect(totalInput).toHaveValue("");
await expect(loadedInput).toHaveValue("0");

// scroll down to trigger loading more rows
await page.evaluate(() => {
const el = document.querySelector(".mx-name-dataGrid21");
if (el) el.scrollTop = 1000;
});
await expect(loadedInput).not.toHaveValue("0");
await expect(totalInput).not.toHaveValue("");
});
});

test.describe("visual testing:", () => {
test("compares with a screenshot baseline and checks if all datagrid and filter elements are rendered as expected", async ({
page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,17 @@ export function getProperties(values: DatagridPreviewProps, defaultProperties: P
hidePropertyIn(defaultProperties, values, "pagingPosition");
}

hidePropertiesIn(defaultProperties, values, [
"dynamicPage",
"dynamicPageSize",
"useCustomPagination",
"customPagination"
]);
hidePropertiesIn(defaultProperties, values, ["useCustomPagination", "customPagination"]);
}

if (values.pagination !== "loadMore") {
hidePropertyIn(defaultProperties, values, "loadMoreButtonCaption");
}

if (values.pagination !== "virtualScrolling") {
hidePropertyIn(defaultProperties, values, "loadedRowsValue");
}

if (values.showEmptyPlaceholder === "none") {
hidePropertyIn(defaultProperties, values, "emptyPlaceholder");
}
Expand Down
7 changes: 7 additions & 0 deletions packages/pluggableWidgets/datagrid-web/src/Datagrid.xml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,13 @@
<attributeType name="Integer" />
</attributeTypes>
</property>
<property key="loadedRowsValue" type="attribute" required="false">
<caption>Loaded rows</caption>
<description>Number of rows currently loaded (virtual scrolling read-only)</description>
<attributeTypes>
<attributeType name="Integer" />
</attributeTypes>
</property>
</propertyGroup>
<propertyGroup caption="Appearance">
<property key="showEmptyPlaceholder" type="enumeration" defaultValue="none">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,27 @@ describe("consistency check", () => {
expect(check(props as unknown as DatagridPreviewProps)).toEqual([]);
});
});

describe("editor config exposes pagination attributes correctly", () => {
const base: any = {
columns: [],
groupList: [],
groupAttrs: []
};

test("buttons mode hides loadedRowsValue and shows dynamicPage/size", () => {
const props = { ...base, pagination: "buttons" };
expect(check(props as DatagridPreviewProps)).toEqual([]);
});

test("virtualScrolling mode shows loadedRowsValue and dynamicPage/size", () => {
const props = { ...base, pagination: "virtualScrolling" };
expect(check(props as DatagridPreviewProps)).toEqual([]);
});

test("loadMore mode hides loadedRowsValue but still shows dynamicPage/size", () => {
const props = { ...base, pagination: "loadMore" };
expect(check(props as DatagridPreviewProps)).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { ComputedAtom, disposeBatch, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
import {
ComputedAtom,
DerivedPropsGate,
disposeBatch,
SetupComponent,
SetupComponentHost
} from "@mendix/widget-plugin-mobx-kit/main";
import { autorun, reaction } from "mobx";
import { Big } from "big.js";
import { MainGateProps } from "../../../typings/MainGateProps";
import { GridPageControl } from "./GridPageControl";
import { PaginationConfig } from "./pagination.config";

export class DynamicPaginationFeature implements SetupComponent {
id = "DynamicPaginationFeature";
constructor(
host: SetupComponentHost,
private config: { dynamicPageSizeEnabled: boolean; dynamicPageEnabled: boolean },
private config: PaginationConfig,
private dynamicPage: ComputedAtom<number>,
private dynamicPageSize: ComputedAtom<number>,
private totalCount: ComputedAtom<number>,
private currentPage: ComputedAtom<number>,
private pageSize: ComputedAtom<number>,
private limit: ComputedAtom<number>,
private gate: DerivedPropsGate<MainGateProps>,
private service: GridPageControl
) {
host.add(this);
Expand All @@ -18,6 +31,7 @@ export class DynamicPaginationFeature implements SetupComponent {
setup(): () => void {
const [add, disposeAll] = disposeBatch();

// inbound reactions (prop -> service)
if (this.config.dynamicPageSizeEnabled) {
add(
reaction(
Expand Down Expand Up @@ -49,6 +63,41 @@ export class DynamicPaginationFeature implements SetupComponent {
);
}

// outbound autoruns (service/query state -> props)
add(
autorun(() => {
const attr = this.gate.props.dynamicPage;
if (!attr || attr.readOnly) return;
const val = this.currentPage.get();
const pageValue = this.config.isLimitBased ? val : val + 1;
attr.setValue(new Big(pageValue));
})
);

add(
autorun(() => {
const attr = this.gate.props.dynamicPageSize;
if (!attr || attr.readOnly) return;
attr.setValue(new Big(this.pageSize.get()));
})
);

add(
autorun(() => {
const attr = this.gate.props.totalCountValue;
if (!attr || attr.readOnly) return;
attr.setValue(new Big(this.totalCount.get()));
})
);

add(
autorun(() => {
const attr = this.gate.props.loadedRowsValue;
if (!attr || attr.readOnly) return;
attr.setValue(new Big(this.limit.get()));
})
);

return disposeAll;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,34 @@ import { GridPageControl } from "./GridPageControl";
export class PageControlService implements GridPageControl {
constructor(
private gate: DerivedPropsGate<{
dynamicPage?: EditableValue<Big>;
dynamicPageSize?: EditableValue<Big>;
totalCountValue?: EditableValue<Big>;
loadedRowsValue?: EditableValue<Big>;
}>,
private setPageSizeAction: SetPageSizeAction,
private setPageAction: SetPageAction
) {}

setPageSize(pageSize: number): void {
this.setPageSizeAction(pageSize);

// mirror to editable attribute if mapped
const attr = this.gate.props.dynamicPageSize;
if (attr && !attr.readOnly) {
attr.setValue(new Big(pageSize));
}
}

setPage(page: number): void {
this.setPageAction(page);

// mirror to editable attribute if mapped (offset-based pages are 1-based)
const attr = this.gate.props.dynamicPage;
if (attr && !attr.readOnly) {
// the grid itself uses 0-based page internally for buttons
attr.setValue(new Big(page + 1));
}
}

setTotalCount(count: number): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { computed, observable, runInAction } from "mobx";
import { DynamicPaginationFeature } from "../DynamicPagination.feature";
import { PaginationConfig } from "../pagination.config";
import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { GridPageControl } from "../GridPageControl";
import { Big } from "big.js";

// helper to create a simple computed atom along with a setter
function boxAtom(initial: number): { atom: any; set: (v: number) => void } {
const box = observable.box(initial);
return {
atom: computed(() => box.get()),
set: (v: number) => box.set(v)
};
}

describe("DynamicPaginationFeature sync", () => {
let config: PaginationConfig;
let service: jest.Mocked<GridPageControl>;
let gate: DerivedPropsGate<any>;
let atoms: {
dynamicPage: ReturnType<typeof boxAtom>;
dynamicPageSize: ReturnType<typeof boxAtom>;
totalCount: ReturnType<typeof boxAtom>;
currentPage: ReturnType<typeof boxAtom>;
pageSize: ReturnType<typeof boxAtom>;
limit: ReturnType<typeof boxAtom>;
};
// editable values used in outbound test
let dp: any;
let dps: any;
let tc: any;
let lr: any;
let dispose: () => void;

beforeEach(() => {
jest.useFakeTimers();
config = {
pagination: "buttons",
showPagingButtons: "always",
showNumberOfRows: false,
constPageSize: 10,
isLimitBased: false,
dynamicPageSizeEnabled: true,
dynamicPageEnabled: true,
customPaginationEnabled: false,
pagingPosition: "bottom",
requestTotalCount: true,
paginationKind: "buttons.always"
};
service = {
setPage: jest.fn(),
setPageSize: jest.fn(),
setTotalCount: jest.fn()
} as any;
gate = { props: {} } as any;

// create editable values for outbound sync and store in variables
dp = new EditableValueBuilder<Big>().build();
dps = new EditableValueBuilder<Big>().build();
tc = new EditableValueBuilder<Big>().build();
lr = new EditableValueBuilder<Big>().build();
gate.props.dynamicPage = dp;
gate.props.dynamicPageSize = dps;
gate.props.totalCountValue = tc;
gate.props.loadedRowsValue = lr;

atoms = {
dynamicPage: boxAtom(-1),
dynamicPageSize: boxAtom(-1),
totalCount: boxAtom(0),
currentPage: boxAtom(0),
pageSize: boxAtom(10),
limit: boxAtom(0)
};
const feature = new DynamicPaginationFeature(
{ add: () => {} } as any,
config,
atoms.dynamicPage.atom,
atoms.dynamicPageSize.atom,
atoms.totalCount.atom,
atoms.currentPage.atom,
atoms.pageSize.atom,
atoms.limit.atom,
gate,
service
);
dispose = feature.setup();
});
afterEach(() => {
dispose();
jest.useRealTimers();
});

test("inbound reactions call service", () => {
// use fake timers to advance past debounce delays
jest.advanceTimersByTime(250);

// change observable by updating box (use action to satisfy strict-mode)
runInAction(() => atoms.dynamicPageSize.set(5));
jest.advanceTimersByTime(250);
expect(service.setPageSize).toHaveBeenCalledWith(5);

runInAction(() => atoms.dynamicPage.set(2));
jest.advanceTimersByTime(250);
expect(service.setPage).toHaveBeenCalledWith(2);

runInAction(() => atoms.totalCount.set(123));
// total count uses autorun, no delay
expect(service.setTotalCount).toHaveBeenCalledWith(123);
});

test("outbound synchronises gate props", () => {
// props already assigned in beforeEach
runInAction(() => atoms.currentPage.set(3));
expect(dp.setValue).toHaveBeenCalledWith(new Big(4)); // offset -> 1-based

runInAction(() => atoms.pageSize.set(25));
expect(dps.setValue).toHaveBeenCalledWith(new Big(25));

runInAction(() => atoms.totalCount.set(200));
expect(tc.setValue).toHaveBeenCalledWith(new Big(200));

runInAction(() => atoms.limit.set(77));
expect(lr.setValue).toHaveBeenCalledWith(new Big(77));
});
});
Loading
Loading