@xh/hoist 78.0.0-SNAPSHOT.1763665705643 → 78.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,17 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 78.0.0-SNAPSHOT - unreleased
3
+ ## 78.0.0 - 2025-11-21
4
4
 
5
5
  ### 💥 Breaking Changes
6
6
 
7
- * `GridModel.cleanColumnState` is now private (not expected to impact applications).
8
7
  * `GridModel.setColumnState` no longer patches existing column state, but instead replaces it
9
8
  wholesale. Applications that were relying on the prior patching behavior will need to
10
9
  call `GridModel.applyColumnStateChanges` instead.
10
+ * `GridModel.cleanColumnState` is now private (not expected to impact applications).
11
11
 
12
12
  ### 🎁 New Features
13
13
 
14
- * `FieldFilter` implementation expanded to support `not begins` and `not ends` operators.
14
+ * Added new `FieldFilter` operators `not begins` and `not ends`.
15
+ * Added new optional `BucketSpec.dependentFields` config to the Cube API, allowing apps to ensure
16
+ proper re-bucketing of rows during data-only updates where those updates could affect bucketing
17
+ determinations made by the spec.
15
18
 
16
19
  ### 🐞 Bug Fixes
17
20
 
@@ -19,11 +22,14 @@
19
22
  numerical ID.
20
23
  * Fixed issue where newly added columns appearing in the Displayed Columns section of the column
21
24
  chooser after loading grid state that was persisted before the columns were added to the grid.
25
+ * Removed a minor Cube `Query` annoyance - `dimensions` are now automatically added to the `fields`
26
+ list and do not need to be manually repeated there.
22
27
 
23
28
  ### ⚙️ Technical
24
29
 
25
- * `FetchService` will recognize variants on the `application/json` content-type when processing
26
- failed responses and decoding exceptions - e.g. `application/problem+json`.
30
+ * Improved documentation on `BucketSpec` class.
31
+ * Enhanced `FetchService` to recognize variants on the `application/json` content-type when
32
+ processing failed responses and decoding exceptions - e.g. `application/problem+json`.
27
33
 
28
34
  ## 77.1.1 - 2025-11-12
29
35
 
@@ -20,6 +20,8 @@ export type Thunkable<T> = T | (() => T);
20
20
  export type Awaitable<T> = Promise<T> | T;
21
21
  /** Convenience type for a "plain", string-keyed object holding any kind of values. */
22
22
  export type PlainObject = Record<string, any>;
23
+ /** Convenience type to make a set of keys optional in a given type. */
24
+ export type SetOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
23
25
  /**
24
26
  * Specification for debouncing in Hoist.
25
27
  *
@@ -1,16 +1,31 @@
1
1
  import { BaseRow } from './row/BaseRow';
2
+ import { SetOptional } from '@xh/hoist/core';
2
3
  /**
3
- * @see BucketSpecFn
4
+ * Spec to define a bucketing level within the hierarchy of data returned by a Query, as identified
5
+ * by a set of rows passed to a {@link BucketSpecFn} configured on that Query (or defaulted from
6
+ * the Cube). If this object is returned for a candidate set of rows, each row is evaluated by the
7
+ * spec's `bucketFn` to determine if it should yield a value for the bucket, causing the row to be
8
+ * nested underneath a new {@link BucketRow} created to hold all rows with that value.
4
9
  */
