Skip to content

Enhancement to API for View: Return ViewRowData #4055

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

Merged
merged 8 commits into from
Aug 4, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
which `dimensions` are provided to the model.
* Added new `ClipboardButton.errorMessage` prop to customize or suppress a toast alert if the copy
operation fails. Set to `false` to fail silently (the behavior prior to this change).
* Cube Views now emit data objects of type `ViewRowData`, rather than an anonymous `PlainObject`.
This new object supports several documented properties, including a useful `cubeLeaves` property,
which can be activated via the `Query.provideLeaves` property.


### 🐞 Bug Fixes

Expand Down Expand Up @@ -54,6 +58,8 @@
* Removed deprecated `FetchService.setDefaultHeaders`
* Removed deprecated `FetchService.setDefaultTimeout`
* Removed deprecated `IdentityService.logoutAsync`
* Removed undocumented `_meta` pointer on row objects returned by `View`. Use the documented
properties on the new `ViewRowData` class instead.

### ✨ Styles

Expand Down
40 changes: 4 additions & 36 deletions admin/tabs/activity/tracking/ActivityTrackingModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {FormModel} from '@xh/hoist/cmp/form';
import {ColumnRenderer, ColumnSpec, GridModel, TreeStyle} from '@xh/hoist/cmp/grid';
import {GroupingChooserModel} from '@xh/hoist/cmp/grouping';
import {HoistModel, LoadSpec, managed, PlainObject, XH} from '@xh/hoist/core';
import {Cube, CubeFieldSpec, FieldSpec, StoreRecord} from '@xh/hoist/data';
import {Cube, CubeFieldSpec, FieldSpec, ViewRowData} from '@xh/hoist/data';
import {dateRenderer, dateTimeSecRenderer, numberRenderer} from '@xh/hoist/format';
import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
import {LocalDate} from '@xh/hoist/utils/datetime';
Expand Down Expand Up @@ -135,7 +135,8 @@ export class ActivityTrackingModel extends HoistModel implements ActivityDetailP
},
{
track: () => this.gridModel.selectedRecords,
run: recs => (this.trackLogs = this.getAllLeafRows(recs)),
run: recs =>
(this.trackLogs = recs.flatMap(r => (r.raw as ViewRowData).cubeLeaves)),
debounce: 100
}
);
Expand Down Expand Up @@ -231,29 +232,13 @@ export class ActivityTrackingModel extends HoistModel implements ActivityDetailP
data = cube.executeQuery({
dimensions,
includeRoot: true,
includeLeaves: true
provideLeaves: true
});

data.forEach(node => this.separateLeafRows(node));
gridModel.loadData(data);
await gridModel.preSelectFirstAsync();
}

// Cube emits leaves in "children" collection - rename that collection to "leafRows" so we can
// carry the leaves with the record, but deliberately not show them in the tree grid. We only
// want the tree grid to show aggregate records.
private separateLeafRows(node) {
if (isEmpty(node.children)) return;

const childrenAreLeaves = !node.children[0].children;
if (childrenAreLeaves) {
node.leafRows = node.children;
delete node.children;
} else {
node.children.forEach(child => this.separateLeafRows(child));
}
}

private cubeLabelComparator(valA, valB, sortDir, abs, {recordA, recordB, defaultComparator}) {
const rawA = recordA?.raw,
rawB = recordB?.raw,
Expand Down Expand Up @@ -291,23 +276,6 @@ export class ActivityTrackingModel extends HoistModel implements ActivityDetailP
};
}

// Extract all leaf, track-entry-level rows from an aggregate record (at any level).
private getAllLeafRows(aggRecs: StoreRecord[], ret = []): PlainObject[] {
if (isEmpty(aggRecs)) return [];

aggRecs.forEach(aggRec => {
if (aggRec.children.length) {
this.getAllLeafRows(aggRec.children, ret);
} else if (aggRec.raw.leafRows) {
aggRec.raw.leafRows.forEach(leaf => {
ret.push({...leaf});
});
}
});

return ret;
}

//------------------------
// Impl - core data models
//------------------------
Expand Down
3 changes: 3 additions & 0 deletions data/cube/BucketSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import {BaseRow} from './row/BaseRow';

