Design: Rich Cell Data Types
Design: Rich Cell Data Types
Section titled “Design: Rich Cell Data Types”Status: Draft — for review
Created: 2026-05-21
Repository: ux-prototype (build), arda-frontend-app (vendor page consumer)
Parent: Phase 2: Standardized AG Grid
Resolves: Phase 2 Open Question #7 (cell editor architecture) and the Grouped/composite columns section.
Overview
Section titled “Overview”The vendor grid renders two “rich” columns today — Role (a list of tokens
like Vendor, Carrier) and Order Method (a single token like Online).
Both are currently hand-rolled as inline cellRenderer functions on the column
def. That works for display, but a cell renderer is cosmetic only: AG Grid
runs sorting, filtering, grouping, clipboard, fill-handle, and export against the
underlying value, not the rendered DOM. So the hand-rolled pills quietly break
copy/paste, set-filtering, and CSV/Excel export of those columns — and every new
rich column (color, image, address) re-introduces the same gap in a slightly
different way.
This design adopts AG Grid’s cell data type mechanism
(cellDataType + dataTypeDefinitions) as the single place where a rich cell
type is defined. A data type owns the value ↔ string round trip
(valueFormatter / valueParser), the filter/group key (keyCreator), and
— through a referenced column type — the renderer and editor. Because
copy, cut, paste, bulk paste, fill-down, range copy-down, and export are all
driven by that same formatter/parser pair, defining it once makes every one of
those behaviors consistent and automatic for every column of that type.
The scope here is the tokens family (Role and Order Method), built on a
shared TokenList presentational molecule and the existing typeahead cell
editors. A bonus section shows the identical architecture scaling to a
composite Address type — many inputs while editing, one line when rendered.
Image columns — the thumbnail renderer and the upload/preview editor —
follow the same recipe: the existing grid-image.tsx is another hand-rolled
one-off with the same copy/filter/export gap, and it migrates to an image
data type in a later slice, as does color. The sequencing for these and
other future types is captured in the
pre-defined data type roadmap below.
Decision Summary
Section titled “Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | Mechanism for reusable rich cells | cellDataType + dataTypeDefinitions (not a per-column renderer, not a partial-ColDef factory) |
| DQ-002 | Where the value↔string contract lives | In the data type: valueFormatter (value→string) + valueParser (string→value) |
| DQ-003 | How renderer + editor attach to a data type | Via a named columnTypes bundle the data type references (carries cellRenderer, cellEditor, keyCreator) |
| DQ-004 | Single-select vs multi-select tokens | One tokens family; multi = string[], single = string; same renderer, different editor + parser arity |
| DQ-005 | Composite columns (Address) | An object data type with a popup composite editor and a single-line renderer — same architecture as tokens |
| DQ-006 | Bulk paste / fill / export wiring | Rely on the data type’s formatter/parser by default; override per-grid only with processCell*FromClipboard for validation feedback |
The alternatives considered for each decision, with trade-offs and rationale, are recorded in the companion Decision Log; review questions from PR #99 are tracked there as Round 2 (DQ-007 – DQ-009).
Background: how AG Grid wires features (the key insight)
Section titled “Background: how AG Grid wires features (the key insight)”Before the decision, the one fact that drives everything: renderers are cosmetic, values are functional. From the AG Grid docs (v34.3.1):
The raw values, and not the result of cell renderer will get used [for export]. Value Getters will be used. Cell Renderers will NOT be used. Cell Formatters will be used by default.
and
Sorting and Filtering are NOT impacted by [rendering] … done before rendering.
So whatever makes a rich column “work” for clipboard/filter/export cannot live in the renderer. It must live on the value-level hooks. Each AG Grid feature maps to exactly one hook:
| Feature | Hook it uses | Direction |
|---|---|---|
| Copy / Cut / range copy / CSV / Excel / clipboard | valueFormatter (useValueFormatterForExport, default true) | value → string |
| Paste / bulk paste / fill-handle / copy-range-down | valueParser (useValueParserForImport, default true) | string → value |
| Set filter / grouping / in-editor search | keyCreator | value → key string |
| Sort / quick filter | the value (or valueFormatter) | — |
| Display | cellRenderer | value → DOM (cosmetic) |
| Edit | cellEditor | — |
The hand-rolled vendor renderers supply only the last two. That is why Role’s set filter and CSV export are broken today, and why the fix is not “make a nicer renderer” but “define the value hooks once, in a place every rich column shares.”
Push vs pull — who drives the value
Section titled “Push vs pull — who drives the value”The table above also settles a recurring architecture question: do we push
values into AG Grid, or does AG Grid pull them from us? AG Grid is a pull
(inversion-of-control) system, and the data-type design leans into that. You
push the source value into the grid exactly once — when you set rowData.
After that the grid owns the row model and pulls each representation by
calling the hooks above at the moment it needs them: cellRenderer to paint,
valueFormatter to copy/export, valueParser to paste/fill, keyCreator to
filter/group.
Pull is the correct paradigm because one source value (["Vendor","Carrier"])
must serve many consumers, each wanting a different representation at a different
time. Pull transforms the value on demand and stays consistent everywhere.
Pushing pre-computed representations instead — storing the display string,
the filter key, and the export text next to the value — forces you to anticipate
every consumer and resync them on every edit. That is exactly the drift this
design removes: the old hand-rolled renderer pushed cosmetic DOM but never
supplied the value hooks, so copy, filter, and export silently broke.
The only legitimate push is the initial rowData seed. Imperative writes
after that — mutating a row and calling api.refreshCells(...) — are an escape
hatch, acceptable only for a derived display field (e.g. a favicon recomputed
from other cells). Such a field sits outside the value round trip, so it will not
participate in copy/paste/export; if it ever needs to, promote it to a
pull-based valueGetter or its own data type rather than keeping the imperative
refresh.
The decision and why
Section titled “The decision and why”Three mechanisms can package a rich cell. They differ in how much of the table above they cover for free, consistently, across many columns.
| Mechanism | What it bundles | Copy/paste/bulk/fill/export consistency | Verdict |
|---|---|---|---|
Per-column cellRenderer/cellEditor (today) | renderer + editor on each column | ❌ none — each column must hand-wire formatter/parser/keyCreator or silently break | Rejected — this is the current drift |
Partial-ColDef factory (createTokenColumn(...)) | renderer + editor + keyCreator + formatter, returned as a ColDef fragment | ⚠️ possible, but the value-round-trip wiring is re-applied per factory; nothing forces colors/images/address factories to stay consistent | Rejected as the spine — useful only as sugar |
cellDataType + dataTypeDefinitions | formatter + parser + keyCreator + (via columnTypes) renderer + editor, registered once and applied by name | ✅ automatic — every column with cellDataType: 'x' inherits the entire round trip identically | Chosen (DQ-001) |
The deciding factor is the explicit Phase 2 goal: copy/paste, bulk copy/paste,
and bulk edits should behave the same way across every rich data type (tokens,
colors, images, address). Only the data-type mechanism makes the value↔string
round trip the unit of reuse — which is exactly the unit those features run on.
A factory can produce a correct column, but it cannot guarantee that the next
engineer’s createColorColumn parses and formats on the same contract; the data
type registry can, because the grid resolves behavior from the type name.
We still keep a thin factory (createTokenDataType) — but it produces a
data-type definition, not a loose ColDef. Sugar on top of the spine, not
instead of it.
Architecture
Section titled “Architecture”Three layers, each independently testable. Presentation is reusable outside the grid; the grid-specific contract is concentrated in the data-type registry.
The component view below shows the dependency direction: the vendor page selects
data types by name; the registry binds each type’s value hooks to a presentational
TokenList and a typeahead editor; the DataGrid registers the types and the
Enterprise clipboard/cell-selection modules with AG Grid.
| Layer | Lives in | Knows about the grid? | Examples |
|---|---|---|---|
| Presentation | canary/molecules/token-list/, canary/atoms/badge/ | No | TokenList, Badge |
| Editors (adapters) | canary/molecules/typeahead-input/ | The cellEditorMode contract only | createMultiSelectCellEditor, createTypeaheadCellEditor |
| Data-type registry | canary/molecules/data-grid/cell-data-types/ | Yes — owns the AG Grid contract | createTokenDataType, dataTypeDefinitions, columnTypes |
The editor-adapter layer is already documented in AG Grid Cell Editors from Inputs; this design adds the layer above it.
Worked example 1 — Role (multi-select tokens)
Section titled “Worked example 1 — Role (multi-select tokens)”Role is string[] — e.g. ["Vendor", "Carrier"]. It renders as a row of
filled pills and edits as a multi-select typeahead with checkboxes.
In read mode the cell shows one pill per role, collapsing to +N more when the
row is too narrow; double-click (or Enter) opens the multi-select editor with the
current roles pre-checked and a filter box.
The data type owns four things; the renderer and editor are bound through a named column type.
import type { ColTypeDef, DataTypeDefinition, KeyCreatorParams, ValueFormatterParams, ValueParserParams,} from 'ag-grid-community';import type { CustomCellRendererProps } from 'ag-grid-react';
import { TokenList } from '@/components/canary/molecules/token-list/token-list';import { createMultiSelectCellEditor, type MultiSelectCellEditorConfig,} from '@/components/canary/molecules/typeahead-input/multiselect-cell-editor';import { createTypeaheadCellEditor, type TypeaheadCellEditorConfig,} from '@/components/canary/molecules/typeahead-input/typeahead-cell-editor';
/** * The entire typeahead/multiselect surface passes straight through `editor` — * `lookup` (async fn OR static list), `maxResults`, `placeholder`, plus * `defaultOne` (multi) and `allowCreate` + `clearOnFocus` (single). The data * type adds the value round trip; it does not restrict the editor. */export type TokenDataTypeConfig = | { multiple: true; editor: MultiSelectCellEditorConfig; /** Badge variant: filled (multi) or outline (single), by convention. */ variant?: 'secondary' | 'outline'; /** Closed list for paste validation. Defaults to a static `editor.lookup`. */ validValues?: string[]; } | { multiple: false; editor: TypeaheadCellEditorConfig; variant?: 'secondary' | 'outline'; validValues?: string[]; };
/** value -> display string. Array joins with ", "; scalar passes through. */const toText = (value: unknown): string => Array.isArray(value) ? value.join(', ') : value == null ? '' : String(value);
export function createTokenDataType(config: TokenDataTypeConfig): { dataType: DataTypeDefinition; columnType: ColTypeDef;} { // Full passthrough — every typeahead/multiselect prop is honored. const editor = config.multiple ? createMultiSelectCellEditor(config.editor) : createTypeaheadCellEditor(config.editor);
// Paste/fill validation list: an explicit `validValues`, else a static // `lookup` array. With an async `lookup` function there is no synchronous // list, so paste shape-parses and option validation is deferred (see note). const closedList: string[] | undefined = config.validValues ?? (Array.isArray(config.editor.lookup) ? config.editor.lookup.map((o) => (typeof o === 'string' ? o : o.value)) : undefined);
// string -> value. When a closed list exists, junk is rejected. const parse = (text: string | null | undefined): string[] | string | null => { if (!text) return null; let parts = String(text).split(',').map((s) => s.trim()).filter(Boolean); if (closedList) parts = parts.filter((p) => closedList.includes(p)); if (parts.length === 0) return null; return config.multiple ? parts : parts[0]!; };
return { // Renderer + editor + filter/group key — bound to the data type by name. columnType: { cellRenderer: (p: CustomCellRendererProps) => { const values = Array.isArray(p.value) ? p.value : p.value ? [p.value] : []; return <TokenList values={values} variant={config.variant} />; }, cellEditor: editor, cellEditorPopup: false, // inline; the dropdown is portaled by the input itself keyCreator: (p: KeyCreatorParams) => toText(p.value), }, // The value round trip — drives copy/paste/fill/export for free. dataType: { baseDataType: 'object', extendsDataType: 'object', valueFormatter: (p: ValueFormatterParams) => toText(p.value), valueParser: (p: ValueParserParams) => parse(p.newValue), // `columnTypes` (the binding name) is assigned at registration — see below. }, };}Registration names the two halves so the data type can reference its column type. Consumers only write registry entries:
import { createTokenDataType } from '@arda-cards/design-system/canary';
const ROLE_OPTIONS = ['Vendor', 'Customer', 'Carrier', 'Operator', 'Other'];const ORDER_METHOD_OPTIONS = ['Online', 'Purchase order', 'Email', 'Phone', 'In store', 'RFQ', 'Production', '3rd party'];
const roles = createTokenDataType({ multiple: true, editor: { lookup: ROLE_OPTIONS, placeholder: 'Select roles…', defaultOne: true }, variant: 'secondary',});const orderMethod = createTokenDataType({ multiple: false, editor: { lookup: ORDER_METHOD_OPTIONS, placeholder: 'Order method…', maxResults: ORDER_METHOD_OPTIONS.length, // show the whole list clearOnFocus: true, }, variant: 'outline',});
export const columnTypes = { rolesColType: roles.columnType, orderMethodColType: orderMethod.columnType,};
export const dataTypeDefinitions = { roles: { ...roles.dataType, columnTypes: 'rolesColType' }, orderMethod: { ...orderMethod.dataType, columnTypes: 'orderMethodColType' },};The vendor column defs collapse to a single property each — no inline renderer, no per-column editor, no missing keyCreator:
const columnDefs: ColDef<VendorRow>[] = [ { field: 'name', headerName: 'Name', flex: 1, minWidth: 200, cellRenderer: VendorNameCell }, { field: 'roles', headerName: 'Role', width: 200, editable: true, cellDataType: 'roles' }, { field: 'orderMethod', headerName: 'Order Method', width: 180, editable: true, cellDataType: 'orderMethod' }, // …];The editor surface passes through untouched
Section titled “The editor surface passes through untouched”The data type holds whatever createTypeaheadCellEditor /
createMultiSelectCellEditor produce, so the entire input API is available
via editor — nothing is lost or hardcoded:
| Prop | Single (createTypeaheadCellEditor) | Multi (createMultiSelectCellEditor) |
|---|---|---|
lookup — async fn or static list | ✓ | ✓ |
maxResults | ✓ | ✓ |
placeholder | ✓ | ✓ |
clearOnFocus | ✓ | — |
allowCreate | ✓ | — |
defaultOne | — | ✓ |
An async lookup is just another editor.lookup — the round trip is unchanged:
const supplier = createTokenDataType({ multiple: false, editor: { lookup: lookupSuppliers, allowCreate: true, maxResults: 20, clearOnFocus: true }, // no static list -> paste shape-parses; option validation deferred (see below)});The one seam — paste validation vs. lookup. The editor can resolve options
asynchronously, but the data type’s valueParser runs synchronously during paste
and fill. So:
- Static
lookup(or explicitvalidValues) → the parser validates against the closed list and rejects junk, matching the Phase 2 “invalid input” spec. - Async
lookup(function) → the parser can only shape-parse (split/trim); option validation is deferred to commit (or a grid-levelprocessCellFromClipboard). The editor still does full async lookup — only mid-paste validation can’t await it.
valueFormatter and keyCreator are value-only, so they are unaffected by which
form lookup takes.
Worked example 2 — Order Method (single-select token)
Section titled “Worked example 2 — Order Method (single-select token)”Order Method is a single string (the vendor’s primary order method). It
renders as one outline pill, or — when empty, and edits as a single typeahead
that shows the full list on focus and clears the filter on focus
(clearOnFocus).
The two states below mirror example 1 with multiple: false: one pill in read
mode, a single-choice list in edit mode. The renderer is the same TokenList
(given a one-element array); only the editor and parser arity differ — the whole
point of DQ-004.
No new code is needed beyond the registry entry already shown — multiple: false
selects the typeahead editor, makes the parser return a scalar, and renders the
single pill via the shared TokenList. This is the consistency payoff: Role and
Order Method are one factory called twice.
The shared presentational molecule:
import { Badge } from '@/components/canary/atoms/badge/badge';import { cn } from '@/types/canary/utilities/utils';
export interface TokenListProps { values: string[]; variant?: 'secondary' | 'outline'; className?: string;}
/** * Read-mode display for token cells. One Badge per value, collapsing to * "+N more" when the row is too narrow (dynamic overflow reuses the * ResizeObserver measurer pattern from OverflowToolbar / MultiSelectTypeaheadInput). */export function TokenList({ values, variant = 'secondary', className }: TokenListProps) { if (values.length === 0) { return <span className="text-muted-foreground text-xs">—</span>; } return ( <div className={cn('flex h-full items-center gap-1 overflow-hidden', className)}> {values.map((v) => ( <Badge key={v} variant={variant} className="shrink-0 whitespace-nowrap"> {v} </Badge> ))} </div> );}Behavioral design — the round-trip flows
Section titled “Behavioral design — the round-trip flows”These flows are what the data type buys us. None of them have any column-specific
code; they all run through valueFormatter and valueParser.
Copy a cell
Section titled “Copy a cell”Copying a Role cell does not copy the pills’ DOM; AG Grid asks the data type’s
valueFormatter for a string and puts that on the clipboard.
Paste / bulk paste across a range
Section titled “Paste / bulk paste across a range”Pasting (one cell or a whole selected range) sends each clipboard string through
the data type’s valueParser. Invalid values are rejected by the parser and can
trigger validation feedback (Phase 2 spec: red ring + “invalid input” alert).
The salt mock shows a 3-row range selected in the Role column being pasted into
(»…« marks the selection); the same parsed value lands in every selected cell,
while the unselected row is untouched.
Fill-handle drag-down
Section titled “Fill-handle drag-down”Dragging the fill handle from one cell over a range copies the source value down,
re-parsing through the same contract. Configured by enabling
cellSelection.handle.mode: 'fill'.
Bulk edit via the editor
Section titled “Bulk edit via the editor”Selecting a range, opening the editor on one cell, and pressing Ctrl+Enter
applies the chosen value to every editable cell in the range — no extra code; the
editor commits a value and AG Grid spreads it.
DataGrid integration
Section titled “DataGrid integration”The canary DataGrid currently registers only AllCommunityModule +
RichSelectModule and does not forward data-type or cell-selection config. Three
small additions make the whole family work.
// data-grid.tsx — module registrationimport { ClipboardModule, CellSelectionModule, RichSelectModule } from 'ag-grid-enterprise';
ModuleRegistry.registerModules([ AllCommunityModule, RichSelectModule, ClipboardModule, // copy / cut / paste (Enterprise) CellSelectionModule, // range selection + fill handle (Enterprise)]);// data-grid.tsx — new optional props on DataGridStaticConfigimport type { CellSelectionOptions, ColTypeDef, DataTypeDefinition } from 'ag-grid-community';
/** Custom cell data type registry (e.g. tokens, address). */dataTypeDefinitions?: Record<string, DataTypeDefinition>;/** Named column-type bundles referenced by data types or `type`. */columnTypes?: Record<string, ColTypeDef>;/** Range selection + fill handle. `{ handle: { mode: 'fill' } }` enables fill-down. */cellSelection?: boolean | CellSelectionOptions;// data-grid.tsx — forwarded to AgGridReact (omit-when-undefined per exactOptionalPropertyTypes)<AgGridMemo /* …existing props… */ {...(dataTypeDefinitions ? { dataTypeDefinitions } : {})} {...(columnTypes ? { columnTypes } : {})} {...(cellSelection !== undefined ? { cellSelection } : {})}/>Consumer wiring (the vendor page):
import { columnTypes, dataTypeDefinitions } from './vendor-grid-types';
<DataGrid<VendorRow> rowData={rows} columnDefs={columnDefs} columnTypes={columnTypes} dataTypeDefinitions={dataTypeDefinitions} cellSelection={{ handle: { mode: 'fill' } }} editable/>Validation feedback on paste (optional, per-grid)
Section titled “Validation feedback on paste (optional, per-grid)”By default the parser silently drops invalid pasted values. To match the Phase 2
spec — flash the cell red and show an “invalid input” alert — add a grid-level
processCellFromClipboard that consults the column’s data type and vetoes the
value when it fails to parse. This is the only place clipboard behavior needs
custom code, and it is generic across all data types.
// data-grid.tsx — optional, enabled when a consumer passes onInvalidPasteimport type { ProcessCellForExportParams } from 'ag-grid-community';
const processCellFromClipboard = (p: ProcessCellForExportParams) => { const def = p.column.getColDef(); const parser = /* resolve dataTypeDefinitions[def.cellDataType]?.valueParser */; if (!parser) return p.value; // plain column — default behavior
const parsed = parser({ newValue: p.value, column: p.column, /* … */ } as never); if (parsed == null && p.value?.trim()) { flashCellInvalid(p.api, p.node, p.column); // red ring for ~1s onInvalidPaste?.(p.value); // consumer shows the alert banner return p.node?.data?.[def.field as keyof typeof p.node.data]; // keep old value } return parsed;};Bonus — Address (a composite object data type)
Section titled “Bonus — Address (a composite object data type)”Address is the proof that this architecture is not token-specific. An address
is structured data — { street, city, state, zip, country } — that should
render as one line but edit as several inputs, and still copy, paste, fill,
and export through the same contract.
It renders as a single formatted line; double-click opens a small multi-field form (a popup editor, because the form grows beyond the cell), and committing writes the structured object back.
One field, not many. This Address example assumes the row stores Address as a single field whose value is the
{ street, city, state, zip, country }object — that is what lets it be acellDataType. If instead the address lives as several sibling fields on the row (street,city,state, …), the gather/scatter across fields cannot be expressed by a data type —DataTypeDefinitionhas novalueGetter/valueSetter— and must be a ColDef factory that ownsvalueGetter(gather) andvalueSetter(scatter). The two cases look alike but use different mechanisms; the multi-field variant iscombined-column.tsxinux-prototype. The composite object’s value contract (valueFormatter/valueParser/keyCreator/ renderer/editor) is identical across the two, so the factory and a futurecreateCompositeObjectDataTypecan share that half.The same split answers joint editing of units — a quantity edited together with its unit of measure. Stored as one field (
{ amount: 12, unit: 'kg' }), it is a composite data type exactly like Address: one popup editor, cross-field validation (the unit constrains precision), one formatted line (12 kg) for copy/filter/export. Stored as siblingamount/unitrow fields, it is thecombined-column.tsxColDef-factory variant. See DQ-008 in the Decision Log.
The data type is structurally identical to createTokenDataType — only the
formatter/parser shapes and the editor change. Note cellEditorPopup: true,
since a multi-field form must escape the cell bounds while editing.
export interface Address { street: string; city: string; state: string; zip: string; country: string;}
const formatAddress = (a: Address | null | undefined): string => a ? [a.street, a.city, `${a.state} ${a.zip}`.trim(), a.country].filter(Boolean).join(', ') : '';
// Round-trips a single-line address; high-fidelity field-by-field copy can use a// stricter delimiter or processDataFromClipboard (see Out of Scope).const parseAddress = (text: string | null | undefined): Address | null => { if (!text) return null; const [street = '', city = '', stateZip = '', country = ''] = text.split(',').map((s) => s.trim()); const [state = '', zip = ''] = stateZip.split(/\s+/); return { street, city, state, zip, country };};
export const addressColumnType: ColTypeDef = { cellRenderer: (p: CustomCellRendererProps) => <span className="truncate">{formatAddress(p.value)}</span>, cellEditor: createAddressCellEditor({ /* state + country lookups, validation */ }), cellEditorPopup: true, // the form grows beyond the cell keyCreator: (p: KeyCreatorParams) => formatAddress(p.value),};
export const addressDataType: DataTypeDefinition = { baseDataType: 'object', extendsDataType: 'object', valueFormatter: (p) => formatAddress(p.value), valueParser: (p) => parseAddress(p.newValue), columnTypes: 'addressColType',};What each grid feature does with an Address, for free:
| Feature | Behavior |
|---|---|
| Copy | one line: 123 Main St, Austin, TX 78701, USA (valueFormatter) |
| Paste / bulk paste | parse the line back into the object, applied to every cell in range (valueParser) |
| Fill-down | copy the whole address object down the range |
| Bulk edit | open the form on one cell, Ctrl+Enter to apply the object across the range |
| Export | the formatted single line (CSV/Excel) |
| Set filter / group | bucket by the formatted address (or change keyCreator to group by city/state) |
| Sort | by the formatted string (or a comparator on a chosen field) |
The composite editor manages its own internal layout, focus order, and
cross-field validation (e.g. country constrains the state list) — exactly the
“form group / composite input” need called out in the Phase 2 plan. Because the
editor satisfies the same value + onValueChange + onCommit contract as a
single input, it slots into the same adapter described in
AG Grid Cell Editors from Inputs.
Pre-defined data type roadmap
Section titled “Pre-defined data type roadmap”The design system will ship a curated, growing set of pre-defined cell data types, added as they are needed and mature. AG Grid already provides five built-in data types — the library’s job for those is to adopt and configure them (formatting, locale, editors) rather than build from scratch; the rich types below them are built on the architecture in this design. Priority order:
| # | Data type | Kind | Status |
|---|---|---|---|
| 1 | Text | AG Grid built-in (text) | Available — default |
| 2 | Number (Decimal) | Built-in (number), configured for precision/locale | Available — needs config pass |
| 3 | Boolean | Built-in (boolean, checkbox renderer/editor) | Available — needs config pass |
| 4 | Date | Built-in (date / dateString) | Available — needs config pass |
| 5 | Tokens — single & multi | Custom (this design, first slice) | In design (this document) |
| 6 | Currency / Money | Custom — decimal + currency code, locale-formatted | Planned |
| 7 | Address | Custom composite (bonus section of this design) | Designed |
| 8 | Quantity + Unit | Custom composite — joint editing of amount and unit of measure (DQ-008) | Planned |
| 9 | Image | Custom — thumbnail renderer + upload/preview editor; migrates grid-image.tsx (DQ-007) | Planned |
| 10 | Color | Custom — swatch renderer + picker editor | Planned |
Each slice picks the next entry; a type is “shipped” when it has the full value round trip (formatter, parser, keyCreator), a renderer, an editor, stories, and round-trip unit tests per the Testing Strategy.
Implementation Scope
Section titled “Implementation Scope”Files to Create
Section titled “Files to Create”| File | Path | Purpose |
|---|---|---|
token-list.tsx | canary/molecules/token-list/ | Presentational pill list (composes Badge); dynamic +N more overflow |
token-list.test.tsx / .stories.tsx / .mdx | same | Tests, stories, prop docs |
token-data-type.tsx | canary/molecules/data-grid/cell-data-types/ | createTokenDataType factory (renderer + editor + formatter + parser + keyCreator) |
token-data-type.test.tsx | same | Round-trip unit tests (format/parse/keyCreator) |
cell-data-types/index.ts | same | Barrel + canary export |
Files to Modify
Section titled “Files to Modify”| File | Change |
|---|---|
canary/molecules/data-grid/data-grid.tsx | Register ClipboardModule + CellSelectionModule; add dataTypeDefinitions, columnTypes, cellSelection passthrough props; optional processCellFromClipboard + onInvalidPaste |
src/canary.ts | Export TokenList, createTokenDataType, related types |
arda-frontend-app/src/app/vendors/page.tsx | Replace inline Role/Order Method renderers + editors with cellDataType; add vendor-grid-types.ts; pass registries + cellSelection to DataGrid |
data-grid.stories.tsx | Add a WithCellDataTypes story demonstrating copy/paste/fill on tokens |
Out of Scope (first slice)
Section titled “Out of Scope (first slice)”- Color and image data types. The existing
grid-image.tsxis a hand-rolled one-off with the same gap; migrating it (and addingcolor) follows the same recipe but is a separate slice. - High-fidelity structured clipboard for Address (field-preserving paste from
external sources). The single-line round trip is in scope; richer transfer via
processDataFromClipboardis future work. - Address verification API and full cross-field validation rules.
- Undo/redo (
undoRedoCellEditing) — tracked separately in Phase 2.
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Target | Validates |
|---|---|---|
| format/parse round trip (multi) | createTokenDataType({multiple:true}) | ["Vendor","Carrier"] ⇄ "Vendor, Carrier" |
| format/parse round trip (single) | createTokenDataType({multiple:false}) | "Online" ⇄ "Online"; empty ⇒ null |
| parser rejects invalid options | token data type | "Vendor, Bogus" ⇒ ["Vendor"]; "Bogus" ⇒ null |
| keyCreator output | token data type | stable, human-readable key for set filter/grouping |
| TokenList render | TokenList | one badge per value; — when empty; variant applied |
| Address format/parse | formatAddress / parseAddress | object ⇄ single line |
Story / VRT (layout-dependent — jsdom has no layout engine)
Section titled “Story / VRT (layout-dependent — jsdom has no layout engine)”| Story | Validates |
|---|---|
WithCellDataTypes | edit lifecycle, +N more overflow, no vertical jump on edit |
| Copy → paste across a range | bulk paste applies parsed value to all cells |
| Fill-handle drag-down | source value extends down the range |
| Invalid paste | red flash + alert; old value retained |
API tests: not applicable (front-end only).
References
Section titled “References”- Phase 2: Standardized AG Grid — parent plan; resolves Open Question #7 and the composite-columns section.
- AG Grid Cell Editors from Inputs — the editor-adapter layer this design builds on.
- Design Document template — format conventions.
- Vendor Page and Standard Layout — project overview.
- AG Grid v34.3.1 docs: Cell Data Types, Value Formatters/Parsers (
useValueFormatterForExport,useValueParserForImport), Key Creator, Clipboard, Cell Selection (fill handle).
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved