-
Notifications
You must be signed in to change notification settings - Fork 2
jsonforms-renderers(computed control): add ComputedControl for multi-input, named-variable computed fields #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
cfe1985
feat: add ComputedControl for multi-input, named-variable computed fi…
sthanikan2000 0266314
fix: pre-populate the blendsheet fixture with one array item
sthanikan2000 2b0c289
feat: split blend-sheet fixture into Computed Control and Computed Co…
sthanikan2000 58886bc
refactor: replace blend-sheet demo with a generic computed-control fi…
sthanikan2000 3f5a2e4
style: run prettier
sthanikan2000 4ad994a
refactor: extract shared isEditable helper from SpreadsheetControl
sthanikan2000 c859405
fix: stop ComputedControl from recomputing in readonly mode
sthanikan2000 357fb3b
fix: match the budget fixture's derivations schema in computed-contro…
sthanikan2000 3681132
fix!: recompute regardless of this field's own enabled/readonly
sthanikan2000 bff3500
fix: exclude x-computed: null from ComputedControlTester
sthanikan2000 b7f0da9
fix: clamp decimals before calling toFixed
sthanikan2000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
24 changes: 24 additions & 0 deletions
24
packages/jsonforms-renderers/dev/sample-files/generate-sales-data-sample.cjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
168
packages/jsonforms-renderers/src/renderers/ComputedControl.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(() => { | ||
| 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) | ||
|
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 | ||
17 changes: 17 additions & 0 deletions
17
packages/jsonforms-renderers/src/renderers/ComputedControlTester.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.