Skip to content
Draft
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,25 @@

## 89.0.0-SNAPSHOT - unreleased

### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - grid column filter specs)
### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - connected stores, grid column filter specs)

See [`docs/upgrade-notes/v89-upgrade-notes.md`](docs/upgrade-notes/v89-upgrade-notes.md) for
detailed, step-by-step upgrade instructions with before/after code examples.

* Stores connected to a Cube `View` are now always `projectionOnly` - the View sets the flag, and
an explicit `false` or a `processRawData` config on a connected store throws. Apps parsing View
rows into their own records must instead declare the needed fields on the Cube.
* `GridFilterModelConfig.fieldSpecs` is no longer an allow-list. Any `filterable` column it omits
now gets a default filter - set `filterable: false` on columns that should have none.

### 🎁 New Features

* Added `FieldSpec.derivedFn` - a field computed from the record's other values, named in the
required `dependsOn`, and read through a getter so it is always current. On a `CubeField` the
function also runs on every View row where the field is not aggregated: with an `aggregator` it
derives each leaf and rolls up (e.g. market value), without one it derives each level from that
row's aggregates (e.g. PnL in bps). A Query including a derived field includes its inputs.

### 🐞 Bug Fixes

* Fixed `GridFilterModelConfig.fieldSpecs` disabling filters on all other `filterable` columns.
Expand Down
4 changes: 2 additions & 2 deletions cmp/grid/columns/Column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,8 +771,8 @@ export class Column {

/** Does column support editing its field for the given StoreRecord? */
isEditableForRecord(record: StoreRecord): boolean {
const {editable, gridModel} = this;
if (!record) return false;
const {editable, gridModel, field} = this;
if (!record || record.store.getField(field)?.isDerived) return false;
return isFunction(editable)
? editable({record, store: record.store, gridModel, column: this})
: editable;
Expand Down
50 changes: 47 additions & 3 deletions data/Field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
* Copyright © 2026 Extremely Heavy Industries Inc.
*/

import {XH} from '@xh/hoist/core';
import {PlainObject, XH} from '@xh/hoist/core';
import {RuleLike} from '@xh/hoist/data/validation/Types';
import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
import {withDefault} from '@xh/hoist/utils/js';
import {throwIf, withDefault} from '@xh/hoist/utils/js';
import {Rule} from './validation/Rule';
import equal from 'fast-deep-equal';
import {isDate, isString, toNumber, isFinite, startCase, isFunction, castArray} from 'lodash';
Expand Down Expand Up @@ -64,8 +64,37 @@ export interface FieldSpec {
* building secured internal apps with large datasets and tight performance tolerances.
*/
enableXssProtection?: boolean;

/**
* Function computing this field's value from the record's other values, making it a *derived*
* field. Values are read through a getter on record `data` - never loaded, parsed, or written,
* and always current with their inputs:
*
* ```ts
* {name: 'marketValue', dependsOn: ['quantity', 'price'], derivedFn: d => d.quantity * d.price}
* ```
*
* Requires `dependsOn`. Derived fields are read-only - {@link Store.modifyRecords} throws on a
* write to one, and a grid column bound to one is never editable. A Store that is a
* `projectionOnly` view of another's data (e.g. one connected to a Cube View) adopts derived
* values from its provider rather than computing them.
*
* On a {@link CubeField}, the function also runs on every View row where the field is not
* aggregated - so a field with an `aggregator` derives at the leaves and rolls up (market
* value), while one without derives at every level from that row's aggregates (PnL in bps).
*/
derivedFn?: DerivedFn;

/**
* Names of the fields a `derivedFn` reads - required with `derivedFn`, and may be empty. A
* Cube Query including a derived field includes these as well.
*/
dependsOn?: string[];
}

/** Function computing a derived field's value from the other values on a record or View row. */
export type DerivedFn = (data: PlainObject) => any;

/**
* Metadata for an individual data field within a {@link StoreRecord}.
*
Expand All @@ -76,6 +105,11 @@ export class Field {
return true;
}

/** True if this field's value is computed from other values - see {@link FieldSpec.derivedFn}. */
get isDerived(): boolean {
return !!this.derivedFn;
}

readonly name: string;
readonly type: FieldType;
readonly displayName: string;
Expand All @@ -84,6 +118,8 @@ export class Field {
readonly isDimension: boolean;
readonly rules: Rule[];
readonly enableXssProtection: boolean;
readonly derivedFn: DerivedFn;
readonly dependsOn: string[];

constructor({
name,
Expand All @@ -93,7 +129,9 @@ export class Field {
defaultValue = null,
isDimension = false,
rules = [],
enableXssProtection = XH.appSpec.enableXssProtection
enableXssProtection = XH.appSpec.enableXssProtection,
derivedFn = null,
dependsOn = null
}: FieldSpec) {
this.name = name;
this.type = type;
Expand All @@ -103,6 +141,12 @@ export class Field {
this.rules = this.processRuleSpecs(rules);
this.enableXssProtection = enableXssProtection;
this.defaultValue = this.parseValueInternal(defaultValue);
this.derivedFn = derivedFn;
this.dependsOn = dependsOn;
throwIf(
derivedFn && !dependsOn,
`Field '${name}' declares a 'derivedFn' but no 'dependsOn' - name the fields it reads, or pass [].`
);
}

parseVal(val: any): any {
Expand Down
30 changes: 27 additions & 3 deletions data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,30 @@ apps with large datasets. Set `enableXssProtection` per field, or app-wide via
| `'tags'` | String array | Splits comma-separated |
| `'pwd'` | Password | Marks as sensitive |

### Derived Fields

A field with a `derivedFn` computes its value from the record's other values, named in the
required `dependsOn`. Values are read through a getter on record `data` - never loaded, parsed or
written - so they are always current with their inputs, and sort, filter and export like any other
field.

```typescript
{name: 'marketValue', dependsOn: ['quantity', 'price'], derivedFn: d => d.quantity * d.price}
```

* **Read-only.** `modifyRecords()` throws on a write to a derived field, and a grid column bound
to one is never editable.
* **Read by name, never enumerated.** The getters are not own properties, so `Object.keys()`,
spread and `JSON.stringify()` omit them. `StoreRecord.getValues()` returns every field, derived
included.
* **Keep the function pure and fast.** It can run once per visible cell per paint and once per
comparison when sorting. Return primitives or stable references - a fresh object or array per
read defeats the equality check grids use to skip repainting unchanged cells.

A `projectionOnly` store adopts derived values from its provider rather than computing them. See
the [Cube README](cube/README.md#derived-fields) for derived `CubeField`s, which also run on
aggregated View rows.

## Filter System

**Files**: `filter/Filter.ts`, `filter/FieldFilter.ts`, `filter/CompoundFilter.ts`, `filter/FunctionFilter.ts`
Expand Down Expand Up @@ -961,9 +985,9 @@ parses and owns. Store then uses each incoming raw object *as* its record's `dat
This collapses the usual two objects per row to one, and skips the per-row parse on every load and
update.

Use this config for stores connected to a Cube `View`, or fed by an endpoint that returns data in
its final client-side form. A View logs a warning when a connected store leaves the config unset.
Set it explicitly to `false` to opt out and silence that warning.
Use this config for stores fed by an endpoint that returns data in its final client-side form.
Stores connected to a Cube `View` are always projections - the View sets the flag, and an explicit
`false` throws.

```typescript
const store = new Store({
Expand Down
Loading
Loading