5
10
  export declare class BucketSpec {
11
+ /** Name for the bucketing level configured by this spec - equivalent to a dimension name. */
6
12
  name: string;
13
+ /**
14
+ * Function returning the bucketed value (if any) into which the given row should be placed -
15
+ * equivalent to a dimension value. Return null/undefined to exclude the row from bucketing.
16
+ */
7
17
  bucketFn: (row: BaseRow) => string;
18
+ /**
19
+ * Function returning bucket row label from the bucket value string returned by bucketFn.
20
+ * Defaults to using the value directly.
21
+ */
8
22
  labelFn: (bucket: string) => string;
9
23
  /**
10
- * @param name - name of bucket.
11
- * @param bucketFn - function to determine which (if any) bucket the given row should
12
- * be placed into
13
- * @param labelFn - function to generate the bucket row label from name returned by bucketFn
14
- **/
15
- constructor(name: string, bucketFn: (row: BaseRow) => string, labelFn?: (bucket: string) => string);
24
+ * Fields on which the `bucketFn` depends, to ensure rows are re-bucketed if dependent field
25
+ * values change. If not provided or does not cover all fields potentially accessed by
26
+ * `bucketFn`, an incremental "data only" update that should have changed a row's bucket can
27
+ * fail to do so.
28
+ */
29
+ dependentFields: string[];
30
+ constructor(config: SetOptional<BucketSpec, 'labelFn' | 'dependentFields'>);
16
31
  }
@@ -60,7 +60,7 @@ export type OmitFn = (row: AggregateRow | BucketRow) => boolean;
60
60
  * aggregations and create an unwanted "Open" grouping.
61
61
  *
62
62
  * @param rows - the rows being checked for bucketing
63
- * @returns BucketSpec for configuring dynamic sub-aggregations, or null to perform no bucketing.
63
+ * @returns {@link BucketSpec} for dynamic sub-aggregations, or null to perform no bucketing.
64
64
  */
65
65
  export type BucketSpecFn = (rows: BaseRow[]) => BucketSpec;
66
66
  /**
@@ -19,8 +19,6 @@ export interface QueryConfig {
19
19
  * Fields or field names on which data should be grouped and aggregated. These are the ordered
20
20
  * grouping levels in the resulting hierarchy - e.g. ['Country', 'State', 'City'].
21
21
  *
22
- * Any fields provided here must also be included in the `fields` array, if specified.
23
- *
24
22
  * If not provided or empty, the resulting data will not be grouped. Specify 'includeRoot' or
25
23
  * 'includeLeaves' in that case, otherwise no data will be returned.
26
24
  */
@@ -1,5 +1,5 @@
1
1
  import { HoistBase, PlainObject, Some } from '@xh/hoist/core';
2
- import { Cube, CubeField, Filter, FilterLike, Query, QueryConfig, Store, StoreRecordId } from '@xh/hoist/data';
2
+ import { Cube, CubeField, Filter, FilterLike, Query, QueryConfig, Store, StoreChangeLog, StoreRecordId } from '@xh/hoist/data';
3
3
  import { ViewRowData } from '@xh/hoist/data/cube/ViewRowData';
4
4
  import { AggregationContext } from './aggregate/AggregationContext';
5
5
  import { BaseRow } from './row/BaseRow';