/**
* @see BucketSpecFn
*/
export class BucketSpec {
name: string;
bucketFn: (row: BaseRow) => string;
Expand Down
15 changes: 10 additions & 5 deletions data/cube/Cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import {HoistBase, managed, PlainObject} from '@xh/hoist/core';
import {ViewRowData} from '@xh/hoist/data/cube/ViewRowData';
import {action, makeObservable, observable} from '@xh/hoist/mobx';
import {forEachAsync} from '@xh/hoist/utils/async';
import {CubeField, CubeFieldSpec} from './CubeField';
Expand Down Expand Up @@ -143,7 +144,7 @@ export class Cube extends HoistBase {
* @param query - Config for query defining the shape of the view.
* @returns data containing the results of the query as a hierarchical set of rows.
*/
executeQuery(query: QueryConfig): any {
executeQuery(query: QueryConfig): ViewRowData[] {
const q = new Query({...query, cube: this});
const view = new View({query: q}),
rows = view.result.rows;
Expand Down Expand Up @@ -300,11 +301,15 @@ export type LockFn = (row: AggregateRow | BucketRow) => boolean;
export type OmitFn = (row: AggregateRow | BucketRow) => boolean;

/**
* Function to be called for each dimension to determine if children of said dimension should be
* bucketed into additional dynamic dimensions.
* Function to be called for rows making up an aggregated dimension to determine if the children of
* that dimension should be dynamically bucketed into additional sub-groupings.
*
* An example use case would be a grouped collection of portfolio positions, where any closed
* positions are identified as such by this function and bucketed into a "Closed" sub-grouping,
* without having to add something like an "openClosed" dimension that would apply to all
* aggregations and create an unwanted "Open" grouping.
*
* @param rows - the rows being checked for bucketing
* @returns a BucketSpec for configuring the bucket to place child rows into, or null to perform
* no bucketing
* @returns BucketSpec for configuring dynamic sub-aggregations, or null to perform no bucketing.
*/
export type BucketSpecFn = (rows: BaseRow[]) => BucketSpec;
93 changes: 71 additions & 22 deletions data/cube/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,90 @@
* Copyright © 2025 Extremely Heavy Industries Inc.
*/

import {BucketSpecFn, Filter, LockFn, OmitFn, parseFilter, StoreRecord} from '@xh/hoist/data';
import {isEqual, find} from 'lodash';
import {FilterLike, FilterTestFn} from '../filter/Types';
import {CubeField} from './CubeField';
import {Cube} from './Cube';
import {
BucketSpecFn,
Filter,
FilterLike,
FilterTestFn,
LockFn,
OmitFn,
parseFilter,
StoreRecord
} from '@xh/hoist/data';
import {throwIf} from '@xh/hoist/utils/js';
import {find, isEqual} from 'lodash';
import {Cube} from './Cube';
import {CubeField} from './CubeField';

/**
* Queries determine what data is extracted from a cube and how it should be grouped + aggregated.
*
* Note that if no dimensions are provided, 'includeRoot' or 'includeLeaves' should be true -
* otherwise no data will be returned!
* Queries determine what data is extracted, grouped, and aggregated from a {@link Cube}.
*/
export interface QueryConfig {
/**
* Associated Cube. Required, but note that `Cube.executeQuery()` will install a reference to
* itself on the query config (automatically)
* The Cube to query. Required, but note that the preferred {@link Cube.executeQuery} API will
* install a reference to itself on the query config (automatically).
*/
cube?: Cube;

/**
* Fields or field names. If unspecified will include all available fields
* from the source Cube, otherwise supply a subset to optimize aggregation performance.
* Fields or field names. If unspecified will include all available {@link Cube.fields}.
* Specify a subset to optimize aggregation performance.
*/
fields?: string[] | CubeField[];

/**
* Fields or field names to group on. Any fields provided must also be in fields config, above. If none
* given the resulting data will not be grouped.
* Fields or field names on which data should be grouped and aggregated. These are the ordered
* grouping levels in the resulting hierarchy - e.g. ['Country', 'State', 'City'].
*
* Any fields provided here must also be included in the `fields` array, if specified.
*
* If not provided or empty, the resulting data will not be grouped. Specify 'includeRoot' or
* 'includeLeaves' in that case, otherwise no data will be returned.
*/
dimensions?: string[] | CubeField[];

/**
* One or more filters or configs to create one. If an array, a single 'AND' filter will
* be created.
* Filters to apply to leaf data, or configs to create. Note that leaf data will be filtered
* and then aggregated - i.e. the filters provided here will filter in/out the lowest level
* facts and _won't_ operate directly on any aggregates.
*
* Arrays will be combined into a single 'AND' CompoundFilter.
*/
filter?: FilterLike;

/**
* IncludeRoot?: True to include a synthetic root node in the return with grand total
* aggregate values.
* True to include a synthetic root node in the return with grand totals (aggregations across
* all data returned by the query). Pairs well with {@link StoreConfig.loadRootAsSummary} and
* {@link GridConfig.showSummary} to display a docked grand total row for grids rendering
* Cube results.
*/
includeRoot?: boolean;

/** True to include leaf nodes in return.*/
/**
* True to include leaf nodes (the "flat" facts originally loaded into the Cube) as the
* {@link ViewRowData.children} of the lowest level of aggregated `dimensions`.
*
* False (the default) to only return aggregate rows based on requested `dimensions`.
*
* Useful when you wish to e.g. load Cube results into a tree grid and allow users to expand
* aggregated groups all the way out to see the source data. See also `provideLeaves`, which
* will provide access to these nodes without exposing as `children`.
*/
includeLeaves?: boolean;

/**
* True to provide access to leaf nodes via the {@link ViewRowData.cubeLeaves} getter on the
* lowest level of aggregated `dimensions`. This will allow programmatic access to the leaves
* used to produce a given aggregation, without exposing them as `children` in a way that would
* cause them to be rendered in a tree grid.
*
* Useful when e.g. a full leaf-level drill-down is not desired, but the app still needs
* access to those leaves to display in a separate view or for further processing.
*
* See also the more common `includeLeaves`.
*/
provideLeaves?: boolean;

/**
* True (default) to recursively omit single-child parents in the hierarchy.
* Apps can implement further omit logic using `omitFn`.
Expand All @@ -60,14 +97,21 @@ export interface QueryConfig {

/**
* Optional function to be called for each aggregate node to determine if it should be "locked",
* preventing drill-down into its children. Defaults to Cube.lockFn.
* preventing drill-down into its children.
*
* Defaults to {@link Cube.lockFn}.
*/
lockFn?: LockFn;

/**
* Optional function to be called for each dimension during row generation to determine if the
* children of that dimension should be bucketed into additional dynamic dimensions.
* Defaults to Cube.bucketSpecFn.
*
* This can be used to break selected aggregations into sub-groups dynamically, without having
* to define another dimension in the Cube and have it apply to all aggregations. See the
* {@link BucketSpec} interface for additional information.
*
* Defaults to {@link Cube.bucketSpecFn}.
*/
bucketSpecFn?: BucketSpecFn;

Expand All @@ -85,6 +129,7 @@ export class Query {
readonly filter: Filter;
readonly includeRoot: boolean;
readonly includeLeaves: boolean;
readonly provideLeaves: boolean;
readonly omitRedundantNodes: boolean;
readonly cube: Cube;
readonly lockFn: LockFn;
Expand All @@ -100,6 +145,7 @@ export class Query {
filter = null,
includeRoot = false,
includeLeaves = false,
provideLeaves = false,
omitRedundantNodes = true,
lockFn = cube.lockFn,
bucketSpecFn = cube.bucketSpecFn,
Expand All @@ -110,6 +156,7 @@ export class Query {
this.dimensions = this.parseDimensions(dimensions);
this.includeRoot = includeRoot;
this.includeLeaves = includeLeaves;
this.provideLeaves = provideLeaves;
this.omitRedundantNodes = omitRedundantNodes;
this.filter = parseFilter(filter);
this.lockFn = lockFn;
Expand All @@ -126,6 +173,7 @@ export class Query {
filter: this.filter,
includeRoot: this.includeRoot,
includeLeaves: this.includeLeaves,
provideLeaves: this.provideLeaves,
omitRedundantNodes: this.omitRedundantNodes,
lockFn: this.lockFn,
bucketSpecFn: this.bucketSpecFn,
Expand Down Expand Up @@ -163,6 +211,7 @@ export class Query {
this.cube === other.cube &&
this.includeRoot === other.includeRoot &&
this.includeLeaves === other.includeLeaves &&
this.provideLeaves === other.provideLeaves &&
this.omitRedundantNodes === other.omitRedundantNodes &&
this.bucketSpecFn == other.bucketSpecFn &&
this.omitFn == other.omitFn &&
Expand Down
Loading
Loading