Skip to content

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.

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) — wraps DataGrid and 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.

#DecisionChosen Option
DQ-001Where does persistence live?A new ConnectedDataGrid container; DataGrid stays a presentational, ephemeral-only widget.
DQ-002How is the read model selected (client vs SSRM)?A discriminated data source on ConnectedDataGrid, not a boolean on DataGrid.
DQ-003How do bulk grid operations reach the API?One commit pipeline: accumulate dirty rows → flush on settle → route by size → reconcile.
DQ-004What endpoint(s) carry bulk writes?The existing atomic PUT …/bulk (common BulkUpdateRequest); single edits use PUT …/{id}.
DQ-005How is Ctrl+Z persisted?It isn’t special — AG Grid replays undo as cellValueChanged, so it re-enters the same commit pipeline.
DQ-006How are DataGrid props forwarded through the container?extends Omit<DataGridProps, …owned> — curated owned props, passthrough for the rest.
DQ-007Read model for the vendor proving ground?Vendors on SSRM for testing fidelity, even though ~200 rows would otherwise warrant the client model.
DQ-008Naming of the container?Rename EntityDataGridConnectedDataGrid 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:

TableSingle updateBulk update
ItemsPUT /v1/item/item/{id}PUT /v1/item/item/bulk
VendorsPUT /v1/business-affiliate/business-affiliate/{id}PUT /v1/business-affiliate/business-affiliate/bulk
OrdersPUT /v1/order/order/{id}PUT /v1/order/order/bulk

All three /bulk endpoints reference the same common envelope:

cards.arda.common.lib.api.rest.server.dataauthority.BulkUpdateRequest
{
"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…dataauthority and 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.

PlantUML diagram

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 EntityDataGrid factory is the seed of ConnectedDataGrid — it already has the model/view prop split, a paginationMode, and an onRowPublish write seam — but its server props are stubbed and its write seam is per-row. This design completes and renames it (DQ-008).

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 }> }; // SSRM
  • client — 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

  1. Accumulate. onCellValueChanged records { rowId → changedFields }.
  2. 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.)
  3. 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 /bulk is all-or-nothing.
  4. Route by size:
    • 1 rowPUT /v1/{entity}/{id} (lowest latency).
    • 2 … a few hundred rowsone 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-job path.
  5. 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.

PlantUML diagram

Flow: bulk paste / fill → one atomic bulk PUT

Section titled “Flow: bulk paste / fill → one atomic bulk PUT”

PlantUML diagram

Flow: undo (Ctrl+Z) reuses the pipeline (DQ-005)

Section titled “Flow: undo (Ctrl+Z) reuses the pipeline (DQ-005)”

PlantUML diagram

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.


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:

ConcernItems grid today (ItemTableAGGrid)Target (ConnectedDataGrid)
ComponentBespoke AgGridReact, hand-wiredShared DataGrid molecule wrapped by the container
Write granularityPer-row full PUT, one row at a timeSize-routed: single PUT (1 row) / one atomic /bulk (many)
Bulk paste / fillNot persisted — no onPasteEnd/onFillEnd; publish only on row-blurPersisted via flush-on-settle through the commit pipeline
UndoNot persistedPersisted via the cellValueChanged replay → same pipeline
Concurrency controlPer-row publishQueue (serialize + coalesce per row), no global capOne bulk request — no fan-out, so no cap needed
Draft stepgetOrCreateDraft per row → first edit can be 2 callsGreenfield on vendors (no draft); items migration must decide its fate
ReconciledebouncedRefresh reloads the SSRM block after each PUTReconcile from EntityRecord[] response; block refresh only when needed
Stale snapshotpendingCellValuesRef merges edits onto a possibly-stale node.dataSame exposure under SSRM — addressed by optimistic concurrency (backend)
Validation feedbackAd hocPre-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.


  1. Full-row payload from a possibly-stale snapshot. Each /bulk entry is a whole <Entity>Input built 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. /bulk atomicity protects within the batch, not against stale reads. The fix is optimistic concurrency (a version / If-Match per entry → 409 on conflict) — a backend enhancement, not a prerequisite. The client model (vendors-as-client) does not have this exposure; SSRM does.
  2. 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.
  3. BFF gap. Today only single-entity PUT is proxied. Bulk needs thin PUT /api/arda/{entity}/bulk proxy routes (mirroring the existing PUT handler’s JWT + header injection). Vendor write is fully greenfield — even the single PUT proxy does not exist yet.
  4. Latency band. Synchronous /bulk is good to ~hundreds of rows; pick a threshold to hand very large sets to the async upload-job path.

/bulk lets us ship safe bulk editing on existing endpoints. The backend asks are enhancements to the existing common contract, in priority order:

  1. Optimistic concurrency on BulkUpdateRequest (version / If-Match) — the one change that makes SSRM bulk-edit fully safe against stale snapshots.
  2. Per-row results (a 207-style body) so a partial paste need not be all-or-nothing — optional; pre-validation covers most cases.
  3. Partial / merge payloads (true PATCH semantics) — 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.


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 EntityDataGridConnectedDataGrid (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.


FilePathPurpose
business-affiliate/[entityId]/route.tsarda-frontend-app/src/app/api/arda/business-affiliateBFF single-entity PUT proxy for vendors
business-affiliate/bulk/route.tsarda-frontend-app/src/app/api/arda/business-affiliateBFF atomic PUT …/bulk proxy for vendors
commit-pipeline moduleux-prototype (canary, ConnectedDataGrid)accumulate → flush → route-by-size → reconcile
FileChange
create-entity-data-grid.tsxRename to ConnectedDataGrid; add dataSource, onCommit, Omit-extend forwarding
data-grid.tsx (molecule)Surface onPasteEnd / onFillEnd so the container can flush
vendors/page.tsxMigrate from bare DataGrid to ConnectedDataGrid; supply data source + commit
  • Partial / merge (PATCH) payloads — tracked as backend evolution, not required.
  • The async upload-job path 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.

TestTargetValidates
route-by-size selects single vs bulkcommit pipeline1 row → PUT /{id}; N rows → one PUT /bulk
flush on paste/fill endcommit pipelineonPasteEnd/onFillEnd enqueue all affected rows
pre-validate drops invalid rowscommit pipelineinvalid valueParser results excluded from the batch
undo re-enters the pipelineConnectedDataGridCtrl+Z produces a commit with reverted values
owned props rejected at compile timeConnectedDataGridPropspassing rowData is a type error (Omit-extend)
TestSetupValidates
vendor bulk paste persists atomicallymock /business-affiliate/bulkone request; all rows updated or none; UI reconciles
BFF bulk proxy forwards auth headersBFF route testJWT → X-Author/X-Tenant-Id/X-oidc-subject

  • Phase 2: Standardized AG Grid — parent plan.
  • Design: Rich Cell Data Types — the value↔string round trip and valueParser validation 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), single PUT …/{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