@@ -51,6 +51,7 @@ export declare class View extends HoistBase {
51
51
  private _rowDatas;
52
52
  private _leafMap;
53
53
  private _recordMap;
54
+ private _bucketDependentFields;
54
55
  _aggContext: AggregationContext;
55
56
  _rowCache: Map<string, BaseRow>;
56
57
  /** @internal - applications should use {@link Cube.createView} */
@@ -79,7 +80,7 @@ export declare class View extends HoistBase {
79
80
  /** Update the filter on the current Query.*/
80
81
  setFilter(filter: FilterLike): void;
81
82
  noteCubeLoaded(): void;
82
- noteCubeUpdated(changeLog: PlainObject): void;
83
+ noteCubeUpdated(changeLog: StoreChangeLog): void;
83
84
  private fullUpdate;
84
85
  private dataOnlyUpdate;
85
86
  private loadStores;
@@ -88,7 +89,7 @@ export declare class View extends HoistBase {
88
89
  private groupAndInsertRecords;
89
90
  private bucketRows;
90
91
  private getSimpleUpdates;
91
- private hasDimUpdates;
92
+ private hasDimOrBucketUpdates;
92
93
  private cachedRow;
93
94
  private filterRecords;
94
95
  private createAggregationContext;
@@ -5,7 +5,7 @@ import { View } from '../View';
5
5
  /**
6
6
  * Row within a dataset produced by a Cube / View representing aggregated data on a dimension that
7
7
  * has been further grouped into a dynamic child "bucket" - a subset of the dimension-level
8
- * {@link AggregateRow} produced as per a specified {@link Query.bucketSpecFn}.
8
+ * {@link AggregateRow} produced as per a specified {@link BucketSpecFn}.
9
9
  *
10
10
  * This is an internal data structure - {@link ViewRowData} is the public row-level data API.
11
11
  */
@@ -34,6 +34,9 @@ export type Awaitable<T> = Promise<T> | T;
34
34
  /** Convenience type for a "plain", string-keyed object holding any kind of values. */
35
35
  export type PlainObject = Record<string, any>;
36
36
 
37
+ /** Convenience type to make a set of keys optional in a given type. */
38
+ export type SetOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
39
+
37
40
  /**
38
41
  * Specification for debouncing in Hoist.
39
42
  *
@@ -6,28 +6,43 @@
6
6
  */
7
7
 
8
8
  import {BaseRow} from './row/BaseRow';
9
+ import {SetOptional} from '@xh/hoist/core';
9
10
 
10
11
  /**
11
- * @see BucketSpecFn
12
+ * Spec to define a bucketing level within the hierarchy of data returned by a Query, as identified
13
+ * by a set of rows passed to a {@link BucketSpecFn} configured on that Query (or defaulted from
14
+ * the Cube). If this object is returned for a candidate set of rows, each row is evaluated by the
15
+ * spec's `bucketFn` to determine if it should yield a value for the bucket, causing the row to be
16
+ * nested underneath a new {@link BucketRow} created to hold all rows with that value.
12
17
  */
13
18
  export class BucketSpec {
19
+ /** Name for the bucketing level configured by this spec - equivalent to a dimension name. */
14
20
  name: string;
21
+
22
+ /**
23
+ * Function returning the bucketed value (if any) into which the given row should be placed -
24
+ * equivalent to a dimension value. Return null/undefined to exclude the row from bucketing.
25
+ */
15
26
  bucketFn: (row: BaseRow) => string;
27
+
28
+ /**
29
+ * Function returning bucket row label from the bucket value string returned by bucketFn.
30
+ * Defaults to using the value directly.
31
+ */
16
32
  labelFn: (bucket: string) => string;
17
33
 
18
34
  /**
19
- * @param name - name of bucket.
20
- * @param bucketFn - function to determine which (if any) bucket the given row should
21
- * be placed into
22
- * @param labelFn - function to generate the bucket row label from name returned by bucketFn
23
- **/
24
- constructor(
25
- name: string,
26
- bucketFn: (row: BaseRow) => string,
27
- labelFn?: (bucket: string) => string
28
- ) {
29
- this.name = name;
30
- this.bucketFn = bucketFn;
31
- this.labelFn = labelFn ?? (b => b);
35
+ * Fields on which the `bucketFn` depends, to ensure rows are re-bucketed if dependent field
36
+ * values change. If not provided or does not cover all fields potentially accessed by
37
+ * `bucketFn`, an incremental "data only" update that should have changed a row's bucket can
38
+ * fail to do so.
39
+ */
40
+ dependentFields: string[];
41
+
42
+ constructor(config: SetOptional<BucketSpec, 'labelFn' | 'dependentFields'>) {
43
+ this.name = config.name;
44
+ this.bucketFn = config.bucketFn;
45
+ this.labelFn = config.labelFn ?? (b => b);
46
+ this.dependentFields = config.dependentFields ?? [];
32
47
  }
33
48
  }
package/data/cube/Cube.ts CHANGED
@@ -82,7 +82,7 @@ export type OmitFn = (row: AggregateRow | BucketRow) => boolean;
82
82
  * aggregations and create an unwanted "Open" grouping.
83
83
  *
84
84
  * @param rows - the rows being checked for bucketing
85
- * @returns BucketSpec for configuring dynamic sub-aggregations, or null to perform no bucketing.
85
+ * @returns {@link BucketSpec} for dynamic sub-aggregations, or null to perform no bucketing.
86
86
  */
87
87
  export type BucketSpecFn = (rows: BaseRow[]) => BucketSpec;
88
88
 
@@ -16,7 +16,7 @@ import {
16
16
  StoreRecord
17
17
  } from '@xh/hoist/data';
18
18
  import {throwIf} from '@xh/hoist/utils/js';
19
- import {find, isEqual} from 'lodash';
19
+ import {find, isEqual, uniq} from 'lodash';
20
20
  import {Cube} from './Cube';
21
21
  import {CubeField} from './CubeField';
22
22
 
@@ -40,8 +40,6 @@ export interface QueryConfig {
40
40
  * Fields or field names on which data should be grouped and aggregated. These are the ordered
41
41
  * grouping levels in the resulting hierarchy - e.g. ['Country', 'State', 'City'].
42
42
  *
43
- * Any fields provided here must also be included in the `fields` array, if specified.
44
- *
45
43
  * If not provided or empty, the resulting data will not be grouped. Specify 'includeRoot' or
46
44
  * 'includeLeaves' in that case, otherwise no data will be returned.
47
45
  */
@@ -153,8 +151,8 @@ export class Query {
153
151
  omitFn = cube.omitFn
154
152
  }: QueryConfig) {
155
153
  this.cube = cube;
156
- this.fields = this.parseFields(fields);
157
154
  this.dimensions = this.parseDimensions(dimensions);
155
+ this.fields = uniq([...this.parseFields(fields), ...(this.dimensions ?? [])]);
158
156
  this.includeRoot = includeRoot;
159
157
  this.includeLeaves = includeLeaves;
160
158
  this.provideLeaves = provideLeaves;
@@ -234,7 +232,7 @@ export class Query {
234
232
  private parseDimensions(raw: CubeField[] | string[]): CubeField[] {
235
233
  if (!raw) return null;
236
234
  if (raw[0] instanceof CubeField) return raw as CubeField[];
237
- const {fields} = this;
235
+ const {fields} = this.cube;
238
236
  return raw.map(name => {
239
237
  const field = find(fields, {name});
240
238
  throwIf(
package/data/cube/View.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  Query,
15
15
  QueryConfig,
16
16
  Store,
17
+ StoreChangeLog,
17
18
  StoreRecord,
18
19
  StoreRecordId
19
20
  } from '@xh/hoist/data';
@@ -21,7 +22,7 @@ import {ViewRowData} from '@xh/hoist/data/cube/ViewRowData';
21
22
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
22
23
  import {shallowEqualArrays} from '@xh/hoist/utils/impl';
23
24
  import {logWithDebug, throwIf} from '@xh/hoist/utils/js';
24
- import {castArray, find, forEach, groupBy, isEmpty, isNil, map} from 'lodash';
25
+ import {castArray, find, forEach, groupBy, isEmpty, isNil, map, uniq} from 'lodash';
25
26
  import {AggregationContext} from './aggregate/AggregationContext';
26
27
  import {AggregateRow} from './row/AggregateRow';
27
28
  import {BaseRow} from './row/BaseRow';
@@ -94,6 +95,7 @@ export class View extends HoistBase {
94
95
  private _rowDatas: ViewRowData[] = null;
95
96
  private _leafMap: Map<StoreRecordId, LeafRow> = null;
96
97
  private _recordMap: Map<StoreRecordId, StoreRecord> = null;
98
+ private _bucketDependentFields = new Set<string>();
97
99
  _aggContext: AggregationContext = null;
98
100
  _rowCache: Map<string, BaseRow> = null;
99
101
 
@@ -207,7 +209,7 @@ export class View extends HoistBase {
207
209
  }
208
210
 
209
211
  @action
210
- noteCubeUpdated(changeLog: PlainObject) {
212
+ noteCubeUpdated(changeLog: StoreChangeLog) {
211
213
  const simpleUpdates = this.getSimpleUpdates(changeLog);
212
214
 
213
215
  if (!simpleUpdates) {
@@ -277,6 +279,8 @@ export class View extends HoistBase {
277
279
  {dimensions, includeRoot} = query,
278
280
  rootId = 'root';
279
281
 
282
+ this._bucketDependentFields.clear();
283
+
280
284
  const records = this._aggContext.filteredRecords;
281
285
  const leafMap: Map<StoreRecordId, LeafRow> = new Map();
282
286
  let newRows = this.groupAndInsertRecords(records, dimensions, rootId, {}, leafMap);
@@ -363,10 +367,12 @@ export class View extends HoistBase {
363
367
  const bucketSpec = query.bucketSpecFn(rows);
364
368
  if (!bucketSpec) return rows;
365
369
 
366
- const {name: bucketName, bucketFn} = bucketSpec,
370
+ const {name: bucketName, bucketFn, dependentFields} = bucketSpec,
367
371
  buckets: Record<string, BaseRow[]> = {},
368
372
  ret: BaseRow[] = [];
369
373
 
374
+ dependentFields.forEach(it => this._bucketDependentFields.add(it));
375
+
370
376
  // Determine which bucket to put this row into (if any)
371
377
  rows.forEach(row => {
372
378
  const bucketVal = bucketFn(row);
@@ -394,14 +400,14 @@ export class View extends HoistBase {
394
400
 
395
401
  // return a list of simple data updates we can apply to leaves.
396
402
  // false if leaf population changing, or aggregations are complex
397
- private getSimpleUpdates(t): StoreRecord[] | false {
403
+ private getSimpleUpdates(t: StoreChangeLog): StoreRecord[] | false {
398
404
  if (!t) return [];
399
405
  if (!this.aggregatorsAreSimple) return false;
400
406
  const {_leafMap, query} = this;
401
407
 
402
408
  // 1) Simple case: no filter
403
409
  if (!query.filter) {
404
- return isEmpty(t.add) && isEmpty(t.remove) && !this.hasDimUpdates(t.update)
410
+ return isEmpty(t.add) && isEmpty(t.remove) && !this.hasDimOrBucketUpdates(t.update)
405
411
  ? t.update
406
412
  : false;
407
413
  }
@@ -425,19 +431,21 @@ export class View extends HoistBase {
425
431
 
426
432
  // 2c) Examine the final set of updates for any changes to dimension field values which would
427
433
  // require rebuilding the row hierarchy
428
- if (this.hasDimUpdates(ret)) return false;
434
+ if (this.hasDimOrBucketUpdates(ret)) return false;
429
435
 
430
436
  return ret;
431
437
  }
432
438
 
433
- private hasDimUpdates(update: StoreRecord[]): boolean {
434
- const {dimensions} = this.query;
435
- if (isEmpty(dimensions)) return false;
439
+ private hasDimOrBucketUpdates(update: StoreRecord[]): boolean {
440
+ const {dimensions} = this.query,
441
+ bucketDependentFields = Array.from(this._bucketDependentFields);
442
+
443
+ if (isEmpty(dimensions) && isEmpty(bucketDependentFields)) return false;
436
444
 
437
- const dimNames = dimensions.map(it => it.name);
445
+ const fieldNames = uniq([...dimensions.map(it => it.name), ...bucketDependentFields]);
438
446
  for (const rec of update) {
439
447
  const curRec = this._leafMap.get(rec.id);
440
- if (dimNames.some(name => rec.data[name] !== curRec.data[name])) return true;
448
+ if (fieldNames.some(name => rec.data[name] !== curRec.data[name])) return true;
441
449
  }
442
450
 
443
451
  return false;
@@ -13,7 +13,7 @@ import {View} from '../View';
13
13
  /**
14
14
  * Row within a dataset produced by a Cube / View representing aggregated data on a dimension that
15
15
  * has been further grouped into a dynamic child "bucket" - a subset of the dimension-level
16
- * {@link AggregateRow} produced as per a specified {@link Query.bucketSpecFn}.
16
+ * {@link AggregateRow} produced as per a specified {@link BucketSpecFn}.
17
17
  *
18
18
  * This is an internal data structure - {@link ViewRowData} is the public row-level data API.
19
19
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "78.0.0-SNAPSHOT.1763665705643",
3
+ "version": "78.0.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",