Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`)
Binary file not shown.
55 changes: 55 additions & 0 deletions packages/jsonforms-renderers/docs/computed-fields.md
Original file line number Diff line number Diff line change
@@ -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.<id>.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.
168 changes: 168 additions & 0 deletions packages/jsonforms-renderers/src/renderers/ComputedControl.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ComputedInput>
/** 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<string, ComputedInput> = {}

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<Status>(() => (typeof data === 'number' ? 'ok' : 'unavailable'))
const [value, setValue] = useState<CellValue | null>(() => (typeof data === 'number' ? data : null))
const [error, setError] = useState<string | null>(null)

useEffect(() => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Comment thread
coderabbitai[bot] marked this conversation as 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 (
<Box mb="4">
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="bold">
{label}
</Text>
{status === 'unavailable' ? (
<Text size="2" color="gray">
Not yet available.
</Text>
) : status === 'loading' ? (
<Flex align="center" gap="2">
<Spinner size="1" />
<Text size="2" color="gray">
Computing…
</Text>
</Flex>
) : status === 'error' ? (
<Text size="2" color="red">
{error}
</Text>
) : (
<Text size="2">{format.split(DEFAULT_FORMAT).join(formatComputedValue(value, decimals))}</Text>
)}
{schema.description && (
<Text size="1" color="gray">
{schema.description}
</Text>
)}
</Flex>
</Box>
)
}

const JsonFormsComputedControl = withJsonFormsControlProps(ComputedControl)
export default JsonFormsComputedControl
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)['x-computed']
return schema.type === 'number' && typeof xComputed === 'object' && xComputed !== null
}),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading