diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index eaa8d8e45..8946e8add 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -10,10 +10,13 @@ import { ColumnCellClassRuleFn, ColumnGroup, ColumnGroupSpec, + ColumnOrGroup, + ColumnOrGroupSpec, ColumnSpec, GridAutosizeMode, GridFilterModelConfig, GridGroupSortFn, + isColumnSpec, TreeStyle } from '@xh/hoist/cmp/grid'; import {GridFilterModel} from '@xh/hoist/cmp/grid/filter/GridFilterModel'; @@ -22,6 +25,7 @@ import { Awaitable, HoistModel, HSide, + LoadSpec, managed, PlainObject, SizingMode, @@ -32,7 +36,9 @@ import { XH } from '@xh/hoist/core'; import { + Field, FieldSpec, + getFieldName, Store, StoreConfig, StoreRecord, @@ -87,7 +93,6 @@ import { isEmpty, isFunction, isNil, - isPlainObject, isString, isUndefined, keysIn, @@ -117,7 +122,7 @@ import { export interface GridConfig { /** Columns for this grid. */ - columns?: Array; + columns?: ColumnOrGroupSpec[]; /** Column configs to be set on all columns. Merges deeply. */ colDefaults?: Partial; @@ -454,7 +459,7 @@ export class GridModel extends HoistModel { //------------------------ // Observable API //------------------------ - @observable.ref columns: Array = []; + @observable.ref columns: ColumnOrGroup[] = []; @observable.ref columnState: ColumnState[] = []; @observable.ref expandState: any = {}; @observable.ref sortBy: GridSorter[] = []; @@ -1146,7 +1151,7 @@ export class GridModel extends HoistModel { this.sortBy = newSorters; } - override async doLoadAsync(loadSpec) { + override async doLoadAsync(loadSpec: LoadSpec) { // Delegate to any store that has load support return (this.store as any).loadSupport?.loadAsync(loadSpec); } @@ -1167,8 +1172,7 @@ export class GridModel extends HoistModel { } @action - setColumns(colConfigs: Array) { - this.validateColConfigs(colConfigs); + setColumns(colConfigs: ColumnOrGroupSpec[]) { colConfigs = this.enhanceColConfigsFromStore(colConfigs); const columns = compact(colConfigs.map(c => this.buildColumn(c))); @@ -1362,7 +1366,7 @@ export class GridModel extends HoistModel { } /** Return matching leaf-level Column object from the provided collection. */ - findColumn(cols: Array, colId: string): Column { + findColumn(cols: ColumnOrGroup[], colId: string): Column { for (let col of cols) { if (col instanceof ColumnGroup) { const ret = this.findColumn(col.children, colId); @@ -1375,7 +1379,7 @@ export class GridModel extends HoistModel { } /** Return matching ColumnGroup from the provided collection. */ - findColumnGroup(cols: Array, groupId: string): ColumnGroup { + findColumnGroup(cols: ColumnOrGroup[], groupId: string): ColumnGroup { for (let col of cols) { if (col instanceof ColumnGroup) { if (col.groupId === groupId) return col; @@ -1595,7 +1599,7 @@ export class GridModel extends HoistModel { //----------------------- // Implementation //----------------------- - private buildColumn(config: ColumnGroupSpec | ColumnSpec, borderedGroup?: ColumnGroupSpec) { + private buildColumn(config: ColumnOrGroupSpec, borderedGroup?: ColumnGroupSpec): ColumnOrGroup { // Merge leaf config with defaults. // Ensure *any* tooltip setting on column itself always wins. if (this.colDefaults && !this.isGroupSpec(config)) { @@ -1609,12 +1613,8 @@ export class GridModel extends HoistModel { if (this.isGroupSpec(config)) { if (config.borders !== false) borderedGroup = config; - const children = compact( - config.children.map(c => this.buildColumn(c, borderedGroup)) - ) as Array; - return !isEmpty(children) - ? new ColumnGroup(config as ColumnGroupSpec, this, children) - : null; + const children = compact(config.children.map(c => this.buildColumn(c, borderedGroup))); + return !isEmpty(children) ? new ColumnGroup(config, this, children) : null; } if (borderedGroup) { @@ -1649,19 +1649,23 @@ export class GridModel extends HoistModel { } } - private gatherLeaves(columns, leaves = []) { + private gatherLeaves(columns: ColumnOrGroup[], leaves: Column[] = []): Column[] { columns.forEach(col => { - if (col.groupId) this.gatherLeaves(col.children, leaves); - if (col.colId) leaves.push(col); + if (col instanceof ColumnGroup) { + this.gatherLeaves(col.children, leaves); + } else { + leaves.push(col); + } }); return leaves; } - private collectIds(cols, ids = []) { + private collectIds(cols: ColumnOrGroup[], ids: string[] = []) { cols.forEach(col => { - if (col.colId) ids.push(col.colId); - if (col.groupId) { + if (col instanceof Column) { + ids.push(col.colId); + } else { ids.push(col.groupId); this.collectIds(col.children, ids); } @@ -1686,47 +1690,31 @@ export class GridModel extends HoistModel { // so it can be better re-used across Hoist APIs such as `Filter` and `FormModel`. However for // convenience, a `GridModel.store` config can also be very minimal (or non-existent), and // in this case GridModel should work out the required Store fields from column definitions. - private parseAndSetColumnsAndStore(colConfigs, store = {}) { - // 1) Validate configs. - this.validateStoreConfig(store); - this.validateColConfigs(colConfigs); - - // 2) Enhance colConfigs with field-level metadata provided by store, if any. - colConfigs = this.enhanceColConfigsFromStore(colConfigs, store); + private parseAndSetColumnsAndStore( + colConfigs: ColumnOrGroupSpec[], + storeOrConfig: Store | StoreConfig = {} + ) { + // Enhance colConfigs with field-level metadata provided by store, if any. + colConfigs = this.enhanceColConfigsFromStore(colConfigs, storeOrConfig); - // 3) Create and set columns with (possibly) enhanced configs. + // Create and set columns with (possibly) enhanced configs. this.setColumns(colConfigs); - let newStore: Store; - // 4) Create store if needed - if (isPlainObject(store)) { - store = this.enhanceStoreConfigFromColumns(store); - newStore = new Store({loadTreeData: this.treeMode, ...store}); - newStore.xhImpl = this.xhImpl; - this.markManaged(newStore); + // Set or create Store as needed. + let store: Store; + if (storeOrConfig instanceof Store) { + store = storeOrConfig; } else { - newStore = store as Store; + storeOrConfig = this.enhanceStoreConfigFromColumns(storeOrConfig); + store = new Store({loadTreeData: this.treeMode, ...storeOrConfig}); + store.xhImpl = this.xhImpl; + this.markManaged(store); } - this.store = newStore; - } - - private validateStoreConfig(store) { - throwIf( - !(store instanceof Store || isPlainObject(store)), - 'GridModel.store config must be either an instance of a Store or a config to create one.' - ); - } - - private validateColConfigs(colConfigs) { - throwIf(!isArray(colConfigs), 'GridModel.columns config must be an array.'); - throwIf( - colConfigs.some(c => !isPlainObject(c)), - 'GridModel.columns config only accepts plain objects for Column or ColumnGroup configs.' - ); + this.store = store; } - private validateColumns(cols) { + private validateColumns(cols: ColumnOrGroup[]) { if (isEmpty(cols)) return; const ids = this.collectIds(cols); @@ -1782,16 +1770,30 @@ export class GridModel extends HoistModel { // Selectively enhance raw column configs with field-level metadata from store.fields and/or // field config partials provided by the column configs themselves. - private enhanceColConfigsFromStore(colConfigs, storeOrConfig?) { + private enhanceColConfigsFromStore( + colConfigs: ColumnOrGroupSpec[], + storeOrConfig?: Store | StoreConfig + ): ColumnOrGroupSpec[] { const store = storeOrConfig || this.store, storeFields = store?.fields, - fieldsByName = {}; + fieldsByName: Record = {}; // Extract field definitions in all supported forms: pull Field instances/configs from - // storeFields first, then fill in with any col-level `field` config objects. - storeFields?.forEach(sf => (fieldsByName[sf.name] = sf)); + // storeFields first... + storeFields?.forEach(sf => { + if (sf && !isString(sf)) { + fieldsByName[sf.name] = sf; + } + }); + + // Then fill in with any col-level `field` config objects. colConfigs.forEach(cc => { - if (isPlainObject(cc.field) && !fieldsByName[cc.field.name]) { + if ( + isColumnSpec(cc) && + cc.field && + !isString(cc.field) && + !fieldsByName[cc.field.name] + ) { fieldsByName[cc.field.name] = cc.field; } }); @@ -1800,16 +1802,17 @@ export class GridModel extends HoistModel { const numTypes = ['int', 'number'], dateTypes = ['date', 'localDate']; + return colConfigs.map(col => { // Recurse into children for column groups - if (col.children) { + if (!isColumnSpec(col)) { return { ...col, children: this.enhanceColConfigsFromStore(col.children, storeOrConfig) }; } - const colFieldName = isPlainObject(col.field) ? col.field.name : col.field, + const colFieldName = getFieldName(col.field), field = fieldsByName[colFieldName]; if (!field) return col; @@ -1837,9 +1840,9 @@ export class GridModel extends HoistModel { // Ensure store config has a complete set of fields for all configured columns. Note this // requires columns to have been constructed and set, and will only work with a raw store // config object, not an instance. - private enhanceStoreConfigFromColumns(storeConfig) { + private enhanceStoreConfigFromColumns(storeConfig: StoreConfig) { const fields = storeConfig.fields ?? [], - storeFieldNames = fields.map(it => it.name ?? it), + storeFieldNames = fields.map(it => getFieldName(it)), leafColsByFieldName = this.leafColsByFieldName(); const newFields: FieldSpec[] = []; @@ -1886,31 +1889,33 @@ export class GridModel extends HoistModel { return sizingMode; } - private parseSelModel(selModel): StoreSelectionModel { - const {store} = this; - selModel = withDefault(selModel, XH.isMobileApp ? 'disabled' : 'single'); - + private parseSelModel(selModel: GridConfig['selModel']): StoreSelectionModel { + // Return actual instance directly. if (selModel instanceof StoreSelectionModel) { return selModel; } - if (isPlainObject(selModel)) { - return this.markManaged(new StoreSelectionModel({...selModel, store, xhImpl: true})); + // Default unspecified based on platform, treat explicit null as disabled. + if (selModel === undefined) { + selModel = XH.isMobileApp ? 'disabled' : 'single'; + } else if (selModel === null) { + selModel = 'disabled'; } - // Assume its just the mode... - let mode: any = 'single'; + // Strings specify the mode. if (isString(selModel)) { - mode = selModel; - } else if (selModel === null) { - mode = 'disabled'; + selModel = {mode: selModel}; } - return this.markManaged(new StoreSelectionModel({mode, store, xhImpl: true})); + + return this.markManaged( + new StoreSelectionModel({...selModel, store: this.store, xhImpl: true}) + ); } - private parseFilterModel(filterModel) { + private parseFilterModel(filterModel: GridConfig['filterModel']) { if (XH.isMobileApp || !filterModel) return null; - filterModel = isPlainObject(filterModel) ? filterModel : {}; + + filterModel = filterModel === true ? {} : filterModel; return new GridFilterModel({bind: this.store, ...filterModel}, this); } @@ -1921,17 +1926,15 @@ export class GridModel extends HoistModel { }; } - private parseChooserModel(chooserModel): HoistModel { - const modelClass = XH.isMobileApp ? MobileColChooserModel : DesktopColChooserModel; + private parseChooserModel(chooserModel: GridConfig['colChooserModel']): HoistModel { + if (!chooserModel) return null; - if (isPlainObject(chooserModel)) { - return this.markManaged(new modelClass({...chooserModel, gridModel: this})); - } - - return chooserModel ? this.markManaged(new modelClass({gridModel: this})) : null; + const modelClass = XH.isMobileApp ? MobileColChooserModel : DesktopColChooserModel; + chooserModel = chooserModel === true ? {} : chooserModel; + return this.markManaged(new modelClass({...chooserModel, gridModel: this})); } - private isGroupSpec(col: ColumnGroupSpec | ColumnSpec): col is ColumnGroupSpec { + private isGroupSpec(col: ColumnOrGroupSpec): col is ColumnGroupSpec { return 'children' in col; } diff --git a/cmp/grid/Types.ts b/cmp/grid/Types.ts index 8b7c21ac8..d058a4af5 100644 --- a/cmp/grid/Types.ts +++ b/cmp/grid/Types.ts @@ -7,23 +7,23 @@ import {GridFilterFieldSpecConfig} from '@xh/hoist/cmp/grid/filter/GridFilterFieldSpec'; import {HSide, PersistOptions, Some} from '@xh/hoist/core'; -import {Store, StoreRecord, View} from '@xh/hoist/data'; -import {ReactElement, ReactNode} from 'react'; -import {Column} from './columns/Column'; -import {ColumnGroup} from './columns/ColumnGroup'; -import {GridModel} from './GridModel'; +import {FilterBindTarget, FilterValueSource, Store, StoreRecord} from '@xh/hoist/data'; import type { CellClassParams, + CustomCellEditorProps, HeaderClassParams, HeaderValueGetterParams, ICellRendererParams, IRowNode, ITooltipParams, RowClassParams, - ValueSetterParams, - CustomCellEditorProps + ValueSetterParams } from '@xh/hoist/kit/ag-grid'; +import type {ReactElement, ReactNode} from 'react'; +import type {Column, ColumnSpec} from './columns/Column'; +import type {ColumnGroup, ColumnGroupSpec} from './columns/ColumnGroup'; +import type {GridModel} from './GridModel'; export interface ColumnState { colId: string; @@ -87,10 +87,11 @@ export interface GridModelPersistOptions extends PersistOptions { export interface GridFilterModelConfig { /** - * Store / Cube View to be filtered as column filters are applied. Defaulted to the - * gridModel's store. + * Target (typically a {@link Store} or Cube {@link View}) to be filtered as column filters + * are applied and used as a source for unique values displayed in the filtering UI when + * applicable. Defaulted to the gridModel's store. */ - bind?: Store | View; + bind?: GridFilterBindTarget; /** * True to update filters immediately after each change made in the column-based filter UI. @@ -100,8 +101,8 @@ export interface GridFilterModelConfig { /** * Specifies the fields this model supports for filtering. Should be configs for - * {@link GridFilterFieldSpec}, string names to match with Fields in bound Store/View, or omitted - * entirely to indicate that all fields should be filter-enabled. + * {@link GridFilterFieldSpec}, string names to match with Fields in bound Store/View, or + * omitted entirely to indicate that all fields should be filter-enabled. */ fieldSpecs?: Array; @@ -109,6 +110,12 @@ export interface GridFilterModelConfig { fieldSpecDefaults?: Omit; } +/** + * {@link GridFilterModel} currently accepts a single `bind` target that also provides available + * values. Note that both `Store` and `View` satisfy this intersection. + */ +export interface GridFilterBindTarget extends FilterBindTarget, FilterValueSource {} + /** * Renderer for a group row * @param context - The group renderer params from ag-Grid @@ -142,6 +149,13 @@ export interface ColChooserConfig { height?: string | number; } +export type ColumnOrGroup = Column | ColumnGroup; +export type ColumnOrGroupSpec = ColumnSpec | ColumnGroupSpec; + +export function isColumnSpec(spec: ColumnOrGroupSpec): spec is ColumnSpec { + return !('children' in spec); +} + /** * Sort comparator function for a grid column. Note that this comparator will also be called if * agGrid-provided column filtering is enabled: it is used to sort values shown for set filter @@ -248,7 +262,7 @@ export type ColumnTooltipFn = ( * @returns CSS class(es) to use. */ export type ColumnHeaderClassFn = (context: { - column: Column | ColumnGroup; + column: ColumnOrGroup; gridModel: GridModel; agParams: HeaderClassParams; }) => Some; diff --git a/cmp/grid/columns/ColumnGroup.ts b/cmp/grid/columns/ColumnGroup.ts index d62311f62..2300a1d68 100644 --- a/cmp/grid/columns/ColumnGroup.ts +++ b/cmp/grid/columns/ColumnGroup.ts @@ -13,7 +13,7 @@ import {throwIf, withDefault} from '@xh/hoist/utils/js'; import {clone, isEmpty, isFunction, isString, keysIn} from 'lodash'; import {ReactNode} from 'react'; import {GridModel} from '../GridModel'; -import {ColumnHeaderClassFn, ColumnHeaderNameFn} from '../Types'; +import {ColumnHeaderClassFn, ColumnHeaderNameFn, ColumnOrGroup} from '../Types'; import {Column, ColumnSpec} from './Column'; export interface ColumnGroupSpec { @@ -51,7 +51,7 @@ export interface ColumnGroupSpec { * Provided to GridModels as plain configuration objects. */ export class ColumnGroup { - readonly children: Array; + readonly children: ColumnOrGroup[]; readonly gridModel: GridModel; readonly groupId: string; readonly headerName: ReactNode | ColumnHeaderNameFn; @@ -79,11 +79,7 @@ export class ColumnGroup { * * @internal */ - constructor( - config: ColumnGroupSpec, - gridModel: GridModel, - children: Array - ) { + constructor(config: ColumnGroupSpec, gridModel: GridModel, children: ColumnOrGroup[]) { const { children: childrenSpecs, groupId, diff --git a/cmp/grid/filter/GridFilterModel.ts b/cmp/grid/filter/GridFilterModel.ts index 68c2c8096..b80df4070 100644 --- a/cmp/grid/filter/GridFilterModel.ts +++ b/cmp/grid/filter/GridFilterModel.ts @@ -5,7 +5,12 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {GridFilterFieldSpec, GridFilterModelConfig} from '@xh/hoist/cmp/grid'; +import { + GridFilterBindTarget, + GridFilterFieldSpec, + GridFilterFieldSpecConfig, + GridFilterModelConfig +} from '@xh/hoist/cmp/grid'; import {HoistModel, managed} from '@xh/hoist/core'; import { CompoundFilter, @@ -13,8 +18,6 @@ import { Filter, FilterLike, flattenFilter, - Store, - View, withFilterByField, withFilterByTypes } from '@xh/hoist/data'; @@ -31,7 +34,7 @@ export class GridFilterModel extends HoistModel { override xhImpl = true; gridModel: GridModel; - bind: Store | View; + bind: GridFilterBindTarget; @bindable commitOnChange: boolean; @managed fieldSpecs: GridFilterFieldSpec[] = []; @@ -66,7 +69,7 @@ export class GridFilterModel extends HoistModel { setColumnFilters(field: string, filter: FilterLike) { // If current bound filter is a CompoundFilter for a single column, wrap it // in an 'AND' CompoundFilter so new columns get 'ANDed' alongside it. - let currFilter = this.filter as any; + let currFilter: FilterLike = this.filter; if (currFilter instanceof CompoundFilter && currFilter.field) { currFilter = {filters: [currFilter], op: 'AND'}; } @@ -144,7 +147,10 @@ export class GridFilterModel extends HoistModel { //-------------------------------- // Implementation //-------------------------------- - private parseFieldSpecs(specs, fieldSpecDefaults) { + private parseFieldSpecs( + specs: Array, + fieldSpecDefaults: Omit + ) { const {bind} = this; // If no specs provided, include all source fields. diff --git a/cmp/grid/impl/Utils.ts b/cmp/grid/impl/Utils.ts index 0a4fbddd3..9585223e2 100644 --- a/cmp/grid/impl/Utils.ts +++ b/cmp/grid/impl/Utils.ts @@ -4,8 +4,8 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Column, ColumnGroup, ColumnRenderer, GroupRowRenderer} from '@xh/hoist/cmp/grid'; -import {HeaderClassParams} from '@xh/hoist/kit/ag-grid'; +import {Column, ColumnOrGroup, ColumnRenderer, GroupRowRenderer} from '@xh/hoist/cmp/grid'; +import type {HeaderClassParams} from '@xh/hoist/kit/ag-grid'; import {logWarn} from '@xh/hoist/utils/js'; import {castArray, isFunction} from 'lodash'; @@ -31,9 +31,7 @@ export function managedRenderer( * * @internal */ -export function getAgHeaderClassFn( - column: Column | ColumnGroup -): (params: HeaderClassParams) => string[] { +export function getAgHeaderClassFn(column: ColumnOrGroup): (params: HeaderClassParams) => string[] { const {headerClass, headerAlign, gridModel} = column; return agParams => { diff --git a/cmp/tab/TabContainerModel.ts b/cmp/tab/TabContainerModel.ts index c6f56931a..d3706e0f0 100644 --- a/cmp/tab/TabContainerModel.ts +++ b/cmp/tab/TabContainerModel.ts @@ -123,7 +123,7 @@ export class TabContainerModel extends HoistModel { /** * @param config - TabContainer configuration. - * @param [depth] - Depth in hierarchy of nested TabContainerModels. Not for application use. + * @param depth - Depth in hierarchy of nested TabContainerModels. Not for application use. */ constructor( { @@ -138,7 +138,7 @@ export class TabContainerModel extends HoistModel { xhImpl = false, switcher = {mode: 'static'} }: TabContainerConfig, - depth = 0 + depth: number = 0 ) { super(); makeObservable(this); diff --git a/data/Field.ts b/data/Field.ts index e9cfad2e7..dbd6637b5 100644 --- a/data/Field.ts +++ b/data/Field.ts @@ -181,3 +181,8 @@ export function genDisplayName(fieldName: string): string { // Handle common cases of "id" -> "ID" and "foo_id" -> "Foo ID" (vs "Foo Id") return startCase(fieldName).replace(/(^| )Id\b/g, '$1ID'); } + +/** Convenience function to return the name of a field from one of several common inputs. */ +export function getFieldName(field: string | Field | FieldSpec): string { + return field ? (isString(field) ? field : field.name) : null; +} diff --git a/data/cube/Cube.ts b/data/cube/Cube.ts index 38c20f09c..4a3aceeca 100755 --- a/data/cube/Cube.ts +++ b/data/cube/Cube.ts @@ -8,17 +8,17 @@ import {HoistBase, managed, PlainObject, Some} from '@xh/hoist/core'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {forEachAsync} from '@xh/hoist/utils/async'; -import {CubeField, CubeFieldSpec} from './CubeField'; -import {ViewRowData} from './ViewRowData'; -import {Query, QueryConfig} from './Query'; -import {View} from './View'; +import {defaultsDeep, isEmpty} from 'lodash'; import {Store, StoreRecordIdSpec, StoreTransaction} from '../Store'; import {StoreRecord} from '../StoreRecord'; +import {BucketSpec} from './BucketSpec'; +import {CubeField, CubeFieldSpec} from './CubeField'; +import {Query, QueryConfig} from './Query'; import {AggregateRow} from './row/AggregateRow'; -import {BucketRow} from './row/BucketRow'; import {BaseRow} from './row/BaseRow'; -import {BucketSpec} from './BucketSpec'; -import {defaultsDeep, isEmpty} from 'lodash'; +import {BucketRow} from './row/BucketRow'; +import {View} from './View'; +import {ViewRowData} from './ViewRowData'; export interface CubeConfig { fields: CubeField[] | CubeFieldSpec[]; @@ -160,6 +160,10 @@ export class Cube extends HoistBase { return this._connectedViews.size; } + getField(name: string): CubeField { + return this.store.getField(name) as CubeField; + } + //------------------ // Querying API //-----------------