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 }