Skip to content

Commit 6a41dfc

Browse files
committed
fix(datagrid-web): virtual scroll loses scrollbar after column hide
1 parent 1e5abe7 commit 6a41dfc

7 files changed

Lines changed: 253 additions & 6 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Datagrid Web (`@mendix/datagrid-web`)
2+
3+
Data Grid 2 — the primary data table widget for Mendix web apps. Sorting, filtering,
4+
pagination (buttons/virtual scroll/load more), column resize/reorder/hide, row selection
5+
(single/multi, checkbox/click), data export, and personalization (localStorage/attribute).
6+
7+
## Commands
8+
9+
- Test: `cd packages/pluggableWidgets/datagrid-web && pnpm run test`
10+
- Build: `pnpm --filter @mendix/datagrid-web run build`
11+
- Lint: `cd packages/pluggableWidgets/datagrid-web && pnpm run lint`
12+
- E2E: `cd packages/pluggableWidgets/datagrid-web && pnpm run e2e`
13+
- Dev: set `MX_PROJECT_PATH`, then `pnpm run start` inside package dir
14+
15+
**Shared package changes:** When modifying a workspace dependency (e.g., `widget-plugin-grid`,
16+
`widget-plugin-mobx-kit`), you must build the shared package first, then rebuild datagrid-web:
17+
18+
```sh
19+
pnpm --filter @mendix/widget-plugin-grid run build && pnpm --filter @mendix/datagrid-web run build
20+
```
21+
22+
## Key Concepts
23+
24+
### Gate Pattern (React → MobX bridge)
25+
26+
All MobX stores read props from a `DerivedPropsGate`, never from React props directly.
27+
28+
- `GateProvider` holds a mutable props atom, exposes an immutable `DerivedPropsGate<T>` with `{ readonly props: T }`
29+
- `MainGateProvider` extends this — blocks prop updates during export or select-all to prevent UI flicker
30+
- In `useDatagridContainer`, every React render pushes props through `mainProvider.setProps(props)`
31+
- Stores observe `gate.props` reactively via MobX
32+
- **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
33+
34+
Source: `model/services/MainGateProvider.service.ts`, `widget-plugin-mobx-kit` (`GateProvider`, `DerivedPropsGate`)
35+
36+
### Dependency Injection with Brandi
37+
38+
Two-level container hierarchy, organized by numbered binding groups.
39+
40+
**Container hierarchy:**
41+
42+
```
43+
RootContainer (shared atoms: row count, column count, page size, selection counts, texts)
44+
└── DatagridContainer (extends root — all feature bindings)
45+
SelectAllModule (separate sub-container, initialized alongside)
46+
```
47+
48+
**Binding groups** in `Datagrid.container.ts` — 9 numbered `BindingGroup` objects, each with lifecycle hooks:
49+
50+
1. `inject()` — declare constructor dependencies (runs once at module load)
51+
2. `define(container)` — bind tokens to factories/classes
52+
3. `init(container, deps)` — bind constants and prop-derived values
53+
4. `postInit(container, deps)` — bootstrap: eagerly resolve services, hydrate state
54+
55+
**Tokens** live in `model/tokens.ts`:
56+
57+
- `CORE_TOKENS` — shared across containers (mainGate, columnsStore, atoms, selection, config, setupService)
58+
- `DG_TOKENS` — datagrid-specific (query, filters, pagination, grid size, row interaction, view models)
59+
- `SA_TOKENS` — select-all module (barStore, emitter, progressService, feature)
60+
61+
**React access:** `createInjectionHooks()` in `model/hooks/injection-hooks.ts` generates typed hooks like `useColumnsStore()`, `usePaginationVM()`, etc.
62+
63+
### SetupComponent Lifecycle
64+
65+
Stores that need MobX reactions/autoruns implement `SetupComponent`:
66+
67+
```
68+
constructor → host.add(this) → setup() called on mount → returns cleanup function → cleanup on unmount
69+
```
70+
71+
- `DatagridSetupService` (extends `SetupHost`) collects all setup components
72+
- `useSetup()` hook in `useDatagridContainer` triggers the lifecycle
73+
- `disposeBatch()` batches multiple cleanup functions into one disposer
74+
- Used by: `ColumnGroupStore`, `DatasourceParamsController`, `DatasourceService`, `GridPersonalizationStore`, `DynamicPaginationFeature`, `SelectAllFeature`, `createSelectionHelper`, `createFocusController`, `createClickActionHelper`
75+
76+
### ComputedAtom Pattern
77+
78+
`ComputedAtom<T>` = `{ get(): T }` — a lightweight read-only interface over MobX `computed()`.
79+
80+
- Factory functions (e.g., `rowsAtom`, `gridStyleAtom`, `pageSizeAtom`) create injectable computed values
81+
- Bound via `toInstance(factoryFn).inTransientScope()` — brandi calls the factory with injected deps
82+
- This is how derived state (rows, column count, page size, grid CSS) flows through DI
83+
84+
## Architecture
85+
86+
### Entry Point Flow
87+
88+
```
89+
Datagrid.tsx (default export)
90+
→ useDatagridContainer(props) // creates containers + gate provider
91+
→ createDatagridContainer(props) // RootContainer + DatagridContainer + SelectAllModule
92+
→ ContainerProvider (brandi-react) // isolated — no inherited bindings
93+
→ DatagridRoot (observer) // injection hooks, data export, JS actions
94+
→ Widget // pure layout composition
95+
```
96+
97+
### Component Tree
98+
99+
```
100+
WidgetRoot — outer div, CSS classes for selection/export states
101+
├── WidgetTopBar — top pagination + selection counter
102+
├── WidgetHeader — filter placeholder (provides FilterAPI + Selection contexts)
103+
├── WidgetContent
104+
│ └── Grid — role="grid", CSS grid via custom properties
105+
│ ├── GridHeader — column headers (sort/resize/drag)
106+
│ ├── SelectAllBar — "select all X items across pages" banner
107+
│ ├── RefreshStatus — silent refresh indicator
108+
│ └── GridBody — scrollable body with loading states
109+
│ ├── RowsRenderer — maps ObjectItem[] → Row → DataCell/CheckboxCell/SelectorCell
110+
│ ├── MockHeader — invisible header for column size measurement
111+
│ └── EmptyPlaceholder
112+
├── WidgetFooter — bottom pagination + load more button + selection counter
113+
├── SelectionProgressDialog
114+
└── ExportProgressDialog
115+
```
116+
117+
### Data Flow
118+
119+
```
120+
React props (DatagridContainerProps)
121+
→ MainGateProvider.setProps()
122+
→ DerivedPropsGate (MobX observable)
123+
→ Stores read gate.props reactively
124+
→ ComputedAtoms derive state
125+
→ Observer components re-render
126+
```
127+
128+
Filter flow:
129+
130+
```
131+
Filter widgets (in filtersPlaceholder) → CustomFilterHost.observe() → CombinedFilter
132+
→ DatasourceParamsController → QueryService.setFilter() → datasource re-fetches
133+
```
134+
135+
## DI Binding Groups
136+
137+
| # | Group | Key Bindings | Responsibility |
138+
| --- | --------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
139+
| 01 | Core | `ColumnGroupStore`, `DatasourceParamsController`, `GridBasicData`, `WidgetRootViewModel`, `GridSizeStore` | Column state, sort/filter param sync, grid sizing |
140+
| 02 | Filter | `CombinedFilter`, `CustomFilterHost`, `WidgetFilterAPI` | Filter condition aggregation + context for filter widgets |
141+
| 03 | Loader | `DerivedLoaderController` | Loading states (first load, next batch, refreshing) |
142+
| 04 | Empty State | `EmptyPlaceholderViewModel`, `emptyStateWidgetsAtom` | Empty message when no items |
143+
| 05 | Personalization | `GridPersonalizationStore` | Save/restore column order, sizes, sort, filters to localStorage or attribute |
144+
| 06 | Pagination | `PaginationViewModel`, `PageControlService`, `DynamicPaginationFeature`, page/size atoms | All pagination logic (buttons, virtual scroll, load more) |
145+
| 07 | Selection | `SelectionHelper`, `SelectActionsProvider`, `gridStyleAtom`, `rowClassProvider`, `SelectionCounterViewModel` | Row selection state + visual feedback |
146+
| 08 | Row Interaction | `CellEventsController`, `CheckboxEventsController`, `FocusTargetController`, `ClickActionHelper` | Click, keyboard, and checkbox event handling |
147+
| 09 | Select All | Imports from `SelectAllModule` sub-container | Cross-page "select all" with progress dialog |
148+
149+
## Where to Make Changes
150+
151+
| Task | Files to Touch |
152+
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
153+
| 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 |
154+
| Add/modify a column property | `Datagrid.xml` (columns object) → `helpers/state/column/ColumnStore.tsx``helpers/state/column/BaseColumnInfo.ts` |
155+
| Change pagination behavior | `features/pagination/pagination.config.ts``_06_paginationBindings` in `Datagrid.container.ts` |
156+
| Change selection behavior | `_07_selectionBindings``model/services/SelectionGate.service.ts``features/row-interaction/` handlers |
157+
| Change virtual scrolling | `model/stores/GridSize.store.ts``model/hooks/useInfiniteControl.tsx``model/hooks/useBodyScroll.ts` |
158+
| Add a new DI service | Define token in `model/tokens.ts` → create factory/class → `injected()` call + bind in appropriate `BindingGroup` |
159+
| Add a new feature area | Create folder in `features/` → create `BindingGroup` → add to `groups` array in `Datagrid.container.ts` |
160+
| Modify grid CSS layout | `model/models/grid.model.ts` (`gridStyleAtom`) — uses CSS custom properties on the grid div |
161+
| Change loading states | `model/services/DerivedLoaderController.ts``components/GridBody.tsx` (`ContentGuard`) |
162+
| Modify personalization storage | `helpers/storage/` implementations → `helpers/state/GridPersonalizationStore.ts` |
163+
| Change filter integration | `_02_filterBindings` in container → `widget-plugin-filtering` shared package |
164+
165+
## Workspace Dependencies
166+
167+
| Package | What Datagrid Uses |
168+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169+
| `widget-plugin-grid` | `DatasourceService` (QueryService), pagination (`PaginationViewModel`, `PageControlService`, page atoms), selection (`SelectActionsProvider`, `SelectionHelper`, `SelectAllService`), keyboard nav (`FocusTargetController`, `VirtualGridLayout`), `ClickActionHelper`, `ProgressService` |
170+
| `widget-plugin-mobx-kit` | `DerivedPropsGate`/`GateProvider` (gate pattern), `SetupHost`/`SetupComponent`, `ComputedAtom`, `disposeBatch`, `useSetup`, `useConst`, `Emitter` |
171+
| `widget-plugin-filtering` | `CombinedFilter`, `CustomFilterHost`, `WidgetFilterAPI` — filter aggregation and React context |
172+
| `filter-commons` | `reduceArray`/`restoreArray` — condition array utilities for column filter merging |
173+
| `widget-plugin-component-kit` | `If` — conditional rendering helper |
174+
| `widget-plugin-hooks` | `useOnScreen` — visibility detection for infinite scroll trigger |
175+
| `widget-plugin-platform` | `generateUUID` — unique IDs for containers and configs |
176+
| `widget-plugin-external-events` | External event bus for cross-widget communication |
177+
178+
## Filter Widget Integration
179+
180+
Datagrid provides a `WidgetFilterAPI` React context. External filter widgets placed in the `filtersPlaceholder` slot consume it:
181+
182+
1. Datagrid creates `CustomFilterHost` + `WidgetFilterAPI` in `_02_filterBindings`
183+
2. `WidgetHeader` wraps `filtersPlaceholder` children with the FilterAPI context provider
184+
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
185+
4. Each filter registers itself via `CustomFilterHost.observe(key, filter)`
186+
5. `CombinedFilter` merges all column filters (from `ColumnGroupStore.condWithMeta`) + custom filters (from `CustomFilterHost`) into a single `FilterCondition`
187+
6. `DatasourceParamsController` reacts to filter changes and pushes them to `QueryService.setFilter()`
188+
189+
## Testing
190+
191+
- **Unit tests:** Jest + React Testing Library — `src/**/__tests__/*.spec.ts(x)`
192+
- **E2E tests:** Playwright — `e2e/*.spec.js` (DataGrid.spec.js, DataGridSelection.spec.js, filtering/)
193+
- **Test utilities:** `src/utils/test-utils.tsx`, `@mendix/widget-plugin-test-utils`
194+
- **Consistency check:** `src/__tests__/consistency-check.spec.ts` — validates XML ↔ TypeScript prop alignment
195+
- Run unit tests: `cd packages/pluggableWidgets/datagrid-web && pnpm run test`
196+
- Run E2E: `cd packages/pluggableWidgets/datagrid-web && pnpm run e2e`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Datagrid Web — Claude Code
2+
3+
See @AGENTS.md for full architecture, DI wiring, component tree, and routing map.
4+
5+
This file exists as a Claude Code proxy. All datagrid-web context lives in AGENTS.md
6+
so it is available to any AI agent, not just Claude Code.

packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,32 @@ test.describe("visual testing:", () => {
195195
});
196196
});
197197

198+
test.describe("virtual scrolling + column hiding", () => {
199+
test("scrollbar remains after hiding a column", async ({ page }) => {
200+
await page.goto("/p/filtering-multi");
201+
await page.waitForLoadState("networkidle");
202+
203+
const grid = page.locator(".mx-name-dataGrid21");
204+
await grid.waitFor({ state: "visible", timeout: 15000 });
205+
206+
const gridBody = grid.locator(".widget-datagrid-grid-body");
207+
208+
const before = await gridBody.evaluate(el => ({
209+
hasScrollbar: el.scrollHeight > el.clientHeight
210+
}));
211+
expect(before.hasScrollbar).toBe(true);
212+
213+
await grid.locator(".column-selector-button").click();
214+
await page.locator(".column-selectors > li").first().click();
215+
await page.waitForTimeout(300);
216+
217+
const after = await gridBody.evaluate(el => ({
218+
hasScrollbar: el.scrollHeight > el.clientHeight
219+
}));
220+
expect(after.hasScrollbar).toBe(true);
221+
});
222+
});
223+
198224
test.describe("a11y testing:", () => {
199225
test("checks accessibility violations", async ({ page }) => {
200226
await page.goto("/");

packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ export const Pagination = observer(function Pagination(): ReactNode {
1313
canNextPage={paging.hasMoreItems}
1414
canPreviousPage={paging.currentPage !== 0}
1515
gotoPage={page => paging.setPage(page)}
16-
nextPage={() => paging.setPage(n => n + 1)}
16+
nextPage={() => paging.setPage((n: number) => n + 1)}
1717
numberOfItems={paging.totalCount}
1818
page={paging.currentPage}
1919
pageSize={paging.pageSize}
2020
showPagingButtons={paging.showPagingButtons}
21-
previousPage={() => paging.setPage(n => n - 1)}
21+
previousPage={() => paging.setPage((n: number) => n - 1)}
2222
pagination={paging.pagination}
2323
/>
2424
);

packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const WidgetFooter = observer(function WidgetFooter(): ReactElement | nul
3535
<div className="widget-datagrid-pb-middle">
3636
<button
3737
className="btn btn-primary widget-datagrid-load-more"
38-
onClick={() => paging.setPage(n => n + 1)}
38+
onClick={() => paging.setPage((n: number) => n + 1)}
3939
tabIndex={0}
4040
>
4141
{loadMoreButtonCaption}

packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,14 @@ const _01_coreBindings: BindingGroup = {
7979
DG.exportProgressService,
8080
SA_TOKENS.selectionDialogVM
8181
);
82-
injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction, DG.pageSize);
82+
injected(
83+
GridSizeStore,
84+
CORE.atoms.hasMoreItems,
85+
DG.paginationConfig,
86+
DG.setPageAction,
87+
DG.pageSize,
88+
CORE.atoms.visibleColumnsCount
89+
);
8390
},
8491
define(container: Container) {
8592
container.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope();

0 commit comments

Comments
 (0)