diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 9e8758a..2662f23 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -382,6 +382,111 @@ export const fixtures: Fixture[] = [ elements: [{ type: 'Control', scope: '#/properties/budget' }], } as UISchemaElement, }, + { + id: 'computed-control', + name: 'Computed Control', + schema: { + type: 'object', + properties: { + price_per_kg: { type: 'number', title: 'Price per KG' }, + quantity_kg: { type: 'number', title: 'Quantity (KG)' }, + discount: { + type: 'number', + title: 'Discount', + description: 'Manually entered, optional — x-computed defaults this to 0 when left blank.', + }, + total_value: { + type: 'number', + title: 'Total Value', + description: + 'price * quantity - discount, via x-computed reading three plain sibling fields (no spreadsheet involved). Note: aliases must not be 1-3 letter all-alphabetic names like "qty" — see docs/computed-fields.md, they collide with the formula engine\'s own spreadsheet-column tokens.', + 'x-computed': { + inputs: { + price: 'price_per_kg', + quantity: 'quantity_kg', + discount_amount: { path: 'discount', default: 0 }, + }, + formula: 'price * quantity - discount_amount', + decimals: 2, + }, + }, + }, + } as unknown as JsonSchema, + uischema: { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/price_per_kg' }, + { type: 'Control', scope: '#/properties/quantity_kg' }, + { type: 'Control', scope: '#/properties/discount' }, + { type: 'Control', scope: '#/properties/total_value' }, + ], + } as UISchemaElement, + }, + { + id: 'computed-control-with-spreadsheet', + name: 'Computed Control (with Spreadsheet)', + schema: { + type: 'object', + properties: { + sales_data: { + type: 'object', + title: 'Sales Data', + description: + 'Upload dev/sample-files/sales-data-sample.xlsx (regenerate via generate-sales-data-sample.cjs).', + 'x-spreadsheet': { + accept: '.xlsx,.xls,.csv', + maxSize: 10485760, + persistSheet: true, + columnHeader: true, + rowHeader: true, + }, + 'x-evaluate': [{ id: 'total_quantity', label: 'Total Quantity', expression: '=SUM(D2:D4)' }], + properties: { + sheet: { type: 'array' }, + derivations: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + label: { type: 'string' }, + value: {}, + error: { type: 'string' }, + }, + required: ['label', 'value'], + }, + }, + }, + }, + unit_price: { + type: 'number', + title: 'Unit Price', + description: 'Manually entered — not a computed field.', + }, + estimated_total: { + type: 'number', + title: 'Estimated Total', + description: + 'quantity * price, via x-computed: quantity is a spreadsheet derivation (sales_data.derivations.total_quantity.value), price is the plain sibling field above.', + 'x-computed': { + inputs: { + quantity: 'sales_data.derivations.total_quantity.value', + price: 'unit_price', + }, + formula: 'quantity * price', + decimals: 2, + }, + }, + }, + } as unknown as JsonSchema, + uischema: { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/sales_data' }, + { type: 'Control', scope: '#/properties/unit_price' }, + { type: 'Control', scope: '#/properties/estimated_total' }, + ], + } as UISchemaElement, + }, { id: 'array', name: 'Array (objects)', diff --git a/packages/jsonforms-renderers/dev/sample-files/generate-sales-data-sample.cjs b/packages/jsonforms-renderers/dev/sample-files/generate-sales-data-sample.cjs new file mode 100644 index 0000000..e35e3af --- /dev/null +++ b/packages/jsonforms-renderers/dev/sample-files/generate-sales-data-sample.cjs @@ -0,0 +1,24 @@ +// Regenerates sales-data-sample.xlsx — a small, generic (industry-agnostic) +// line-item sheet used to manually test ComputedControl (x-computed) reading +// a spreadsheet derivation combined with a plain user-entered field in the +// dev playground (see the 'computed-control-with-spreadsheet' fixture in +// dev/fixtures.ts). Run with: +// node dev/sample-files/generate-sales-data-sample.cjs +const { utils, writeFile } = require('@e965/xlsx') +const path = require('node:path') + +// x-evaluate: total_quantity = SUM(D2:D4) -> 500 + 300 + 200 = 1000 +const rows = [ + ['Date', 'Item', 'Category', 'Quantity'], + ['01/06/2026', 'Widget A', 'Hardware', 500], + ['02/06/2026', 'Widget B', 'Hardware', 300], + ['03/06/2026', 'Widget C', 'Accessories', 200], +] + +const worksheet = utils.aoa_to_sheet(rows) +const workbook = utils.book_new() +utils.book_append_sheet(workbook, worksheet, 'Sales Data') + +const outPath = path.join(__dirname, 'sales-data-sample.xlsx') +writeFile(workbook, outPath) +console.log(`Wrote ${outPath}`) diff --git a/packages/jsonforms-renderers/dev/sample-files/sales-data-sample.xlsx b/packages/jsonforms-renderers/dev/sample-files/sales-data-sample.xlsx new file mode 100644 index 0000000..22eda63 Binary files /dev/null and b/packages/jsonforms-renderers/dev/sample-files/sales-data-sample.xlsx differ diff --git a/packages/jsonforms-renderers/docs/computed-fields.md b/packages/jsonforms-renderers/docs/computed-fields.md new file mode 100644 index 0000000..65cf69f --- /dev/null +++ b/packages/jsonforms-renderers/docs/computed-fields.md @@ -0,0 +1,55 @@ +# Computed fields (`x-computed`) + +`ComputedControl` reads one or more named values from anywhere in the form's data tree, evaluates a formula written in terms of those names, and persists/renders the result as a readonly `type: 'number'` field, configured via the schema's `x-computed` object: + +```json +"estimated_total": { + "type": "number", + "x-computed": { + "inputs": { + "quantity": "sales_data.derivations.total_quantity.value", + "price": "unit_price", + "discount": { "path": "discount_amount", "default": 0 } + }, + "formula": "quantity * price - discount", + "format": "{value}", + "decimals": 2 + } +} +``` + +## `inputs`: alias → path + +Each entry maps an alias (the name used in `formula`) to a path — either a bare string, or `{ path, default }` when the value is legitimately optional. + +**Paths are not a syntax invented for this feature.** They're the same dot-joined data-path representation every JSONForms `ControlProps.path` already uses at runtime (`@jsonforms/core`'s own `Resolve.data`/`Paths.compose` — see the source, both just `.split('.')`/join with `.`). A path is resolved **relative to the computed field's own containing object** — e.g. a computed field that's an item inside an array (`items.0.estimated_total`) resolves `"sales_data.derivations.total_quantity.value"` against `items.0`, reaching `items.0.sales_data.derivations.total_quantity.value` — the same array item, never a different one. There's currently no way to address an absolute/root path; every input is relative to the field's own parent. + +An input can point at: + +- A plain, manually-entered sibling field (`"unit_price"`). +- One specific `SpreadsheetControl` derivation, via its map path (`"sales_data.derivations..value"` — derivations are persisted as a map keyed by each `x-evaluate` entry's `id`, not an array, specifically so they're addressable this way). +- Another **computed** field's own persisted value — computed fields chain through the same mechanism as any other field. + +### `default` + +When a path resolves to `null`/`undefined` (an upload that hasn't happened yet, a field left blank) and no `default` is configured, the **whole** computed field goes to `unavailable` — it does not attempt a partial computation with some inputs missing. Configuring `default` lets the computation proceed anyway, which is the right choice for a genuinely optional input (e.g. a spreadsheet upload labeled "if applicable"). + +## `formula` + +Written in terms of the aliases above (not Excel-style cell references), evaluated by the same `fast-formula-parser`/`@formulajs/formulajs` engine `x-evaluate` uses (see [spreadsheet-formulas.md](./spreadsheet-formulas.md)) via the library's own named-variable ("defined name") mechanism — so the full function/operator set documented there (`ROUND`, `IF`, etc.) is available here too, not just `+ - * /`. + +**Alias-naming constraint**, inherited directly from the library's grammar (not enforced by this package): an alias must lex as an identifier, which loses to the library's spreadsheet-column token at **equal match length**. In practice: + +- A **1-3 letter, all-alphabetic** alias (`a`, `qty`) is read as a spreadsheet column reference, not a variable, and won't work. +- A **longer** alias, or one containing a digit or underscore (`total_sales`, `qty1`, `qty_export`), is unambiguous. +- An alias must not be shaped like a full cell address (`A1`, `B12`) or be `TRUE`/`FALSE` (reserved literals). + +When in doubt, use a descriptive `snake_case` alias with an underscore — every example above follows this. + +## Behavior + +- **Always recomputes when its inputs/formula are available, regardless of `enabled`/`readonly`** — on this field's own schema, or the whole form's. `x-computed`'s presence on a field is itself the complete signal that it's entirely calculated, not manually entered; there's no legitimate configuration where recomputation should be skipped. A schema author who wants a static, non-computed display value simply omits `x-computed` (falls to the plain number control instead). This is deliberately different from `SpreadsheetControl`, whose own-field `readOnly` genuinely gates a real interactive action (accepting a new upload) that this control has no equivalent of. +- **The computed value is persisted**, not display-only — `handleChange` writes it to the field's own path, guarded by an equality check so the field's own write can't retrigger its own resolution. +- **Clears to `null` when `unavailable` or on a formula/type error** (rather than leaving a stale prior value showing). This matters once fields chain: without it, a field reading another computed field's value could silently compute from a number that's no longer true. Restricted to `type: 'number'` — a formula that evaluates to a non-number (e.g. a `CONCATENATE`-style expression) is treated as a computation error, not persisted; a computed field that should produce a string/boolean/date isn't supported yet. +- **Renders an already-persisted value immediately on mount**, with no "Not yet available" → "Computing…" flash, and a recompute of an already-good value updates silently in place rather than flashing back to the spinner — only a field with nothing to show yet (or one that just errored) shows the loading state. +- **Chained computed fields settle automatically** — every control re-renders off the same shared root data, so a downstream field's inputs re-resolve on the render that follows an upstream field's own update. A schema author who creates an actual cycle (field A reads field B, which reads field A) is **not detected** and will loop indefinitely — don't do that. diff --git a/packages/jsonforms-renderers/src/renderers/ComputedControl.tsx b/packages/jsonforms-renderers/src/renderers/ComputedControl.tsx new file mode 100644 index 0000000..08807b2 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/ComputedControl.tsx @@ -0,0 +1,168 @@ +import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react' +import type { ControlProps, JsonSchema } from '@jsonforms/core' +import { Box, Flex, Spinner, Text } from '@radix-ui/themes' +import { useEffect, useState } from 'react' +import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { evaluateComputedFormula, formatComputedValue, resolveComputedInputs } from '../utils/computed' +import type { ComputedInput } from '../utils/computed' +import type { CellValue } from '../utils/spreadsheet' + +interface XComputedOptions { + /** alias -> path (shorthand) or { path, default }, relative to this control's own parent object. */ + inputs: Record + /** Formula written in terms of the aliases above, e.g. "total_sales + total_imported + total_blend_balance". */ + formula: string + /** Display template; "{value}" is replaced by the formatted number. Default "{value}". */ + format?: string + /** Decimal places. Default 2. */ + decimals?: number +} + +type ComputedControlProps = ControlProps & { + schema: JsonSchema & { 'x-computed'?: XComputedOptions } +} + +type Status = 'unavailable' | 'loading' | 'ok' | 'error' + +const DEFAULT_FORMAT = '{value}' +const DEFAULT_DECIMALS = 2 +const EMPTY_INPUTS: Record = {} + +const ComputedControl = ({ data, handleChange, path, label, schema, visible = true }: ComputedControlProps) => { + useClearWhenHidden(visible, path, handleChange, null) + + const xComputed = schema?.['x-computed'] + const inputs = xComputed?.inputs ?? EMPTY_INPUTS + const formula = xComputed?.formula ?? '' + const format = xComputed?.format ?? DEFAULT_FORMAT + const decimals = xComputed?.decimals ?? DEFAULT_DECIMALS + + // Every render, not just inside the effect below — this is what lets the + // effect correctly re-fire the moment a SIBLING's data changes (e.g. a + // spreadsheet re-upload, or another computed field updating), since only + // ctx.core.data changes on that write, never this control's own path/data. + const ctx = useJsonForms() + const parentPath = path.split('.').slice(0, -1).join('.') + const resolvedInputs = resolveComputedInputs(ctx.core?.data, parentPath, inputs) + // Cheap, stable dependency key for the effect below — resolvedInputs is a + // fresh object every render even when its contents are unchanged. + const inputsKey = resolvedInputs ? JSON.stringify(resolvedInputs) : null + + // No enabled/readonly awareness here, deliberately — x-computed's own + // presence on a field IS the complete signal that it's entirely + // calculated, not manually entered. There's no legitimate case where a + // schema author configures x-computed but wants recomputation skipped: a + // field's own `readOnly`/`enabled` (this field's, or the whole form's) + // means "the user can't type into this," never "stop calculating it" — + // this control has no editable input to disable in the first place. A + // schema author who wants a static, non-computed display value simply + // omits x-computed (falls to the plain NumberControl instead). Contrast + // with SpreadsheetControl, whose own-field `readOnly` genuinely means + // "don't accept a new upload" — a real interactive gate this control + // doesn't have an equivalent of. + // + // Lazy initializers so an already-persisted value renders immediately on + // mount — no "Not yet available" -> "Computing…" flash — regardless of + // whether this mount will end up recomputing (it always will, once inputs + // resolve; see the effect below for how it avoids re-flashing then too). + const [status, setStatus] = useState(() => (typeof data === 'number' ? 'ok' : 'unavailable')) + const [value, setValue] = useState(() => (typeof data === 'number' ? data : null)) + const [error, setError] = useState(null) + + useEffect(() => { + if (!visible) return // don't touch anything while hidden — useClearWhenHidden already cleared data + + let cancelled = false + + if (resolvedInputs === undefined) { + setStatus('unavailable') + setError(null) + setValue(null) + // Loop-safety guard: this control only ever writes to its own path, so + // a sibling's data (which this effect otherwise depends on) doesn't + // change from that write — but guard anyway. + if (data !== null) handleChange(path, null) + return + } + + // Only drop to the spinner when there's nothing already-good to keep + // showing in its place — a recompute of an already-persisted value + // settles silently instead of flashing "Computing…" every time a + // sibling changes. The functional updater reads the CURRENT status + // without needing it in the deps array (which would make this effect + // re-fire on its own state changes). + setStatus((current) => (current === 'ok' ? current : 'loading')) + + void evaluateComputedFormula(resolvedInputs, formula).then((result) => { + if (cancelled) return + + // ComputedControlTester only matches type: 'number' schemas, but the + // formula engine can still return a non-number (e.g. a CONCATENATE- + // style expression yields a string) — treat that as a computation + // error rather than persisting schema-invalid data. + const resolved = result.status === 'ok' && typeof result.value === 'number' ? result.value : null + + if (resolved === null) { + setStatus('error') + setError( + result.status === 'error' ? (result.error ?? 'Unable to compute value.') : 'Computed value must be a number.', + ) + setValue(null) + if (data !== null) handleChange(path, null) + return + } + + setStatus('ok') + setError(null) + setValue(resolved) + if (resolved !== (data ?? null)) handleChange(path, resolved) + }) + + return () => { + cancelled = true + } + // Deliberately keyed on the resolved inputs, formula, and visible only — + // not on `data`/`path`/`handleChange`, which this effect itself writes to. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputsKey, formula, visible]) + + if (visible === false) { + return null + } + + return ( + + + + {label} + + {status === 'unavailable' ? ( + + Not yet available. + + ) : status === 'loading' ? ( + + + + Computing… + + + ) : status === 'error' ? ( + + {error} + + ) : ( + {format.split(DEFAULT_FORMAT).join(formatComputedValue(value, decimals))} + )} + {schema.description && ( + + {schema.description} + + )} + + + ) +} + +const JsonFormsComputedControl = withJsonFormsControlProps(ComputedControl) +export default JsonFormsComputedControl diff --git a/packages/jsonforms-renderers/src/renderers/ComputedControlTester.ts b/packages/jsonforms-renderers/src/renderers/ComputedControlTester.ts new file mode 100644 index 0000000..953ccd4 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/ComputedControlTester.ts @@ -0,0 +1,17 @@ +import { rankWith, schemaMatches } from '@jsonforms/core' +import type { JsonSchema } from '@jsonforms/core' + +// Rank 5 beats NumberControlTester's rank 2 for a `type: 'number'` schema +// carrying `x-computed`, following the same "custom x-* keyword outranks the +// generic control" convention as SpreadsheetControlTester/FileControlTester +// (10) and SearchSelectControlTester (3). +export const ComputedControlTester = rankWith( + 5, + schemaMatches((schema: JsonSchema) => { + // typeof null === 'object' in JS — exclude it explicitly, or + // "x-computed": null would match and evaluate an empty configuration + // instead of falling through to the normal number control. + const xComputed = (schema as Record)['x-computed'] + return schema.type === 'number' && typeof xComputed === 'object' && xComputed !== null + }), +) diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx index bff87da..b7acdc5 100644 --- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx @@ -4,6 +4,7 @@ import { Box, Flex, IconButton, Spinner, Table, Text, Tooltip } from '@radix-ui/ import { UploadIcon, Cross2Icon, ExclamationTriangleIcon } from '@radix-ui/react-icons' import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from 'react' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { isEditable } from '../utils/editable' import { getErrorMessage } from '../utils/error' import { formatBytes, formatAccept } from '../utils/format' import { @@ -75,7 +76,7 @@ const SpreadsheetControl = ({ // either, check both explicitly rather than relying on that incidental fold-in. // For this control, "disabled" and "readonly" mean the same thing: show // whatever data exists, but don't allow uploading a replacement or removing it. - const canEdit = enabled !== false && readonly !== true + const canEdit = isEditable(enabled, readonly) const xSpreadsheet: XSpreadsheetOptions = schema?.['x-spreadsheet'] ?? {} const xEvaluate: FormulaConfigEntry[] = schema?.['x-evaluate'] ?? EMPTY_FORMULAS diff --git a/packages/jsonforms-renderers/src/renderers/index.ts b/packages/jsonforms-renderers/src/renderers/index.ts index a991663..f08bd72 100644 --- a/packages/jsonforms-renderers/src/renderers/index.ts +++ b/packages/jsonforms-renderers/src/renderers/index.ts @@ -21,6 +21,8 @@ import FileControl from './FileControl' import { FileControlTester } from './FileControlTester' import SpreadsheetControl from './SpreadsheetControl' import { SpreadsheetControlTester } from './SpreadsheetControlTester' +import ComputedControl from './ComputedControl' +import { ComputedControlTester } from './ComputedControlTester' import ArrayControl from './ArrayControl' import { ArrayControlTester } from './ArrayControlTester' import LabelRenderer, { LabelTester } from './LabelRenderer' @@ -42,6 +44,7 @@ export const radixRenderers = [ { tester: CategorizationLayoutTester, renderer: CategorizationLayoutRenderer }, { tester: FileControlTester, renderer: FileControl }, { tester: SpreadsheetControlTester, renderer: SpreadsheetControl }, + { tester: ComputedControlTester, renderer: ComputedControl }, { tester: ArrayControlTester, renderer: ArrayControl }, { tester: PrimitiveArrayControlTester, renderer: ArrayControl }, { tester: LabelTester, renderer: LabelRenderer }, @@ -59,6 +62,8 @@ export { default as FileControl } from './FileControl' export * from './FileControlTester' export { default as SpreadsheetControl } from './SpreadsheetControl' export * from './SpreadsheetControlTester' +export { default as ComputedControl } from './ComputedControl' +export * from './ComputedControlTester' export { default as SearchSelectControl } from './SearchSelectControl' export * from './SearchSelectControlTester' export { default as ArrayControl } from './ArrayControl' diff --git a/packages/jsonforms-renderers/src/utils/computed.test.ts b/packages/jsonforms-renderers/src/utils/computed.test.ts new file mode 100644 index 0000000..811ec28 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/computed.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { evaluateComputedFormula, formatComputedValue, resolveComputedInputs } from './computed' + +describe('resolveComputedInputs', () => { + const rootData = { + blendsheet_data: [ + { + quality_to_be_exported: 16128, + total: 41128, + sales: { derivations: { total_sales: { label: 'Total Sales', value: 27000 } } }, + imported_tea: { derivations: { total_imported: { label: 'Total Imported', value: 5000 } } }, + blend_balances: { derivations: {} }, + }, + ], + } + + it('resolves a plain manually-entered sibling field', () => { + expect(resolveComputedInputs(rootData, 'blendsheet_data.0', { qty: 'quality_to_be_exported' })).toEqual({ + qty: 16128, + }) + }) + + it('resolves a nested spreadsheet-derivation value via its map path', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { total_sales: 'sales.derivations.total_sales.value' }), + ).toEqual({ total_sales: 27000 }) + }) + + it('resolves a sibling computed field the same way as any other value', () => { + expect(resolveComputedInputs(rootData, 'blendsheet_data.0', { total: 'total' })).toEqual({ total: 41128 }) + }) + + it('combines multiple aliases from different sources in one call', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { + total_sales: 'sales.derivations.total_sales.value', + total_imported: 'imported_tea.derivations.total_imported.value', + qty: 'quality_to_be_exported', + }), + ).toEqual({ total_sales: 27000, total_imported: 5000, qty: 16128 }) + }) + + it('returns undefined (unavailable) when an alias with no default resolves to missing', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { + total_blend_balance: 'blend_balances.derivations.total_blend_balance.value', + }), + ).toBeUndefined() + }) + + it('uses the configured default when an alias resolves to missing', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { + total_blend_balance: { path: 'blend_balances.derivations.total_blend_balance.value', default: 0 }, + }), + ).toEqual({ total_blend_balance: 0 }) + }) + + it('a present value is used as-is even when a default is also configured', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { + total_sales: { path: 'sales.derivations.total_sales.value', default: -1 }, + }), + ).toEqual({ total_sales: 27000 }) + }) + + it('empty inputs resolves to an empty object, not undefined', () => { + expect(resolveComputedInputs(rootData, 'blendsheet_data.0', {})).toEqual({}) + }) + + it('short-circuits to undefined on the FIRST missing alias without a default, even with other aliases present', () => { + expect( + resolveComputedInputs(rootData, 'blendsheet_data.0', { + qty: 'quality_to_be_exported', + missing: 'does_not_exist', + }), + ).toBeUndefined() + }) + + it('works with an empty parentPath (a root-level control)', () => { + expect(resolveComputedInputs({ a: 5 }, '', { a: 'a' })).toEqual({ a: 5 }) + }) +}) + +describe('evaluateComputedFormula', () => { + it('evaluates arithmetic over the resolved values', async () => { + const result = await evaluateComputedFormula( + { total_sales: 27000, total_imported: 5000 }, + 'total_sales + total_imported', + ) + expect(result).toEqual({ status: 'ok', value: 32000 }) + }) + + it('surfaces an unknown-name error as #NAME?', async () => { + const result = await evaluateComputedFormula({ total: 100 }, 'total + nonexistent_alias') + expect(result.status).toBe('error') + expect(result.error).toBe('#NAME?') + }) + + it('surfaces a division-by-zero error as #DIV/0!', async () => { + const result = await evaluateComputedFormula({ total: 100, divisor: 0 }, 'total / divisor') + expect(result.status).toBe('error') + expect(result.error).toBe('#DIV/0!') + }) + + it('never throws even for a malformed formula', async () => { + await expect(evaluateComputedFormula({}, '')).resolves.toEqual({ status: 'error', error: '#ERROR!' }) + }) +}) + +describe('formatComputedValue', () => { + it('formats a number to the given decimal places', () => { + expect(formatComputedValue(1234.5, 2)).toBe('1234.50') + expect(formatComputedValue(810, 2)).toBe('810.00') + }) + + it('formats a Date as a locale date string', () => { + const date = new Date(2026, 0, 15) + expect(formatComputedValue(date, 2)).toBe(date.toLocaleDateString()) + }) + + it('stringifies a non-number, non-Date value', () => { + expect(formatComputedValue('BOP', 2)).toBe('BOP') + expect(formatComputedValue(true, 2)).toBe('true') + }) + + it('returns an empty string for null/undefined', () => { + expect(formatComputedValue(null, 2)).toBe('') + expect(formatComputedValue(undefined, 2)).toBe('') + }) + + it('clamps decimals to [0, 100] instead of letting toFixed throw', () => { + expect(formatComputedValue(1.25, -1)).toBe('1') + expect(formatComputedValue(1.25, 0)).toBe('1') + expect(formatComputedValue(1.25, 100)).toBe((1.25).toFixed(100)) + expect(formatComputedValue(1.25, 101)).toBe((1.25).toFixed(100)) + }) + + it('treats a non-finite decimals value as 0', () => { + expect(formatComputedValue(1.25, NaN)).toBe('1') + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/computed.ts b/packages/jsonforms-renderers/src/utils/computed.ts new file mode 100644 index 0000000..53f82f3 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/computed.ts @@ -0,0 +1,85 @@ +import { Resolve } from '@jsonforms/core' +import { describeFormulaError, evaluateFormulaWithVariables } from './spreadsheet' +import type { CellValue } from './spreadsheet' + +// A bare string is shorthand for { path: thatString }. `default` is used +// when the resolved value is null/undefined — e.g. an optional upload that +// hasn't happened yet — so the computation can proceed instead of the whole +// field going "unavailable". `path` follows the same dot-joined convention +// every ControlProps.path already uses (see Resolve.data / Paths.compose in +// @jsonforms/core) — not a syntax invented for this feature. +export type ComputedInput = string | { path: string; default?: CellValue } + +export interface ComputedResolution { + status: 'ok' | 'error' + value?: CellValue + error?: string +} + +// Pure, synchronous. Resolves each alias in `inputs` against `rootData`, +// relative to `parentPath` — the computed control's own containing object +// (e.g. a control at `blendsheet_data.0.total` passes parentPath +// `blendsheet_data.0`, so `"sales.derivations.total_sales.value"` reaches +// `blendsheet_data.0.sales.derivations...`, the SAME array item). +// +// For each alias: resolve the path; if it's null/undefined and a `default` +// was given, use the default; if null/undefined with no default, the WHOLE +// result is undefined — the formula short-circuits as "not yet available" +// rather than attempting a partial computation. An upstream-errored +// derivation already has `value: null` by construction (see +// utils/spreadsheet/process.ts), so it naturally takes the same +// default-or-unavailable path as a value that's simply missing — no separate +// lookup of a sibling `.error` key is needed to get *a* signal, just not a +// maximally-specific one. +export function resolveComputedInputs( + rootData: unknown, + parentPath: string, + inputs: Record, +): Record | undefined { + const values: Record = {} + + for (const [alias, config] of Object.entries(inputs)) { + const { path, default: fallback } = typeof config === 'string' ? { path: config, default: undefined } : config + const resolvedPath = parentPath ? `${parentPath}.${path}` : path + const resolved = Resolve.data(rootData, resolvedPath) as CellValue | undefined + + if (resolved === null || resolved === undefined) { + if (fallback === undefined) return undefined + values[alias] = fallback + } else { + values[alias] = resolved + } + } + + return values +} + +// Pure, async, never throws — thin wrapper over evaluateFormulaWithVariables +// with this package's own error-message contract (describeFormulaError). +export async function evaluateComputedFormula( + values: Record, + formula: string, +): Promise { + try { + const value = await evaluateFormulaWithVariables(values, formula) + return { status: 'ok', value } + } catch (err) { + return { status: 'error', error: describeFormulaError(err) } + } +} + +// Number -> fixed decimals; Date -> locale date string; anything else -> +// String(); null/undefined -> ''. +export function formatComputedValue(value: CellValue | null | undefined, decimals: number): string { + if (value == null) return '' + if (typeof value === 'number') return value.toFixed(clampDecimals(decimals)) + if (value instanceof Date) return value.toLocaleDateString() + return String(value) +} + +// Number.prototype.toFixed throws outside [0, 100] and for non-finite input +// — clamp rather than let a misconfigured x-computed.decimals crash rendering. +function clampDecimals(decimals: number): number { + if (!Number.isFinite(decimals)) return 0 + return Math.min(100, Math.max(0, Math.trunc(decimals))) +} diff --git a/packages/jsonforms-renderers/src/utils/editable.ts b/packages/jsonforms-renderers/src/utils/editable.ts new file mode 100644 index 0000000..1bb15a1 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/editable.ts @@ -0,0 +1,6 @@ +// Shared enabled/readonly gate, used by any control that needs to tell +// whether the user can currently interact with it — enabled and readonly +// are independent props in @jsonforms/core, not one derived from the other. +export function isEditable(enabled?: boolean, readonly?: boolean): boolean { + return enabled !== false && readonly !== true +} diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/expression.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/expression.ts index 4fb6ed4..6c1c9c4 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/expression.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/expression.ts @@ -23,11 +23,20 @@ import type { CellValue, FormulaConfigEntry, FormulaErrorCode, FormulaResult, Ma // *resolved* constructor's own static property (`FormulaParser.FormulaError`) // rather than off the module namespace, since that's the one place it's // guaranteed to exist regardless of interop shape. -type FormulaParserCtor = new (options: { +type FormulaHooks = { onCell: (ref: CellRef) => unknown onRange: (ref: RangeRef) => unknown[][] - functions: Record unknown> -}) => { parse(formula: string, position: CellRef): unknown } + // The library's own "defined name" mechanism — a bare identifier in a + // formula (not an A1-style cell address) resolves through this hook to a + // CellRef/RangeRef, which is then routed through onCell/onRange exactly + // like a real reference. Returning null makes the library itself report + // #NAME? for an unrecognized name, for free. See evaluateFormulaWithVariables. + onVariable?: (name: string, sheetName?: string, position?: CellRef) => CellRef | RangeRef | null +} + +type FormulaParserCtor = new ( + options: FormulaHooks & { functions: Record unknown> }, +) => { parse(formula: string, position: CellRef): unknown } type LibFormulaErrorCtor = typeof LibFormulaErrorClass @@ -135,12 +144,14 @@ function normalizeResult(result: unknown): CellValue { // Internal building block: strips the leading `=` (tolerating its absence), // runs the allowlist pre-scan, then delegates parsing and evaluation to -// fast-formula-parser. Throws FormulaError on failure. Both libraries are -// dynamically imported here (not as static top-level imports) so the -// formula-evaluation code splits into an on-demand chunk only fetched when a -// form actually has an `x-evaluate`-bearing field and a file gets uploaded — -// consumers who never touch this control never pay for either dependency. -export async function evaluateExpression(matrix: Matrix, expression: string): Promise { +// fast-formula-parser using whichever hooks the caller supplies (a real +// matrix's onCell/onRange for evaluateExpression, or a variables-only set for +// evaluateFormulaWithVariables — see below). Throws FormulaError on failure. +// Both libraries are dynamically imported here (not as static top-level +// imports) so the formula-evaluation code splits into an on-demand chunk only +// fetched when a form actually needs it — consumers who never touch a +// formula-bearing field never pay for either dependency. +async function evaluateWithHooks(expression: string, hooks: FormulaHooks): Promise { if (typeof expression !== 'string') throw new FormulaError('ERROR') const trimmed = expression.trim() @@ -159,8 +170,7 @@ export async function evaluateExpression(matrix: Matrix, expression: string): Pr const LibFormulaError = (FormulaParser as unknown as { FormulaError: LibFormulaErrorCtor }).FormulaError const parser = new FormulaParser({ - onCell: makeOnCell(matrix), - onRange: makeOnRange(matrix), + ...hooks, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- formulajs's own types are all `any`-typed; formulaFunctions.ts narrows the surface it actually calls. functions: backfilledFunctions(fj as any, LibFormulaError), }) @@ -179,6 +189,57 @@ export async function evaluateExpression(matrix: Matrix, expression: string): Pr return normalizeResult(result) } +export async function evaluateExpression(matrix: Matrix, expression: string): Promise { + return evaluateWithHooks(expression, { onCell: makeOnCell(matrix), onRange: makeOnRange(matrix) }) +} + +// Sentinel row for a synthetic "variables row" — must be truthy (the +// library's own `checkFormulaResult` treats a bare top-level dereference as +// `result.ref.row && !result.ref.from`, so `row: 0` would silently fail that +// check and produce a false #VALUE! for a formula that's just one bare +// variable with no operator around it — verified empirically against the +// installed library) and must never collide with a real, 1-based cell row. +const VARIABLE_ROW = -1 + +// Evaluates a formula written in terms of NAMED variables rather than +// A1-style cell references — e.g. "total_sales + total_imported" — via +// fast-formula-parser's own `onVariable` "defined name" hook, not a textual +// substitution hack. No real matrix backs this: each variable's value is +// served from the synthetic `VARIABLE_ROW` that no real cell reference can +// ever address, so a stray `A1`-shaped token in the formula correctly falls +// through to `#REF!` rather than silently resolving to a variable. +// +// Alias-naming constraints (inherited from the library's own grammar, not +// enforced here — verified empirically): an alias must lex as a `Name` +// token, which loses to a `Column`/`Cell` token match of EQUAL length. In +// practice this means a 1-3 letter, all-alphabetic alias (`a`, `qty`) is +// read as a spreadsheet column reference, not a variable — a longer alias, or +// one containing a digit/underscore (`total_sales`, `qty1`), is unambiguous. +// An alias also must not look like a full cell address (`A1`, `B12`) and must +// not be `TRUE`/`FALSE` (reserved boolean literals) — see docs/computed-fields.md. +export async function evaluateFormulaWithVariables( + variables: Record, + expression: string, +): Promise { + const names = Object.keys(variables) + return evaluateWithHooks(expression, { + onVariable: (name) => { + const i = names.indexOf(name) + // Unknown name -> null -> the library itself reports #NAME?, for free. + return i === -1 ? null : { row: VARIABLE_ROW, col: i + 1 } + }, + onCell: (ref) => { + if (ref.row === VARIABLE_ROW) return variables[names[ref.col - 1]] ?? null + // A real A1-style cell reference: nothing backs it in a variables-only formula. + throw new FormulaError('REF') + }, + onRange: () => { + // Ranges over named scalars aren't meaningful here. + throw new FormulaError('REF') + }, + }) +} + const CODE_TO_STRING: Record = { REF: '#REF!', VALUE: '#VALUE!', diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/expression.variables.test.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/expression.variables.test.ts new file mode 100644 index 0000000..4525a02 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/expression.variables.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { evaluateFormulaWithVariables } from './expression' +import { FormulaError } from './reference' +import type { FormulaErrorCode } from './types' + +async function expectFormulaError(promise: Promise, code: FormulaErrorCode) { + try { + await promise + } catch (err) { + if (!(err instanceof FormulaError)) throw err + expect(err.code).toBe(code) + return + } + throw new Error('expected the promise to reject with a FormulaError') +} + +describe('evaluateFormulaWithVariables', () => { + it('resolves a bare variable to its value', async () => { + expect(await evaluateFormulaWithVariables({ total_sales: 27000 }, 'total_sales')).toBe(27000) + }) + + it('evaluates arithmetic across multiple named variables', async () => { + const variables = { total_sales: 27000, total_imported: 5000, total_blend_balance: 0 } + expect(await evaluateFormulaWithVariables(variables, 'total_sales + total_imported + total_blend_balance')).toBe( + 32000, + ) + }) + + it('a function call works over variables the same as it would over cells', async () => { + expect( + await evaluateFormulaWithVariables({ numerator: 10, denominator: 3 }, '=ROUND(numerator/denominator,2)'), + ).toBeCloseTo(3.33, 2) + }) + + it('tolerates a missing leading =', async () => { + expect(await evaluateFormulaWithVariables({ total: 100, exported: 40 }, 'total - exported')).toBe(60) + }) + + it('documents the alias-naming pitfall: a short all-letter alias collides with a spreadsheet column reference', async () => { + // "qty" is 3 letters, matching the library's Column token (`[A-Za-z]{1,3}`) + // at equal length to the Name token match, so the lexer treats it as a + // whole-column reference rather than routing it through onVariable at + // all — it never reaches our onVariable hook, so #NAME? isn't produced; + // the library's own top-level "single column reference" handling + // resolves it to #VALUE! instead, with nothing backing a real column + // here. Longer or digit/underscore-containing aliases (see the tests + // above) don't have this problem. + await expectFormulaError(evaluateFormulaWithVariables({ qty: 40 }, 'qty'), 'VALUE') + }) + + it('an unknown variable name resolves to #NAME?', async () => { + await expectFormulaError(evaluateFormulaWithVariables({ total: 100 }, 'total + nonexistent'), 'NAME') + }) + + it('a cell-address-shaped token has nothing backing it and resolves to #REF!', async () => { + await expectFormulaError(evaluateFormulaWithVariables({ total: 100 }, 'A1'), 'REF') + }) + + it('a range token has nothing backing it and resolves to #REF!', async () => { + await expectFormulaError(evaluateFormulaWithVariables({ total: 100 }, 'SUM(A1:A5)'), 'REF') + }) + + it('a disallowed function is still blocked by the same allowlist as evaluateExpression', async () => { + await expectFormulaError(evaluateFormulaWithVariables({ x: 1 }, 'SUBTOTAL(9,x)'), 'NAME') + }) + + it('an empty variables map still evaluates a literal formula', async () => { + expect(await evaluateFormulaWithVariables({}, '1+2')).toBe(3) + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts index 7566b12..523fdcc 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts @@ -1,5 +1,10 @@ export { parseWorkbookToMatrix, columnLetter, SheetParseError } from './parse' -export { evaluateExpression, evaluateExpressions, describeFormulaError } from './expression' +export { + evaluateExpression, + evaluateExpressions, + evaluateFormulaWithVariables, + describeFormulaError, +} from './expression' export { processMatrix } from './process' export type { ProcessMatrixOptions } from './process' export type {