diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts
index 2662f23..ae15e42 100644
--- a/packages/jsonforms-renderers/dev/fixtures.ts
+++ b/packages/jsonforms-renderers/dev/fixtures.ts
@@ -432,13 +432,13 @@ export const fixtures: Fixture[] = [
type: 'object',
title: 'Sales Data',
description:
- 'Upload dev/sample-files/sales-data-sample.xlsx (regenerate via generate-sales-data-sample.cjs).',
+ "Upload dev/sample-files/sales-data-sample.xlsx (regenerate via generate-sales-data-sample.cjs). Since columnHeader is true, sales_data.sheet persists as one record per row (keyed by row 1's headers), not a raw matrix — see docs/spreadsheet-value-shape.md.",
'x-spreadsheet': {
accept: '.xlsx,.xls,.csv',
maxSize: 10485760,
persistSheet: true,
columnHeader: true,
- rowHeader: true,
+ rowHeader: false,
},
'x-evaluate': [{ id: 'total_quantity', label: 'Total Quantity', expression: '=SUM(D2:D4)' }],
properties: {
@@ -487,6 +487,39 @@ export const fixtures: Fixture[] = [
],
} as UISchemaElement,
},
+ {
+ id: 'spreadsheet-row-header',
+ name: 'Spreadsheet (rowHeader records)',
+ schema: {
+ type: 'object',
+ properties: {
+ quarterly_metrics: {
+ type: 'object',
+ title: 'Quarterly Metrics',
+ description:
+ "Upload dev/sample-files/quarterly-metrics-sample.xlsx (regenerate via generate-quarterly-metrics-sample.cjs) — column A holds each metric's name (row header), columns B-D hold one quarter each. With rowHeader: true and columnHeader: false, the persisted sheet is the TRANSPOSED records shape: each quarter becomes one record, keyed by column A's metric names — see docs/spreadsheet-value-shape.md. Contrast with 'Spreadsheet' and 'Computed Control (with Spreadsheet)', which both use columnHeader and persist one record per row instead.",
+ 'x-spreadsheet': {
+ accept: '.xlsx,.xls,.csv',
+ maxSize: 10485760,
+ persistSheet: true,
+ columnHeader: false,
+ rowHeader: true,
+ },
+ 'x-evaluate': [
+ { id: 'total_units_sold', label: 'Total Units Sold (all quarters)', expression: '=SUM(B2:D2)' },
+ ],
+ properties: {
+ sheet: { type: 'array' },
+ derivations: { type: 'object' },
+ },
+ },
+ },
+ } as unknown as JsonSchema,
+ uischema: {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/quarterly_metrics' }],
+ } as UISchemaElement,
+ },
{
id: 'array',
name: 'Array (objects)',
diff --git a/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs b/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs
new file mode 100644
index 0000000..07a4437
--- /dev/null
+++ b/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs
@@ -0,0 +1,25 @@
+// Regenerates quarterly-metrics-sample.xlsx — a small, generic
+// (industry-agnostic) "wide" metrics table used to manually test
+// SpreadsheetControl's rowHeader: true (transposed records) shaping in the
+// dev playground (see the 'spreadsheet-row-header' fixture in
+// dev/fixtures.ts). Column A holds each metric's name; every OTHER column
+// (one per quarter) becomes one persisted record when shaped. Run with:
+// node dev/sample-files/generate-quarterly-metrics-sample.cjs
+const { utils, writeFile } = require('@e965/xlsx')
+const path = require('node:path')
+
+// x-evaluate: total_units_sold = SUM(B2:D2) -> 120 + 150 + 200 = 470
+const rows = [
+ ['Metric', 'Q1', 'Q2', 'Q3'],
+ ['Units Sold', 120, 150, 200],
+ ['Returns', 5, 8, 6],
+ ['Net Units', 115, 142, 194],
+]
+
+const worksheet = utils.aoa_to_sheet(rows)
+const workbook = utils.book_new()
+utils.book_append_sheet(workbook, worksheet, 'Quarterly Metrics')
+
+const outPath = path.join(__dirname, 'quarterly-metrics-sample.xlsx')
+writeFile(workbook, outPath)
+console.log(`Wrote ${outPath}`)
diff --git a/packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx b/packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx
new file mode 100644
index 0000000..fb0e5cc
Binary files /dev/null and b/packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx differ
diff --git a/packages/jsonforms-renderers/docs/spreadsheet-formulas.md b/packages/jsonforms-renderers/docs/spreadsheet-formulas.md
index 54e0ceb..4dd60ba 100644
--- a/packages/jsonforms-renderers/docs/spreadsheet-formulas.md
+++ b/packages/jsonforms-renderers/docs/spreadsheet-formulas.md
@@ -23,7 +23,7 @@ Each entry's `id` is mandatory and doubles as the key `SpreadsheetControl` persi
Any schema describing this field's shape (see the `spreadsheet` fixture in `dev/fixtures.ts` for a full example) must declare `derivations` as `type: "object"`, not `"array"` — otherwise a successful, correctly-computed write fails AJV validation.
-Cell and range addressing is literal (`B2`, `I2:I6`), the same as in Excel — row 1 is always the sheet's first row, column A is always the sheet's first column, regardless of the control's `columnHeader`/`rowHeader` display options.
+Cell and range addressing is literal (`B2`, `I2:I6`), the same as in Excel — row 1 is always the sheet's first row, column A is always the sheet's first column, regardless of the control's `columnHeader`/`rowHeader` display options. Those same two options also control what shape the persisted `sheet` itself ends up in (raw matrix vs. an array of records) — see [spreadsheet-value-shape.md](./spreadsheet-value-shape.md).
Formulas are parsed and evaluated by [`fast-formula-parser`](https://www.npmjs.com/package/fast-formula-parser), backfilled with [`@formulajs/formulajs`](https://www.npmjs.com/package/formulajs) for functions the former only stubs out or gets wrong for our data (both MIT licensed).
diff --git a/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md
new file mode 100644
index 0000000..21b23ad
--- /dev/null
+++ b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md
@@ -0,0 +1,43 @@
+# Persisted sheet shape (`sheet`: matrix vs records)
+
+`SpreadsheetControl` persists `{ sheet, derivations }` (see [spreadsheet-formulas.md](./spreadsheet-formulas.md) for `derivations`/formula details, and [computed-fields.md](./computed-fields.md) for how a sibling field addresses one). `sheet` itself can take one of two shapes, driven entirely by the existing `x-spreadsheet.columnHeader`/`rowHeader` options — no separate switch:
+
+```json
+"x-spreadsheet": { "columnHeader": true }
+```
+
+- **Neither flag set (default):** `sheet` is the raw, address-preserving matrix — an array of rows, each a plain `CellValue[]`. Unchanged from before this shape existed.
+- **`columnHeader: true`:** row 1 is treated as each column's field name; every row after it becomes one record. For a header of `Item, Qty`, uploading `Widget, 10` / `Gadget, 20` persists:
+ ```json
+ [
+ { "Item": "Widget", "Qty": 10 },
+ { "Item": "Gadget", "Qty": 20 }
+ ]
+ ```
+- **`rowHeader: true`** (and `columnHeader` not also set): transposed — column A is treated as each row's field name; every _other_ column becomes one record. For a sheet with `Metric` down column A (`Revenue`, `Cost`) and `Q1`/`Q2` across row 1, this persists one record per quarter:
+ ```json
+ [
+ { "Metric": "Q1", "Revenue": 100, "Cost": 40 },
+ { "Metric": "Q2", "Revenue": 120, "Cost": 55 }
+ ]
+ ```
+- **Both set is rejected outright** — `SpreadsheetControl` shows a configuration-error message instead of rendering anything (no preview, no corner label), and the underlying `shapeSheet` helper throws if called directly. Each orientation is independently meaningful in real usage, so there's no safe default to silently pick; a schema author must choose exactly one.
+
+**`x-evaluate` formula addressing is completely unaffected.** `B2`, `SUM(I2:I6)`, etc. always address the original, unshaped matrix — shaping only happens afterward, when building the persisted value.
+
+## Detecting which shape you have
+
+There's no stored discriminant field. Told apart at read time the same way `SpreadsheetControl` itself does — `Array.isArray(sheet[0])`: `true` for a matrix (each row is itself an array), `false` for records (each entry is a plain object). Exported as `isRecordsSheet` from `utils/spreadsheet`. An empty persisted sheet (`[]`) reads as records under this check — harmless, since there's nothing to render either way.
+
+## Duplicate keys
+
+A duplicate header value (`columnHeader`) or duplicate column-A value (`rowHeader`) collides last-write-wins in every affected record — the same convention already used for a duplicate `x-evaluate` id colliding in the `derivations` map. Not flagged as an error either place; ids/headers are schema-author-controlled.
+
+A blank/null header (or column-A) cell contributes no key at all, rather than a stringified `"null"`/`""` — that column (or row, for `rowHeader`) is simply absent from every record.
+
+## Special keys
+
+Unlike a schema-author-controlled `x-evaluate` id, a header/column-A value comes from whatever's in the uploaded file, so two edge cases get handled explicitly rather than assumed away:
+
+- **A header value of `"__proto__"` is a safe, real key.** Each record is built with `Object.create(null)`, not `{}` — a plain object's inherited `__proto__` setter would otherwise intercept the write and change the record's _prototype_ instead of creating an enumerable own property, silently dropping that column from `Object.keys`/`Object.entries` and from JSON serialization (the same fix already applied to `processMatrix`'s `derivations` accumulator).
+- **A `Date` header/column-A value keys deterministically.** Keys are built via `date.toISOString()` for `Date` cells, not `String(date)` — the latter renders in the _local_ time zone, which would make the same uploaded file produce different record keys depending on which time zone the uploading browser is in.
diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx
index b7acdc5..68aa623 100644
--- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx
+++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx
@@ -11,6 +11,7 @@ import {
parseWorkbookToMatrix,
columnLetter,
processMatrix,
+ isRecordsSheet,
SheetParseError,
type CellValue,
type FormulaConfigEntry,
@@ -24,9 +25,19 @@ interface XSpreadsheetOptions {
maxSize?: number
/** Include the parsed sheet in the persisted value, so it survives a reload. Default true. */
persistSheet?: boolean
- /** Use row 1's values as column labels instead of A/B/C. Default false. */
+ /**
+ * Use row 1's values as column labels in the preview, AND persist `sheet`
+ * as one record per data row, keyed by those labels, instead of a raw
+ * matrix. Default false. Cannot be combined with rowHeader — see
+ * docs/spreadsheet-value-shape.md.
+ */
columnHeader?: boolean
- /** Use column A's values as row labels instead of 1/2/3. Default false. */
+ /**
+ * Use column A's values as row labels in the preview, AND (only when
+ * columnHeader is not also set) persist `sheet` as one record per OTHER
+ * column, transposed, keyed by those labels. Default false. Cannot be
+ * combined with columnHeader — see docs/spreadsheet-value-shape.md.
+ */
rowHeader?: boolean
/** Render the grid preview at all. Default true — set false to show only computed values. */
showSheet?: boolean
@@ -101,9 +112,18 @@ const SpreadsheetControl = ({
// The grid always renders from localMatrix first — so even with
// persistSheet: false, a fresh upload shows immediately this session, even
- // though it won't survive a reload.
- const matrix = localMatrix ?? value?.sheet ?? null
+ // though it won't survive a reload. localMatrix (this session's own
+ // freshly parsed upload) is always matrix-shaped — shaping only happens
+ // when building the PERSISTED value — so a fresh upload always renders via
+ // the matrix branch this session; only a reload (no localMatrix, reading
+ // the already-shaped persisted value back) can render via the records
+ // branch below. Told apart via isRecordsSheet, not a stored field.
+ const persistedSheet = value?.sheet ?? null
+ const matrix = localMatrix ?? (persistedSheet && !isRecordsSheet(persistedSheet) ? persistedSheet : null)
+ const records = localMatrix == null && persistedSheet && isRecordsSheet(persistedSheet) ? persistedSheet : null
const derivations = value?.derivations ?? {}
+ // records != null always implies value != null (records is derived only
+ // from value?.sheet), so this already covers the records case too.
const hasValue = matrix != null || value != null
const processFile = useCallback(
@@ -144,17 +164,42 @@ const SpreadsheetControl = ({
}
setLocalMatrix(parsedMatrix)
- const value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet })
+
+ let value: SpreadsheetValue
+ try {
+ value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet, columnHeader, rowHeader })
+ } catch (err) {
+ setStatus('error')
+ setError(err instanceof Error ? err.message : 'Failed to process the uploaded spreadsheet.')
+ return
+ }
handleChange(path, value)
setStatus('ready')
},
- [accept, maxSize, persistSheet, sheetName, xEvaluate, path, handleChange],
+ [accept, maxSize, persistSheet, columnHeader, rowHeader, sheetName, xEvaluate, path, handleChange],
)
if (visible === false) {
return null
}
+ // columnHeader and rowHeader are independently meaningful for the persisted
+ // records shape (see shapeSheet in utils/spreadsheet/process.ts) — neither
+ // one is a safe default when both are set, so this is rejected outright
+ // rather than silently picking a winner.
+ if (columnHeader && rowHeader) {
+ return (
+
+
+ {label}
+
+
+ Invalid x-spreadsheet config: columnHeader and rowHeader cannot both be true — pick one orientation.
+
+
+ )
+ }
+
if (!canEdit && !hasValue) return null
const handleDrag = (e: DragEvent) => {
@@ -199,8 +244,25 @@ const SpreadsheetControl = ({
Math.max(0, ...visibleRows.map((row) => Math.max(0, row.length - colOffset))),
)
const colIndices = Array.from({ length: colCount }, (_, i) => i + colOffset)
- const showStoredNote = showSheet && matrix == null && value != null
- const cornerLabel = columnHeader && rowHeader ? formatCell(matrix?.[0]?.[0]) : ''
+ const showStoredNote = showSheet && matrix == null && records == null && value != null
+ // columnHeader && rowHeader together is rejected above, so there's never a
+ // meaningful corner cell to show here — the header row/column consumes it.
+ const cornerLabel = ''
+ // rowHeader alone means every cell in the column-header row below is blank
+ // (see its own comment) — the whole row would just be dead space, so skip
+ // it entirely and lean on the row-label column's distinct styling instead.
+ const suppressColumnHeaderRow = rowHeader && !columnHeader
+
+ // records-mode preview: no row/col "offset" concept (shapeSheet already
+ // consumed the header row/column when building each record) — just the
+ // same MAX_PREVIEW_ROWS/COLS truncation the matrix branch above uses.
+ // Headers are the union of all VISIBLE records' own keys, not just the
+ // first record's — every record shapeSheet itself produces has an
+ // identical key set, so this only matters for a hand-supplied `data`
+ // value with non-uniform records (sheet crosses a serialization boundary
+ // — it isn't guaranteed to have been produced by shapeSheet).
+ const visibleRecords = records ? records.slice(0, MAX_PREVIEW_ROWS) : []
+ const recordHeaders = Array.from(new Set(visibleRecords.flatMap((r) => Object.keys(r)))).slice(0, MAX_PREVIEW_COLS)
return (
@@ -321,23 +383,36 @@ const SpreadsheetControl = ({
-
-
- {cornerLabel}
- {colIndices.map((c) => (
-
- {columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)}
-
- ))}
-
-
+ {!suppressColumnHeaderRow && (
+
+
+ {cornerLabel}
+ {colIndices.map((c) => (
+ // This row only renders when rowHeader isn't the sole
+ // flag set (see suppressColumnHeaderRow) — so reaching
+ // here, either columnHeader is true (real labels) or
+ // both are false (columnLetter fallback).
+
+ {columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)}
+
+ ))}
+
+
+ )}
{visibleRows.map((row, r) => {
const actualRow = rowOffset + r
return (
-
- {rowHeader ? formatCell(matrix[actualRow]?.[0]) : actualRow + 1}
+
+ {/* columnHeader alone means these are shaped into
+ records keyed by row 1 — a 1/2/3 fallback number
+ here isn't a real label, so it's suppressed (the
+ column-header row above stays, since it's real
+ labels there, not suppressed). */}
+ {rowHeader ? formatCell(matrix[actualRow]?.[0]) : columnHeader ? '' : actualRow + 1}
{colIndices.map((c) => (
{formatCell(row[c])}
@@ -355,6 +430,41 @@ const SpreadsheetControl = ({
)}
)}
+
+ {/* ── Records-shaped sheet preview — reload of an already-persisted,
+ columnHeader/rowHeader-shaped value; mutually exclusive with the
+ matrix preview above ── */}
+ {records && showSheet && (
+
+
+
+
+
+
+ {recordHeaders.map((key) => (
+ {key}
+ ))}
+
+
+
+ {visibleRecords.map((record, r) => (
+
+ {r + 1}
+ {recordHeaders.map((key) => (
+ {formatCell(record[key])}
+ ))}
+
+ ))}
+
+
+
+ {records.length > MAX_PREVIEW_ROWS && (
+
+ Showing first {MAX_PREVIEW_ROWS} of {records.length} rows
+
+ )}
+
+ )}
{showStoredNote && (
Sheet preview not stored for this field — re-upload to view contents.
diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts
index 523fdcc..728ab37 100644
--- a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts
+++ b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts
@@ -5,8 +5,8 @@ export {
evaluateFormulaWithVariables,
describeFormulaError,
} from './expression'
-export { processMatrix } from './process'
-export type { ProcessMatrixOptions } from './process'
+export { processMatrix, shapeSheet, isRecordsSheet } from './process'
+export type { ProcessMatrixOptions, ShapeSheetOptions } from './process'
export type {
CellValue,
Matrix,
@@ -16,4 +16,5 @@ export type {
FormulaErrorCode,
DerivationResult,
SpreadsheetValue,
+ SheetData,
} from './types'
diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts
new file mode 100644
index 0000000..589b6b0
--- /dev/null
+++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it } from 'vitest'
+import { isRecordsSheet, processMatrix, shapeSheet } from './process'
+import type { CellValue } from './types'
+
+describe('shapeSheet', () => {
+ it('returns the matrix unchanged when neither flag is set', () => {
+ const matrix: CellValue[][] = [
+ ['Item', 'Qty'],
+ ['Widget', 10],
+ ]
+ expect(shapeSheet(matrix)).toBe(matrix)
+ })
+
+ describe('columnHeader', () => {
+ it('turns each data row into one record keyed by row 1', () => {
+ const matrix: CellValue[][] = [
+ ['Item', 'Qty'],
+ ['Widget', 10],
+ ['Gadget', 20],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([
+ { Item: 'Widget', Qty: 10 },
+ { Item: 'Gadget', Qty: 20 },
+ ])
+ })
+
+ it('yields an empty array for a header-only sheet (no data rows)', () => {
+ expect(shapeSheet([['Item', 'Qty']], { columnHeader: true })).toEqual([])
+ })
+
+ it('yields an empty array for an empty matrix', () => {
+ expect(shapeSheet([], { columnHeader: true })).toEqual([])
+ })
+
+ it('drops a null/blank header cell instead of stringifying it to a key', () => {
+ const matrix: CellValue[][] = [
+ ['A', '', 'C'],
+ ['1', '2', '3'],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: '1', C: '3' }])
+ })
+
+ it('fills a ragged row shorter than the header with null for its missing keys', () => {
+ const matrix: CellValue[][] = [
+ ['A', 'B', 'C'],
+ ['x', 'y'],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: 'x', B: 'y', C: null }])
+ })
+
+ it('drops a data row longer than the header instead of keeping its unheaded cells', () => {
+ const matrix: CellValue[][] = [
+ ['A', 'B'],
+ ['x', 'y', 'z'],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: 'x', B: 'y' }])
+ })
+
+ it('a duplicate header value collides last-write-wins', () => {
+ const matrix: CellValue[][] = [
+ ['A', 'A'],
+ ['1', '2'],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: '2' }])
+ })
+
+ it('stringifies a non-string header cell with plain String(), not locale-aware formatting', () => {
+ const matrix: CellValue[][] = [
+ [100, true],
+ ['x', 'y'],
+ ]
+ expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ '100': 'x', true: 'y' }])
+ })
+
+ it('a header value of "__proto__" is a real, enumerable own key, not a prototype override', () => {
+ const matrix: CellValue[][] = [
+ ['__proto__', 'B'],
+ ['x', 'y'],
+ ]
+ const [record] = shapeSheet(matrix, { columnHeader: true }) as Record[]
+ expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true)
+ expect(Object.keys(record)).toEqual(['__proto__', 'B'])
+ expect(JSON.parse(JSON.stringify(record))).toEqual({ ['__proto__']: 'x', B: 'y' })
+ })
+
+ it('keys a Date header cell by its fixed ISO instant, not the locale-dependent String(date)', () => {
+ const date = new Date(Date.UTC(2026, 0, 15, 12, 30))
+ const matrix: CellValue[][] = [
+ [date, 'B'],
+ ['x', 'y'],
+ ]
+ const [record] = shapeSheet(matrix, { columnHeader: true }) as Record[]
+ expect(Object.keys(record)).toEqual([date.toISOString(), 'B'])
+ })
+ })
+
+ describe('rowHeader (columnHeader not set)', () => {
+ it('turns each OTHER column into one record keyed by column A, transposed', () => {
+ const matrix: CellValue[][] = [
+ ['Metric', 'Q1', 'Q2'],
+ ['Revenue', 100, 120],
+ ['Cost', 40, 55],
+ ]
+ expect(shapeSheet(matrix, { rowHeader: true })).toEqual([
+ { Metric: 'Q1', Revenue: 100, Cost: 40 },
+ { Metric: 'Q2', Revenue: 120, Cost: 55 },
+ ])
+ })
+
+ it('yields an empty array when there is only a column A and no other columns', () => {
+ expect(shapeSheet([['Metric'], ['Revenue'], ['Cost']], { rowHeader: true })).toEqual([])
+ })
+
+ it('yields an empty array for an empty matrix', () => {
+ expect(shapeSheet([], { rowHeader: true })).toEqual([])
+ })
+
+ it('drops a null/blank column-A cell instead of stringifying it to a key', () => {
+ const matrix: CellValue[][] = [
+ ['Metric', 'Q1'],
+ ['Revenue', 100],
+ ['', 999],
+ ['Cost', 40],
+ ]
+ expect(shapeSheet(matrix, { rowHeader: true })).toEqual([{ Metric: 'Q1', Revenue: 100, Cost: 40 }])
+ })
+
+ it('fills a short row with null for the missing column', () => {
+ const matrix: CellValue[][] = [
+ ['Metric', 'Q1', 'Q2'],
+ ['Revenue', 100],
+ ]
+ expect(shapeSheet(matrix, { rowHeader: true })).toEqual([
+ { Metric: 'Q1', Revenue: 100 },
+ { Metric: 'Q2', Revenue: null },
+ ])
+ })
+
+ it('a duplicate column-A value collides last-write-wins', () => {
+ const matrix: CellValue[][] = [
+ ['Metric', 'Q1'],
+ ['Revenue', 100],
+ ['Revenue', 200],
+ ]
+ expect(shapeSheet(matrix, { rowHeader: true })).toEqual([{ Metric: 'Q1', Revenue: 200 }])
+ })
+
+ it('a column-A value of "__proto__" is a real, enumerable own key, not a prototype override', () => {
+ const matrix: CellValue[][] = [
+ ['__proto__', 'Q1'],
+ ['Revenue', 100],
+ ]
+ const [record] = shapeSheet(matrix, { rowHeader: true }) as Record[]
+ expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true)
+ expect(Object.keys(record)).toEqual(['__proto__', 'Revenue'])
+ expect(JSON.parse(JSON.stringify(record))).toEqual({ ['__proto__']: 'Q1', Revenue: 100 })
+ })
+
+ it('keys a Date column-A cell by its fixed ISO instant, not the locale-dependent String(date)', () => {
+ const date = new Date(Date.UTC(2026, 0, 15, 12, 30))
+ const matrix: CellValue[][] = [
+ [date, 'Q1'],
+ ['Revenue', 100],
+ ]
+ const [record] = shapeSheet(matrix, { rowHeader: true }) as Record[]
+ expect(Object.keys(record)).toEqual([date.toISOString(), 'Revenue'])
+ })
+ })
+
+ it('throws when both columnHeader and rowHeader are true, rather than picking a winner', () => {
+ const matrix: CellValue[][] = [
+ ['A', 'B'],
+ ['1', '2'],
+ ]
+ expect(() => shapeSheet(matrix, { columnHeader: true, rowHeader: true })).toThrow(
+ /columnHeader and rowHeader cannot both be true/,
+ )
+ })
+})
+
+describe('isRecordsSheet', () => {
+ it('is false for a matrix', () => {
+ expect(
+ isRecordsSheet([
+ ['a', 'b'],
+ ['c', 'd'],
+ ]),
+ ).toBe(false)
+ })
+
+ it('is true for records', () => {
+ expect(isRecordsSheet([{ a: 1 }, { a: 2 }])).toBe(true)
+ })
+
+ it('is true for an empty sheet (documented, unavoidable tie-break)', () => {
+ expect(isRecordsSheet([])).toBe(true)
+ })
+})
+
+describe('processMatrix records-shaping integration', () => {
+ const matrix: CellValue[][] = [
+ ['Item', 'Qty'],
+ ['Widget', 10],
+ ['Gadget', 20],
+ ]
+
+ it('shapes sheet as records while derivations still reflect the raw, unshaped matrix', async () => {
+ const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }], {
+ columnHeader: true,
+ })
+ expect(result.sheet).toEqual([
+ { Item: 'Widget', Qty: 10 },
+ { Item: 'Gadget', Qty: 20 },
+ ])
+ expect(result.derivations).toEqual({ total: { label: 'Total', value: 30 } })
+ })
+
+ it('omits sheet entirely when persistSheet is false, even with a header flag set', async () => {
+ const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }], {
+ persistSheet: false,
+ columnHeader: true,
+ })
+ expect(result).not.toHaveProperty('sheet')
+ })
+
+ it('stays matrix-shaped when neither header flag is set (backward-compat regression guard)', async () => {
+ const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }])
+ expect(Array.isArray(result.sheet)).toBe(true)
+ expect(Array.isArray((result.sheet as CellValue[][])[0])).toBe(true)
+ })
+})
diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts
index cf304dc..aa277f3 100644
--- a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts
+++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts
@@ -1,10 +1,112 @@
import { evaluateExpressions } from './expression'
-import type { CellValue, DerivationResult, FormulaConfigEntry, SpreadsheetValue } from './types'
+import type { CellValue, DerivationResult, FormulaConfigEntry, SheetData, SpreadsheetValue } from './types'
-export interface ProcessMatrixOptions {
+export interface ShapeSheetOptions {
+ columnHeader?: boolean
+ rowHeader?: boolean
+}
+
+export interface ProcessMatrixOptions extends ShapeSheetOptions {
persistSheet?: boolean
}
+// Shapes a raw matrix into what gets persisted as `sheet`. Never touches
+// formula evaluation — only ever called when building the PERSISTED value,
+// after derivations are already computed against the original, unshaped
+// matrix. Both flags true is a schema-authoring error, not a "pick a
+// winner" situation — each orientation is independently meaningful in real
+// usage, so silently guessing would silently discard the other.
+//
+// Same format-agnostic contract as processMatrix below (see its own
+// comment): this operates purely on the normalized CellValue[][] matrix,
+// never on the original file format. A future XML (or any other) upload
+// path only needs to produce that same matrix shape — with, if it wants
+// records-shaping, a real header row/column at position 0 in it, the same
+// convention columnHeader/rowHeader already use for the preview table
+// today — and this function works on it completely unchanged. Not adding
+// any further pluggability (e.g. an injectable key-extraction strategy)
+// ahead of that actually existing: the one real assumption here (keys come
+// from row/column 0 of the matrix) is already the existing convention, not
+// a new one, and speculatively generalizing further for a format that
+// doesn't exist yet isn't worth it until its actual needs are known.
+export function shapeSheet(matrix: CellValue[][], options: ShapeSheetOptions = {}): SheetData {
+ if (options.columnHeader && options.rowHeader) {
+ throw new Error(
+ 'x-spreadsheet: columnHeader and rowHeader cannot both be true — pick one orientation for the persisted sheet shape.',
+ )
+ }
+ if (options.columnHeader) return rowsToRecords(matrix)
+ if (options.rowHeader) return columnsToRecords(matrix)
+ return matrix
+}
+
+// Fixed, timezone-independent stringification for a header/column-A cell.
+// String(date)/date.toString() renders in the LOCAL time zone, which would
+// make the persisted record's KEYS vary depending on which time zone the
+// uploading browser is in; date.toISOString() (fixed, UTC) doesn't. Only
+// display formatting (formatCell, SpreadsheetControl.tsx) is allowed to be
+// locale-aware — persisted keys must be deterministic.
+function cellKey(cell: CellValue): string | null {
+ if (cell == null || cell === '') return null
+ if (cell instanceof Date) return cell.toISOString()
+ return String(cell)
+}
+
+// columnHeader: row 1 = keys, every row after it = one record. A blank/null
+// header cell contributes no key (that column is absent from every record,
+// not present under a stringified "null"/""). A data row shorter than the
+// header fills missing trailing values with null; a row longer than the
+// header silently drops its unheaded trailing cells. A duplicate header
+// value collides last-write-wins (mirrors the existing duplicate x-evaluate
+// id precedent in processMatrix below).
+function rowsToRecords(matrix: CellValue[][]): Record[] {
+ const [headerRow, ...bodyRows] = matrix
+ if (!headerRow) return []
+ const keys = headerRow.map(cellKey)
+ return bodyRows.map((row) => {
+ // Object.create(null), not {} — a header cell of "__proto__" would
+ // otherwise set the record's prototype instead of creating an
+ // enumerable own property, silently dropping that column from
+ // Object.keys/entries and JSON serialization. Same fix already applied
+ // to processMatrix's derivations accumulator below (#32) — worse here
+ // since these keys come from the uploaded file, not a schema-author-
+ // controlled x-evaluate id.
+ const record: Record = Object.create(null)
+ keys.forEach((key, i) => {
+ if (key == null) return
+ record[key] = row[i] ?? null
+ })
+ return record
+ })
+}
+
+// rowHeader: column A = keys (on every row), every OTHER column = one
+// record, transposed. Column A is fully consumed as the key source,
+// symmetric with how row 1 is fully consumed above. Same blank/duplicate
+// handling as rowsToRecords, transposed.
+function columnsToRecords(matrix: CellValue[][]): Record[] {
+ const keys = matrix.map((row) => cellKey(row[0]))
+ const width = Math.max(0, ...matrix.map((row) => row.length))
+ const colCount = Math.max(0, width - 1)
+ return Array.from({ length: colCount }, (_, i) => {
+ const col = i + 1
+ const record: Record = Object.create(null)
+ keys.forEach((key, r) => {
+ if (key == null) return
+ record[key] = matrix[r][col] ?? null
+ })
+ return record
+ })
+}
+
+// Told apart at read time, not via a stored field: a matrix row is itself
+// an array; a record is a plain object. Array.isArray(undefined) is false,
+// so an empty persisted sheet ([]) reads as "records" — harmless, since
+// SpreadsheetControl renders zero rows for an empty sheet either way.
+export function isRecordsSheet(sheet: SheetData): sheet is Record[] {
+ return !Array.isArray(sheet[0])
+}
+
// Matrix-in, persisted-value-out. Deliberately format-agnostic: doesn't care
// whether the matrix came from an xlsx/csv upload or (in the future) an XML
// one — this is the reusable seam for both.
@@ -25,5 +127,6 @@ export async function processMatrix(
for (const { id, label, value, error } of results) {
derivations[id] = error === undefined ? { label, value } : { label, value, error }
}
- return options.persistSheet !== false ? { sheet: matrix, derivations } : { derivations }
+ if (options.persistSheet === false) return { derivations }
+ return { sheet: shapeSheet(matrix, options), derivations }
}
diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts
index 1ac947b..c1c4036 100644
--- a/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts
+++ b/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts
@@ -39,6 +39,13 @@ export interface DerivationResult {
error?: string
}
+// Either shape a persisted `sheet` can take: a raw, address-preserving
+// matrix (default), or an array of plain objects shaped by
+// utils/spreadsheet/process.ts's shapeSheet when x-spreadsheet.columnHeader
+// or .rowHeader is set at upload time. Told apart at read time, not via a
+// stored discriminant field — see process.ts's isRecordsSheet.
+export type SheetData = CellValue[][] | Record[]
+
// Persisted value shape for SpreadsheetControl — the field's data is an
// object, not a string key. No `fileName`: there's no storage service to
// name a retrievable file for. `derivations` is keyed by each x-evaluate
@@ -48,6 +55,6 @@ export interface DerivationResult {
// anywhere. Shared here (rather than kept private to SpreadsheetControl.tsx)
// so sibling renderers, like ComputedControl, can read it with the same type.
export interface SpreadsheetValue {
- sheet?: CellValue[][]
+ sheet?: SheetData
derivations: Record
}