Design: ConnectedDataGrid & the Bulk-Edit Write Path
Design: ConnectedDataGrid & the Bulk-Edit Write Path
Section titled “Design: ConnectedDataGrid & the Bulk-Edit Write Path”Status: Draft — for review
Created: 2026-05-26
Repository: ux-prototype (components), arda-frontend-app (consumers + BFF), operations (API)
Parent: Phase 2: Standardized AG Grid
Related: Design: Rich Cell Data Types — the value↔string round trip this design persists.
Overview
Section titled “Overview”The standardized grid now supports spreadsheet-style editing — single-cell edits, range paste, fill-down and undo/redo (see Rich Cell Data Types). Those are all client capabilities. This document designs how the edits they produce get persisted safely, and which component owns that responsibility.
The central design move is to split the grid into two tiers and keep the write path orthogonal to the read model:
DataGrid(presentational molecule) — the grid widget. Renders, edits, copies, pastes, fills, and undoes in memory. Used for ephemeral grids that read once and never persist (e.g. a Purchase-Order item table that exists only to generate a one-off PDF).ConnectedDataGrid(stateful container) — wrapsDataGridand adds the two things a persistent grid needs: a read source (client rows or a server/SSRM datasource) and a write/commit pipeline. Used for persistent grids (vendors, items).
The enabling discovery is that the backend already exposes an atomic, multi-row update endpoint on a shared contract, across every entity table. So the write path can be built on existing endpoints today; no new batch API is a prerequisite. We prove the whole pipeline on the vendor grid first, then port it to the items grid — replacing the bespoke per-row machinery that grid carries today.
Decision Summary
Section titled “Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | Where does persistence live? | A new ConnectedDataGrid container; DataGrid stays a presentational, ephemeral-only widget. |
| DQ-002 | How is the read model selected (client vs SSRM)? | A discriminated data source on ConnectedDataGrid, not a boolean on DataGrid. |
| DQ-003 | How do bulk grid operations reach the API? | One commit pipeline: accumulate dirty rows → flush on settle → route by size → reconcile. |
| DQ-004 | What endpoint(s) carry bulk writes? | The existing atomic PUT …/bulk (common BulkUpdateRequest); single edits use PUT …/{id}. |
| DQ-005 | How is Ctrl+Z persisted? | It isn’t special — AG Grid replays undo as cellValueChanged, so it re-enters the same commit pipeline. |
| DQ-006 | How are DataGrid props forwarded through the container? | extends Omit<DataGridProps, …owned> — curated owned props, passthrough for the rest. |
| DQ-007 | Read model for the vendor proving ground? | Vendors on SSRM for testing fidelity, even though ~200 rows would otherwise warrant the client model. |
| DQ-008 | Naming of the container? | Rename EntityDataGrid → ConnectedDataGrid as part of giving it the read-source + commit roles. |
This is a living design; decisions are refined as the vendor implementation lands.
Background: the write surface that already exists
Section titled “Background: the write surface that already exists”A survey of the live OpenAPI specs (item, business-affiliate, order) found that every entity table exposes the same atomic bulk-update endpoint, built on a shared data-authority contract:
| Table | Single update | Bulk update |
|---|---|---|
| Items | PUT /v1/item/item/{id} | PUT /v1/item/item/bulk |
| Vendors | PUT /v1/business-affiliate/business-affiliate/{id} | PUT /v1/business-affiliate/business-affiliate/bulk |
| Orders | PUT /v1/order/order/{id} | PUT /v1/order/order/bulk |
All three /bulk endpoints reference the same common envelope:
{ "updates": [ { "eId": "uuid", "payload": { /* full <Entity>Input, same schema as single PUT */ } } ]}Key properties of /bulk, and what each means for the grid:
- Atomic — “all updates succeed or none if any fails.” Makes bulk edit and undo trustworthy: the grid and server can never end up half-applied.
- Full-entity payload per entry (not partial/merge). So it is full-row
replace × N in one transaction. The clobber-vs-stale-read concern is not
solved by
/bulk; it is bounded to the batch (see Caveats). - Returns
EntityRecord[]— the authoritative records, so the client can reconcile from the response instead of a blind refetch. - No per-row results — a partial-failure body does not exist; on rejection the whole batch fails with a single error.
There is also an async, file-based batch path (upload-job: signed S3 URL →
gzipped CSV → process → poll) for very large imports. It is the right tool for
thousands of rows, the wrong tool for a 20-cell paste.
Reframing “should PATCH be a standard?”: the batch envelope is already the standard — it lives in
common.lib…dataauthorityand is live on item, vendor and order. What is genuinely missing for fully-safe bulk editing is optimistic concurrency (and, optionally, partial payloads). Those become enhancements to an existing contract, not a new feature to build. See Backend evolution.
Component model — DataGrid vs ConnectedDataGrid
Section titled “Component model — DataGrid vs ConnectedDataGrid”ConnectedDataGrid composes DataGrid (has-a, not is-a): it renders the
molecule internally and wraps it with data binding, edit lifecycle, column-state
persistence and the commit pipeline.
DataGrid owns (presentation + interaction): theme, rendering, cell editing,
cell data types (renderer/editor/round-trip), row selection, clipboard policy
(clipboardPaste), range selection + fill (cellSelection), undo/redo
(undoRedoLimit), client search, client pagination, empty/loading, CSV export.
ConnectedDataGrid owns (data + state): the read source, the
write/commit pipeline, entity identity (getEntityId), edit lifecycle
(dirty tracking, saveAll/discardAll), and column-state persistence.
Today’s
EntityDataGridfactory is the seed ofConnectedDataGrid— it already has the model/view prop split, apaginationMode, and anonRowPublishwrite seam — but its server props are stubbed and its write seam is per-row. This design completes and renames it (DQ-008).
Prop forwarding (DQ-006)
Section titled “Prop forwarding (DQ-006)”Because the container wraps the molecule and owns some of the same props, a
loose {...rest} bag would let a consumer fight the container (e.g. pass
rowData while the container drives the data source). A fully hand-written
forward is the opposite failure — every new molecule prop (we just added
clipboardPaste, undoRedoLimit, cellSelection) would force a container edit.
The resolution is structural subtraction: inherit the molecule’s prop types, remove the ones the container owns, and spread the safe remainder.
// Props the container drives — removed so consumers can't conflict with it.type OwnedByContainer = | 'rowData' | 'onCellValueChanged' | 'onCellEditingStopped' | 'getRowClass' // data + write lifecycle | 'searchConfig' | 'pageSize'; // container reimplements these
interface ConnectedDataGridProps<T> extends Omit<DataGridProps<T>, OwnedByContainer> { dataSource: EntityDataSource<T>; // read (see below) onCommit?: (changes: RowChange<T>[]) => Promise<CommitResult[]>; // write (see below) getEntityId: (entity: T) => string; // …column-state persistence keys, etc.}Owned props raise a compile error if passed; presentational capability props
(clipboardPaste, cellSelection, undoRedoLimit, dataTypeDefinitions,
columnTypes, …) flow through for free. A narrow gridProps?: Pick<DataGridProps, …>
escape hatch can be added later if a rare molecule prop is needed; it is not the
default.
Read model — a discriminated data source (DQ-002)
Section titled “Read model — a discriminated data source (DQ-002)”The read model is a container concern, expressed as a discriminated union so invalid combinations are unrepresentable:
type EntityDataSource<T> = | { mode: 'client'; data: T[] } // all rows in memory | { mode: 'server'; getRows: (block: BlockRequest) => Promise<{ rows: T[]; lastRow: number }> }; // SSRMclient— the grid holds every row; filter/sort/paginate happen in the browser. Correct for small/medium tables. This is the idiomatic default and what most consumers will use.server(SSRM) — the grid requests blocks via a datasource; filter/sort/ search are pushed to the server. Required for large tables (items).
Putting this on the container (not a boolean on DataGrid) keeps all SSRM
ceremony — module registration, rowModelType, datasource adapter, server
search — in one place, and leaves the molecule simple.
Vendor proving ground (DQ-007). Vendors is ~200 rows, which would normally call for the client model. We deliberately run vendors on SSRM for testing fidelity: the items grid is SSRM, and the hardest write-path problem (writing from a stale server snapshot) only appears under SSRM. Proving the pipeline on a small, safe SSRM table de-risks the items port and exercises the SSRM datasource code (which the container needs for items anyway).
Write path — one commit pipeline (DQ-003)
Section titled “Write path — one commit pipeline (DQ-003)”Every mutating grid operation — single edit, range paste, fill-down, range edit,
delete, and undo/redo — surfaces in AG Grid as a stream of cellValueChanged
events. So one pipeline handles them all, with no per-operation code:
accumulate dirty rows → flush on settle → route by size → reconcile
- Accumulate.
onCellValueChangedrecords{ rowId → changedFields }. - Flush on settle. Single edits flush on editing-stopped / row-blur (as
today). Paste and fill do not fire editing-stopped — so the pipeline must
also flush on
onPasteEnd/onFillEnd. (This trigger is the one piece missing from the current item grid; see the diff.) - Pre-validate. Run the changed values through the cell data type’s
valueParser(already built for rich cells) and drop invalid rows before sending — important because/bulkis all-or-nothing. - Route by size:
- 1 row →
PUT /v1/{entity}/{id}(lowest latency). - 2 … a few hundred rows → one
PUT /v1/{entity}/bulk— one round-trip, one transaction. This replaces any per-row fan-out, so there is no storm. - thousands / file import → the async
upload-jobpath.
- 1 row →
- Reconcile. Patch the affected rows from the returned
EntityRecord[](client model) or refresh the touched SSRM block (server model). The read model only matters here.
Flow: single-cell edit → single PUT
Section titled “Flow: single-cell edit → single PUT”Flow: bulk paste / fill → one atomic bulk PUT
Section titled “Flow: bulk paste / fill → one atomic bulk PUT”Flow: undo (Ctrl+Z) reuses the pipeline (DQ-005)
Section titled “Flow: undo (Ctrl+Z) reuses the pipeline (DQ-005)”Undo of a bulk paste reverts N cells → N cellValueChanged → one atomic bulk PUT.
Because /bulk is all-or-nothing, the revert can’t half-apply. Only the
synchronous paths participate in undo; the async upload-job path is kept out of
the undo stack.
Diff from the current items grid
Section titled “Diff from the current items grid”The production items grid (ItemTableAGGrid in arda-frontend-app) is a bespoke
AgGridReact integration that predates this design. It is the thing
we are porting away from. The differences are the porting work:
| Concern | Items grid today (ItemTableAGGrid) | Target (ConnectedDataGrid) |
|---|---|---|
| Component | Bespoke AgGridReact, hand-wired | Shared DataGrid molecule wrapped by the container |
| Write granularity | Per-row full PUT, one row at a time | Size-routed: single PUT (1 row) / one atomic /bulk (many) |
| Bulk paste / fill | Not persisted — no onPasteEnd/onFillEnd; publish only on row-blur | Persisted via flush-on-settle through the commit pipeline |
| Undo | Not persisted | Persisted via the cellValueChanged replay → same pipeline |
| Concurrency control | Per-row publishQueue (serialize + coalesce per row), no global cap | One bulk request — no fan-out, so no cap needed |
| Draft step | getOrCreateDraft per row → first edit can be 2 calls | Greenfield on vendors (no draft); items migration must decide its fate |
| Reconcile | debouncedRefresh reloads the SSRM block after each PUT | Reconcile from EntityRecord[] response; block refresh only when needed |
| Stale snapshot | pendingCellValuesRef merges edits onto a possibly-stale node.data | Same exposure under SSRM — addressed by optimistic concurrency (backend) |
| Validation feedback | Ad hoc | Pre-validate via the data type valueParser before sending |
The vendor grid, by contrast, currently has no write path at all — every
edit mutates in-memory rowData and is lost on reload. That makes it greenfield:
we build the clean pipeline there first, with none of the legacy machinery.
Caveats and honest limitations
Section titled “Caveats and honest limitations”- Full-row payload from a possibly-stale snapshot. Each
/bulkentry is a whole<Entity>Inputbuilt from the grid’s row. Under SSRM that row can be stale, so a write can clobber a field another user changed between read and write./bulkatomicity protects within the batch, not against stale reads. The fix is optimistic concurrency (a version /If-Matchper entry →409on conflict) — a backend enhancement, not a prerequisite. The client model (vendors-as-client) does not have this exposure; SSRM does. - All-or-nothing UX. One invalid row rolls back the whole paste, and the
endpoint returns a single error (no per-row results). Mitigation: pre-validate
with
valueParser; surface the offending row if the server still rejects. - BFF gap. Today only single-entity
PUTis proxied. Bulk needs thinPUT /api/arda/{entity}/bulkproxy routes (mirroring the existing PUT handler’s JWT + header injection). Vendor write is fully greenfield — even the single PUT proxy does not exist yet. - Latency band. Synchronous
/bulkis good to ~hundreds of rows; pick a threshold to hand very large sets to the asyncupload-jobpath.
Backend evolution
Section titled “Backend evolution”/bulk lets us ship safe bulk editing on existing endpoints. The backend
asks are enhancements to the existing common contract, in priority order:
- Optimistic concurrency on
BulkUpdateRequest(version /If-Match) — the one change that makes SSRM bulk-edit fully safe against stale snapshots. - Per-row results (a
207-style body) so a partial paste need not be all-or-nothing — optional; pre-validation covers most cases. - Partial / merge payloads (true
PATCHsemantics) — a later optimization to shrink payloads and narrow clobber; unnecessary once (1) exists.
These are tracked separately and are not on the critical path for the vendor or items front-end work.
Roadmap
Section titled “Roadmap”The work is sequenced so each phase is independently valuable and the items grid is the last, lowest-risk step.
Phase 0 — ConnectedDataGrid foundation (ux-prototype).
Rename EntityDataGrid → ConnectedDataGrid (DQ-008). Add the Omit-extend prop
forwarding (DQ-006) so the molecule’s capability props pass through. Introduce the
discriminated dataSource (client mode first) and a bulk-capable onCommit seam
alongside the existing per-row onRowPublish.
Phase 1 — Vendor write-back on existing endpoints, client model (arda-frontend-app).
Add BFF proxies PUT /api/arda/business-affiliate/{id} and …/bulk. Wire the
commit pipeline: flush on onPasteEnd/onFillEnd, pre-validate via valueParser,
route by size to single/bulk PUT, reconcile from the response. Vendor bulk
paste, fill and undo now persist. (Client model — no stale-snapshot exposure.)
Phase 2 — SSRM in ConnectedDataGrid; vendors on SSRM (proving ground).
Add the server data source: datasource adapter, server search/sort/filter, block
reconcile. Add a business-affiliate/query-ssrm BFF route (mirroring the items
pattern). Run vendors on SSRM to exercise stale-snapshot reconcile on a small, safe
table.
Phase 3 — Port the items grid (arda-frontend-app).
Replace ItemTableAGGrid’s bespoke per-row/draft machinery with
ConnectedDataGrid + the commit pipeline. Decide the draft step’s fate. Migrate
undo. Retire the bespoke grid.
Phase 4 — Backend hardening (operations; parallel / follow-up).
Optimistic concurrency on BulkUpdateRequest; optionally per-row results and
partial payloads. Tracked as separate tickets.
Implementation Scope
Section titled “Implementation Scope”Files to Create
Section titled “Files to Create”| File | Path | Purpose |
|---|---|---|
business-affiliate/[entityId]/route.ts | arda-frontend-app/src/app/api/arda/business-affiliate | BFF single-entity PUT proxy for vendors |
business-affiliate/bulk/route.ts | arda-frontend-app/src/app/api/arda/business-affiliate | BFF atomic PUT …/bulk proxy for vendors |
| commit-pipeline module | ux-prototype (canary, ConnectedDataGrid) | accumulate → flush → route-by-size → reconcile |
Files to Modify
Section titled “Files to Modify”| File | Change |
|---|---|
create-entity-data-grid.tsx | Rename to ConnectedDataGrid; add dataSource, onCommit, Omit-extend forwarding |
data-grid.tsx (molecule) | Surface onPasteEnd / onFillEnd so the container can flush |
vendors/page.tsx | Migrate from bare DataGrid to ConnectedDataGrid; supply data source + commit |
Out of Scope
Section titled “Out of Scope”- Partial / merge (
PATCH) payloads — tracked as backend evolution, not required. - The async
upload-jobpath wiring — only the size-routing seam is designed here. - Items-grid port mechanics — designed at Phase 3, not in this first slice.
- Optimistic concurrency implementation — backend, Phase 4.
Testing Strategy
Section titled “Testing Strategy”Unit / Component Tests
Section titled “Unit / Component Tests”| Test | Target | Validates |
|---|---|---|
| route-by-size selects single vs bulk | commit pipeline | 1 row → PUT /{id}; N rows → one PUT /bulk |
| flush on paste/fill end | commit pipeline | onPasteEnd/onFillEnd enqueue all affected rows |
| pre-validate drops invalid rows | commit pipeline | invalid valueParser results excluded from the batch |
| undo re-enters the pipeline | ConnectedDataGrid | Ctrl+Z produces a commit with reverted values |
| owned props rejected at compile time | ConnectedDataGridProps | passing rowData is a type error (Omit-extend) |
Integration / API Tests
Section titled “Integration / API Tests”| Test | Setup | Validates |
|---|---|---|
| vendor bulk paste persists atomically | mock /business-affiliate/bulk | one request; all rows updated or none; UI reconciles |
| BFF bulk proxy forwards auth headers | BFF route test | JWT → X-Author/X-Tenant-Id/X-oidc-subject |
References
Section titled “References”- Phase 2: Standardized AG Grid — parent plan.
- Design: Rich Cell Data Types — the value↔string round trip and
valueParservalidation this design persists. - Vendor Page and Standard Layout — project overview.
- Design Document template — format conventions.
- Live OpenAPI specs:
/v1/item/docs/openApi.json,/v1/business-affiliate/docs/openApi.json,/v1/order/docs/openApi.json—…/bulk(BulkUpdateRequest), singlePUT …/{id}. - AG Grid v34.3.1 docs: Server-Side Row Model, Clipboard (
onPasteEnd), Cell Selection (fill handle,onFillEnd), Undo/Redo (undoRedoCellEditing).
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved