|
| 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` |
0 commit comments