Skip to content
Open
37 changes: 35 additions & 2 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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)',
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`)
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/jsonforms-renderers/docs/spreadsheet-formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
43 changes: 43 additions & 0 deletions packages/jsonforms-renderers/docs/spreadsheet-value-shape.md
Original file line number Diff line number Diff line change
@@ -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.
150 changes: 130 additions & 20 deletions packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
parseWorkbookToMatrix,
columnLetter,
processMatrix,
isRecordsSheet,
SheetParseError,
type CellValue,
type FormulaConfigEntry,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
<Box mb="4">
<Text as="label" size="2" weight="bold">
{label}
</Text>
<Text size="2" color="red" style={{ display: 'block' }}>
Invalid x-spreadsheet config: columnHeader and rowHeader cannot both be true — pick one orientation.
</Text>
</Box>
)
}

if (!canEdit && !hasValue) return null

const handleDrag = (e: DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -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 (
<Box mb="4">
Expand Down Expand Up @@ -321,23 +383,36 @@ const SpreadsheetControl = ({
<Box mt="3">
<Box style={{ overflow: 'auto', maxHeight: 420 }}>
<Table.Root variant="surface" size="1">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>{cornerLabel}</Table.ColumnHeaderCell>
{colIndices.map((c) => (
<Table.ColumnHeaderCell key={c}>
{columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)}
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
{!suppressColumnHeaderRow && (
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>{cornerLabel}</Table.ColumnHeaderCell>
{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).
<Table.ColumnHeaderCell key={c}>
{columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)}
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
)}
<Table.Body>
{visibleRows.map((row, r) => {
const actualRow = rowOffset + r
return (
<Table.Row key={r}>
<Table.RowHeaderCell>
{rowHeader ? formatCell(matrix[actualRow]?.[0]) : actualRow + 1}
<Table.RowHeaderCell
style={rowHeader ? { fontWeight: 700, background: 'var(--gray-a3)' } : undefined}
>
{/* 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}
</Table.RowHeaderCell>
{colIndices.map((c) => (
<Table.Cell key={c}>{formatCell(row[c])}</Table.Cell>
Expand All @@ -355,6 +430,41 @@ const SpreadsheetControl = ({
)}
</Box>
)}

{/* ── Records-shaped sheet preview — reload of an already-persisted,
columnHeader/rowHeader-shaped value; mutually exclusive with the
matrix preview above ── */}
{records && showSheet && (
<Box mt="3">
<Box style={{ overflow: 'auto', maxHeight: 420 }}>
<Table.Root variant="surface" size="1">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell />
{recordHeaders.map((key) => (
<Table.ColumnHeaderCell key={key}>{key}</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{visibleRecords.map((record, r) => (
<Table.Row key={r}>
<Table.RowHeaderCell>{r + 1}</Table.RowHeaderCell>
{recordHeaders.map((key) => (
<Table.Cell key={key}>{formatCell(record[key])}</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
{records.length > MAX_PREVIEW_ROWS && (
<Text size="1" color="gray" mt="1" style={{ display: 'block' }}>
Showing first {MAX_PREVIEW_ROWS} of {records.length} rows
</Text>
)}
</Box>
)}
{showStoredNote && (
<Text size="2" color="gray" mt="3" style={{ display: 'block' }}>
Sheet preview not stored for this field — re-upload to view contents.
Expand Down
5 changes: 3 additions & 2 deletions packages/jsonforms-renderers/src/utils/spreadsheet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,4 +16,5 @@ export type {
FormulaErrorCode,
DerivationResult,
SpreadsheetValue,
SheetData,
} from './types'
Loading
Loading