From 6a41dfc42eee1e455bd7b541071e846ba3b421c3 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 8 Apr 2026 15:22:43 +0200 Subject: [PATCH 1/3] fix(datagrid-web): virtual scroll loses scrollbar after column hide --- .../pluggableWidgets/datagrid-web/AGENTS.md | 196 ++++++++++++++++++ .../pluggableWidgets/datagrid-web/CLAUDE.md | 6 + .../datagrid-web/e2e/DataGrid.spec.js | 26 +++ .../src/components/Pagination.tsx | 4 +- .../src/components/WidgetFooter.tsx | 2 +- .../model/containers/Datagrid.container.ts | 9 +- .../src/model/stores/GridSize.store.ts | 16 +- 7 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/AGENTS.md create mode 100644 packages/pluggableWidgets/datagrid-web/CLAUDE.md diff --git a/packages/pluggableWidgets/datagrid-web/AGENTS.md b/packages/pluggableWidgets/datagrid-web/AGENTS.md new file mode 100644 index 0000000000..aa68027690 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/AGENTS.md @@ -0,0 +1,196 @@ +# Datagrid Web (`@mendix/datagrid-web`) + +Data Grid 2 — the primary data table widget for Mendix web apps. Sorting, filtering, +pagination (buttons/virtual scroll/load more), column resize/reorder/hide, row selection +(single/multi, checkbox/click), data export, and personalization (localStorage/attribute). + +## Commands + +- Test: `cd packages/pluggableWidgets/datagrid-web && pnpm run test` +- Build: `pnpm --filter @mendix/datagrid-web run build` +- Lint: `cd packages/pluggableWidgets/datagrid-web && pnpm run lint` +- E2E: `cd packages/pluggableWidgets/datagrid-web && pnpm run e2e` +- Dev: set `MX_PROJECT_PATH`, then `pnpm run start` inside package dir + +**Shared package changes:** When modifying a workspace dependency (e.g., `widget-plugin-grid`, +`widget-plugin-mobx-kit`), you must build the shared package first, then rebuild datagrid-web: + +```sh +pnpm --filter @mendix/widget-plugin-grid run build && pnpm --filter @mendix/datagrid-web run build +``` + +## Key Concepts + +### Gate Pattern (React → MobX bridge) + +All MobX stores read props from a `DerivedPropsGate`, never from React props directly. + +- `GateProvider` holds a mutable props atom, exposes an immutable `DerivedPropsGate` with `{ readonly props: T }` +- `MainGateProvider` extends this — blocks prop updates during export or select-all to prevent UI flicker +- In `useDatagridContainer`, every React render pushes props through `mainProvider.setProps(props)` +- Stores observe `gate.props` reactively via MobX +- **Two-render cycle:** React props arrive (gate updates, stores still stale) → MobX propagates (stores now fresh). Always use the MobX computed value as an effect dependency, not raw React props + +Source: `model/services/MainGateProvider.service.ts`, `widget-plugin-mobx-kit` (`GateProvider`, `DerivedPropsGate`) + +### Dependency Injection with Brandi + +Two-level container hierarchy, organized by numbered binding groups. + +**Container hierarchy:** + +``` +RootContainer (shared atoms: row count, column count, page size, selection counts, texts) + └── DatagridContainer (extends root — all feature bindings) +SelectAllModule (separate sub-container, initialized alongside) +``` + +**Binding groups** in `Datagrid.container.ts` — 9 numbered `BindingGroup` objects, each with lifecycle hooks: + +1. `inject()` — declare constructor dependencies (runs once at module load) +2. `define(container)` — bind tokens to factories/classes +3. `init(container, deps)` — bind constants and prop-derived values +4. `postInit(container, deps)` — bootstrap: eagerly resolve services, hydrate state + +**Tokens** live in `model/tokens.ts`: + +- `CORE_TOKENS` — shared across containers (mainGate, columnsStore, atoms, selection, config, setupService) +- `DG_TOKENS` — datagrid-specific (query, filters, pagination, grid size, row interaction, view models) +- `SA_TOKENS` — select-all module (barStore, emitter, progressService, feature) + +**React access:** `createInjectionHooks()` in `model/hooks/injection-hooks.ts` generates typed hooks like `useColumnsStore()`, `usePaginationVM()`, etc. + +### SetupComponent Lifecycle + +Stores that need MobX reactions/autoruns implement `SetupComponent`: + +``` +constructor → host.add(this) → setup() called on mount → returns cleanup function → cleanup on unmount +``` + +- `DatagridSetupService` (extends `SetupHost`) collects all setup components +- `useSetup()` hook in `useDatagridContainer` triggers the lifecycle +- `disposeBatch()` batches multiple cleanup functions into one disposer +- Used by: `ColumnGroupStore`, `DatasourceParamsController`, `DatasourceService`, `GridPersonalizationStore`, `DynamicPaginationFeature`, `SelectAllFeature`, `createSelectionHelper`, `createFocusController`, `createClickActionHelper` + +### ComputedAtom Pattern + +`ComputedAtom` = `{ get(): T }` — a lightweight read-only interface over MobX `computed()`. + +- Factory functions (e.g., `rowsAtom`, `gridStyleAtom`, `pageSizeAtom`) create injectable computed values +- Bound via `toInstance(factoryFn).inTransientScope()` — brandi calls the factory with injected deps +- This is how derived state (rows, column count, page size, grid CSS) flows through DI + +## Architecture + +### Entry Point Flow + +``` +Datagrid.tsx (default export) + → useDatagridContainer(props) // creates containers + gate provider + → createDatagridContainer(props) // RootContainer + DatagridContainer + SelectAllModule + → ContainerProvider (brandi-react) // isolated — no inherited bindings + → DatagridRoot (observer) // injection hooks, data export, JS actions + → Widget // pure layout composition +``` + +### Component Tree + +``` +WidgetRoot — outer div, CSS classes for selection/export states +├── WidgetTopBar — top pagination + selection counter +├── WidgetHeader — filter placeholder (provides FilterAPI + Selection contexts) +├── WidgetContent +│ └── Grid — role="grid", CSS grid via custom properties +│ ├── GridHeader — column headers (sort/resize/drag) +│ ├── SelectAllBar — "select all X items across pages" banner +│ ├── RefreshStatus — silent refresh indicator +│ └── GridBody — scrollable body with loading states +│ ├── RowsRenderer — maps ObjectItem[] → Row → DataCell/CheckboxCell/SelectorCell +│ ├── MockHeader — invisible header for column size measurement +│ └── EmptyPlaceholder +├── WidgetFooter — bottom pagination + load more button + selection counter +├── SelectionProgressDialog +└── ExportProgressDialog +``` + +### Data Flow + +``` +React props (DatagridContainerProps) + → MainGateProvider.setProps() + → DerivedPropsGate (MobX observable) + → Stores read gate.props reactively + → ComputedAtoms derive state + → Observer components re-render +``` + +Filter flow: + +``` +Filter widgets (in filtersPlaceholder) → CustomFilterHost.observe() → CombinedFilter + → DatasourceParamsController → QueryService.setFilter() → datasource re-fetches +``` + +## DI Binding Groups + +| # | Group | Key Bindings | Responsibility | +| --- | --------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| 01 | Core | `ColumnGroupStore`, `DatasourceParamsController`, `GridBasicData`, `WidgetRootViewModel`, `GridSizeStore` | Column state, sort/filter param sync, grid sizing | +| 02 | Filter | `CombinedFilter`, `CustomFilterHost`, `WidgetFilterAPI` | Filter condition aggregation + context for filter widgets | +| 03 | Loader | `DerivedLoaderController` | Loading states (first load, next batch, refreshing) | +| 04 | Empty State | `EmptyPlaceholderViewModel`, `emptyStateWidgetsAtom` | Empty message when no items | +| 05 | Personalization | `GridPersonalizationStore` | Save/restore column order, sizes, sort, filters to localStorage or attribute | +| 06 | Pagination | `PaginationViewModel`, `PageControlService`, `DynamicPaginationFeature`, page/size atoms | All pagination logic (buttons, virtual scroll, load more) | +| 07 | Selection | `SelectionHelper`, `SelectActionsProvider`, `gridStyleAtom`, `rowClassProvider`, `SelectionCounterViewModel` | Row selection state + visual feedback | +| 08 | Row Interaction | `CellEventsController`, `CheckboxEventsController`, `FocusTargetController`, `ClickActionHelper` | Click, keyboard, and checkbox event handling | +| 09 | Select All | Imports from `SelectAllModule` sub-container | Cross-page "select all" with progress dialog | + +## Where to Make Changes + +| Task | Files to Touch | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Add a widget property | `Datagrid.xml` → auto-generated `typings/DatagridProps.d.ts` → add to `MainGateProps` pick list (if gated) → add to `DatagridConfig` (if static) → wire in relevant binding group | +| Add/modify a column property | `Datagrid.xml` (columns object) → `helpers/state/column/ColumnStore.tsx` → `helpers/state/column/BaseColumnInfo.ts` | +| Change pagination behavior | `features/pagination/pagination.config.ts` → `_06_paginationBindings` in `Datagrid.container.ts` | +| Change selection behavior | `_07_selectionBindings` → `model/services/SelectionGate.service.ts` → `features/row-interaction/` handlers | +| Change virtual scrolling | `model/stores/GridSize.store.ts` → `model/hooks/useInfiniteControl.tsx` → `model/hooks/useBodyScroll.ts` | +| Add a new DI service | Define token in `model/tokens.ts` → create factory/class → `injected()` call + bind in appropriate `BindingGroup` | +| Add a new feature area | Create folder in `features/` → create `BindingGroup` → add to `groups` array in `Datagrid.container.ts` | +| Modify grid CSS layout | `model/models/grid.model.ts` (`gridStyleAtom`) — uses CSS custom properties on the grid div | +| Change loading states | `model/services/DerivedLoaderController.ts` → `components/GridBody.tsx` (`ContentGuard`) | +| Modify personalization storage | `helpers/storage/` implementations → `helpers/state/GridPersonalizationStore.ts` | +| Change filter integration | `_02_filterBindings` in container → `widget-plugin-filtering` shared package | + +## Workspace Dependencies + +| Package | What Datagrid Uses | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `widget-plugin-grid` | `DatasourceService` (QueryService), pagination (`PaginationViewModel`, `PageControlService`, page atoms), selection (`SelectActionsProvider`, `SelectionHelper`, `SelectAllService`), keyboard nav (`FocusTargetController`, `VirtualGridLayout`), `ClickActionHelper`, `ProgressService` | +| `widget-plugin-mobx-kit` | `DerivedPropsGate`/`GateProvider` (gate pattern), `SetupHost`/`SetupComponent`, `ComputedAtom`, `disposeBatch`, `useSetup`, `useConst`, `Emitter` | +| `widget-plugin-filtering` | `CombinedFilter`, `CustomFilterHost`, `WidgetFilterAPI` — filter aggregation and React context | +| `filter-commons` | `reduceArray`/`restoreArray` — condition array utilities for column filter merging | +| `widget-plugin-component-kit` | `If` — conditional rendering helper | +| `widget-plugin-hooks` | `useOnScreen` — visibility detection for infinite scroll trigger | +| `widget-plugin-platform` | `generateUUID` — unique IDs for containers and configs | +| `widget-plugin-external-events` | External event bus for cross-widget communication | + +## Filter Widget Integration + +Datagrid provides a `WidgetFilterAPI` React context. External filter widgets placed in the `filtersPlaceholder` slot consume it: + +1. Datagrid creates `CustomFilterHost` + `WidgetFilterAPI` in `_02_filterBindings` +2. `WidgetHeader` wraps `filtersPlaceholder` children with the FilterAPI context provider +3. Filter widgets (`datagrid-text-filter-web`, `datagrid-number-filter-web`, `datagrid-date-filter-web`, `datagrid-dropdown-filter-web`) use `withFilterAPI` HOC to access the context +4. Each filter registers itself via `CustomFilterHost.observe(key, filter)` +5. `CombinedFilter` merges all column filters (from `ColumnGroupStore.condWithMeta`) + custom filters (from `CustomFilterHost`) into a single `FilterCondition` +6. `DatasourceParamsController` reacts to filter changes and pushes them to `QueryService.setFilter()` + +## Testing + +- **Unit tests:** Jest + React Testing Library — `src/**/__tests__/*.spec.ts(x)` +- **E2E tests:** Playwright — `e2e/*.spec.js` (DataGrid.spec.js, DataGridSelection.spec.js, filtering/) +- **Test utilities:** `src/utils/test-utils.tsx`, `@mendix/widget-plugin-test-utils` +- **Consistency check:** `src/__tests__/consistency-check.spec.ts` — validates XML ↔ TypeScript prop alignment +- Run unit tests: `cd packages/pluggableWidgets/datagrid-web && pnpm run test` +- Run E2E: `cd packages/pluggableWidgets/datagrid-web && pnpm run e2e` diff --git a/packages/pluggableWidgets/datagrid-web/CLAUDE.md b/packages/pluggableWidgets/datagrid-web/CLAUDE.md new file mode 100644 index 0000000000..2c696f0e05 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/CLAUDE.md @@ -0,0 +1,6 @@ +# Datagrid Web — Claude Code + +See @AGENTS.md for full architecture, DI wiring, component tree, and routing map. + +This file exists as a Claude Code proxy. All datagrid-web context lives in AGENTS.md +so it is available to any AI agent, not just Claude Code. diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index 8fb49539d6..2c795d14f7 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -195,6 +195,32 @@ test.describe("visual testing:", () => { }); }); +test.describe("virtual scrolling + column hiding", () => { + test("scrollbar remains after hiding a column", async ({ page }) => { + await page.goto("/p/filtering-multi"); + await page.waitForLoadState("networkidle"); + + const grid = page.locator(".mx-name-dataGrid21"); + await grid.waitFor({ state: "visible", timeout: 15000 }); + + const gridBody = grid.locator(".widget-datagrid-grid-body"); + + const before = await gridBody.evaluate(el => ({ + hasScrollbar: el.scrollHeight > el.clientHeight + })); + expect(before.hasScrollbar).toBe(true); + + await grid.locator(".column-selector-button").click(); + await page.locator(".column-selectors > li").first().click(); + await page.waitForTimeout(300); + + const after = await gridBody.evaluate(el => ({ + hasScrollbar: el.scrollHeight > el.clientHeight + })); + expect(after.hasScrollbar).toBe(true); + }); +}); + test.describe("a11y testing:", () => { test("checks accessibility violations", async ({ page }) => { await page.goto("/"); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx index 0068a2b798..775ff5b8c5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx @@ -13,12 +13,12 @@ export const Pagination = observer(function Pagination(): ReactNode { canNextPage={paging.hasMoreItems} canPreviousPage={paging.currentPage !== 0} gotoPage={page => paging.setPage(page)} - nextPage={() => paging.setPage(n => n + 1)} + nextPage={() => paging.setPage((n: number) => n + 1)} numberOfItems={paging.totalCount} page={paging.currentPage} pageSize={paging.pageSize} showPagingButtons={paging.showPagingButtons} - previousPage={() => paging.setPage(n => n - 1)} + previousPage={() => paging.setPage((n: number) => n - 1)} pagination={paging.pagination} /> ); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index ee39956ce3..e1ea44d6d0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -35,7 +35,7 @@ export const WidgetFooter = observer(function WidgetFooter(): ReactElement | nul