Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0a1f119
Add generic <T, Id> params to StoreRecord
amcclain Jun 25, 2026
84a0803
Add generic <T, Id> params to Store; type record-returning read API
amcclain Jun 25, 2026
8d3883b
Add generic <T, Id> params to StoreSelectionModel
amcclain Jun 25, 2026
dc674d1
Add compile-time type-assertion fixture for data-layer generics
amcclain Jun 25, 2026
a244c39
Use optional RecordData type for data; preserve untyped-store cast co…
amcclain Jun 25, 2026
65c1b54
Type StoreRecord.data as bare T (id remains a top-level record property)
amcclain Jun 25, 2026
95e682e
Add generic <T, Id> params to GridModel; thread through store and sel…
amcclain Jun 25, 2026
415ecf6
Add generic <T, Id> params to DataViewModel and ZoneGridModel
amcclain Jun 25, 2026
635d879
Add generic <T, Id> params to UrlStore, RestStore, RestGridModel
amcclain Jun 25, 2026
bc3de6a
Migrate admin RoleModel to typed GridModel<HoistRole>; drop record.da…
amcclain Jun 25, 2026
d72fe1c
Migrate admin ClusterObjectsModel to typed GridModel<ClusterObjectRec…
amcclain Jun 25, 2026
b2947dc
Document data-layer generics
amcclain Jun 25, 2026
e71b368
Document generics in grid README and doc registry
amcclain Jun 25, 2026
9729ed1
Merge remote-tracking branch 'origin/develop' into data-layer-generics
amcclain Jun 25, 2026
8abd6be
Move data-layer generics changelog entry under 87.0.0-SNAPSHOT
amcclain Jun 25, 2026
d277564
Move generics changelog entry to Typescript API Adjustments section
amcclain Jun 25, 2026
89f0260
Rename data-layer generic params to <TData, TId>
amcclain Jun 25, 2026
416c63d
Merge remote-tracking branch 'origin/develop' into data-layer-generics
amcclain Jul 6, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@

### ⚙️ Typescript API Adjustments

* `Store`, `StoreRecord`, `StoreSelectionModel`, `GridModel`, `DataViewModel`, `ZoneGridModel`,
`RestGridModel`, and `RestStore` now accept optional generic type parameters `<TData, TId>` describing
a record's data shape and id type. Specify a shape (e.g. `new GridModel<MyRow>({...})`) to get
fully-typed `store.records`, `selectedRecord.data`, and `record.id`. Fully opt-in - untyped
usage is unchanged.
* Retyped `GridModel.colChooserModel` as the new cross-platform `IColChooserModel` interface,
replacing the bare `HoistModel` type and exposing `isOpen`, `open()`, and `close()` directly.

Expand Down
4 changes: 2 additions & 2 deletions admin/tabs/cluster/objects/ClusterObjectsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class ClusterObjectsModel extends HoistModel {
recordsRequired: true
};

@managed gridModel = new GridModel({
@managed gridModel = new GridModel<ClusterObjectRecord>({
selModel: 'multiple',
treeMode: true,
expandLevel: 2,
Expand Down Expand Up @@ -94,7 +94,7 @@ export class ClusterObjectsModel extends HoistModel {
contextMenu: [this.clearHibernateCachesAction, '-', ...GridModel.defaults.contextMenu]
});

get selectedRecord(): StoreRecord {
get selectedRecord(): StoreRecord<ClusterObjectRecord> {
return this.gridModel.selectedRecord;
}

Expand Down
20 changes: 15 additions & 5 deletions admin/tabs/userData/roles/RoleModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ import {compact, groupBy, mapValues} from 'lodash';
import {RoleEditorModel} from './editor/RoleEditorModel';
import {HoistRole, RoleModuleConfig} from './Types';

/** Extends HoistRole with synthetic fields added by processRawData and processRolesForTreeGrid. */
interface RoleGridRow extends HoistRole {
/** True for category group rows injected by processRolesForTreeGrid - not real role records. */
isGroupRow?: boolean;
effectiveUserNames?: string[];
effectiveDirectoryGroupNames?: string[];
effectiveRoleNames?: string[];
inheritedRoleNames?: string[];
}

export class RoleModel extends HoistModel {
override telemetryPrefix = 'xh.client.admin.roles';

Expand All @@ -33,7 +43,7 @@ export class RoleModel extends HoistModel {

override persistWith = RoleModel.PERSIST_WITH;

@managed gridModel: GridModel;
@managed gridModel: GridModel<RoleGridRow>;
@managed filterChooserModel: FilterChooserModel;
@managed readonly roleEditorModel = new RoleEditorModel(this);
@managed recategorizeDialogModel = new RecategorizeDialogModel(this);
Expand All @@ -49,7 +59,7 @@ export class RoleModel extends HoistModel {

get selectedRole(): HoistRole {
const selected = this.gridModel.selectedRecord?.data;
if (selected && !selected.isGroupRow) return selected as HoistRole;
if (selected && !selected.isGroupRow) return selected;
return null;
}

Expand Down Expand Up @@ -282,8 +292,8 @@ export class RoleModel extends HoistModel {
return root;
}

private createGridModel(): GridModel {
return new GridModel({
private createGridModel(): GridModel<RoleGridRow> {
return new GridModel<RoleGridRow>({
treeMode: true,
treeStyle: TreeStyle.HIGHLIGHTS_AND_BORDERS,
autosizeOptions: {mode: 'managed', includeCollapsedChildren: true},
Expand Down Expand Up @@ -357,7 +367,7 @@ export class RoleModel extends HoistModel {
contextMenu: () => this.getContextMenuItems(),
onRowDoubleClicked: ({data: record}) => {
if (record && !record.data.isGroupRow) {
this.editAsync(record.data as HoistRole);
this.editAsync(record.data);
}
}
});
Expand Down
30 changes: 19 additions & 11 deletions cmp/dataview/DataViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import {
} from '@xh/hoist/cmp/grid';
import {HoistModel, LoadSpec, managed, PlainObject, Some} from '@xh/hoist/core';
import {
RecordId,
Store,
StoreConfig,
StoreRecord,
StoreRecordId,
StoreRecordOrId,
StoreSelectionConfig,
StoreSelectionModel,
Expand All @@ -38,9 +40,12 @@ import {ReactNode} from 'react';
*
* @see DataViewModel
*/
export interface DataViewConfig {
export interface DataViewConfig<
TData extends PlainObject = PlainObject,
TId extends StoreRecordId = RecordId<TData>
> {
/** A Store instance, or a config to create one. */
store?: Store | StoreConfig;
store?: Store<TData, TId> | StoreConfig;

/** Renderer to use for each data row. */
renderer?: ColumnRenderer;
Expand Down Expand Up @@ -148,12 +153,15 @@ export type ItemHeightFn = (params: {
* @see DataView
* @see DataViewConfig
*/
export class DataViewModel extends HoistModel {
@managed gridModel: GridModel;
export class DataViewModel<
TData extends PlainObject = PlainObject,
TId extends StoreRecordId = RecordId<TData>
> extends HoistModel {
@managed gridModel: GridModel<TData, TId>;
@bindable.ref itemHeight: number | ItemHeightFn;
@bindable groupRowHeight: number;

constructor(config: DataViewConfig) {
constructor(config: DataViewConfig<TData, TId>) {
super();
makeObservable(this);
const {
Expand Down Expand Up @@ -202,7 +210,7 @@ export class DataViewModel extends HoistModel {
rendererIsComplex: true
});

this.gridModel = new GridModel({
this.gridModel = new GridModel<TData, TId>({
store,
sortBy,
selModel,
Expand All @@ -226,25 +234,25 @@ export class DataViewModel extends HoistModel {
}

// Getters and methods trampolined from GridModel.
get store() {
get store(): Store<TData, TId> {
return this.gridModel.store;
}
get empty() {
return this.gridModel.empty;
}
get selModel() {
get selModel(): StoreSelectionModel<TData, TId> {
return this.gridModel.selModel;
}
get hasSelection() {
return this.gridModel.hasSelection;
}
get selectedRecords() {
get selectedRecords(): StoreRecord<TData, TId>[] {
return this.gridModel.selectedRecords;
}
get selectedRecord() {
get selectedRecord(): StoreRecord<TData, TId> {
return this.gridModel.selectedRecord;
}
get selectedId() {
get selectedId(): TId {
return this.gridModel.selectedId;
}
get groupBy() {
Expand Down
41 changes: 25 additions & 16 deletions cmp/grid/GridModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
Field,
FieldSpec,
getFieldName,
RecordId,
Store,
StoreConfig,
StoreRecord,
Expand Down Expand Up @@ -130,7 +131,10 @@ import {
* @see GridModel
* @see ColumnSpec
*/
export interface GridConfig {
export interface GridConfig<
TData extends PlainObject = PlainObject,
TId extends StoreRecordId = RecordId<TData>
> {
/** Columns for this grid. */
columns?: ColumnOrGroupSpec[];

Expand All @@ -141,7 +145,7 @@ export interface GridConfig {
* A Store instance, or a config with which to create a Store. If not supplied,
* store fields will be inferred from columns config.
*/
store?: Store | StoreConfig;
store?: Store<TData, TId> | StoreConfig;

/** True if grid is a tree grid (default false). */
treeMode?: boolean;
Expand Down Expand Up @@ -446,7 +450,10 @@ export interface GridModelDefaults {
*
* @mcpHint model backing all grid components
*/
export class GridModel extends HoistModel {
export class GridModel<
TData extends PlainObject = PlainObject,
TId extends StoreRecordId = RecordId<TData>
> extends HoistModel {
/** App-level defaults for GridModel. Instance config takes precedence. */
static defaults: GridModelDefaults = {
autosizeMode: 'onSizingModeChange',
Expand Down Expand Up @@ -496,8 +503,8 @@ export class GridModel extends HoistModel {
//------------------------
// Immutable public properties
//------------------------
store: Store;
selModel: StoreSelectionModel;
store: Store<TData, TId>;
selModel: StoreSelectionModel<TData, TId>;
treeMode: boolean;
colChooserModel: IColChooserModel;
rowClassFn: RowClassFn;
Expand Down Expand Up @@ -599,7 +606,7 @@ export class GridModel extends HoistModel {
/** Tracks execution of autosize operations. */
@managed autosizeTask = TaskObserver.trackAll();

constructor(config: GridConfig) {
constructor(config: GridConfig<TData, TId>) {
super();
makeObservable(this);
let {
Expand Down Expand Up @@ -658,7 +665,7 @@ export class GridModel extends HoistModel {
appData,
xhImpl,
...rest
}: GridConfig = config;
}: GridConfig<TData, TId> = config;

this.xhImpl = xhImpl;

Expand Down Expand Up @@ -991,12 +998,12 @@ export class GridModel extends HoistModel {
}

/** Currently selected records. */
get selectedRecords(): StoreRecord[] {
get selectedRecords(): StoreRecord<TData, TId>[] {
return this.selModel.selectedRecords;
}

/** IDs of currently selected records. */
get selectedIds(): StoreRecordId[] {
get selectedIds(): TId[] {
return this.selModel.selectedIds;
}

Expand All @@ -1007,7 +1014,7 @@ export class GridModel extends HoistModel {
* due to store loading or editing. Applications only interested in the identity
* of the selection should use {@link selectedId} instead.
*/
get selectedRecord(): StoreRecord {
get selectedRecord(): StoreRecord<TData, TId> {
return this.selModel.selectedRecord;
}

Expand All @@ -1018,7 +1025,7 @@ export class GridModel extends HoistModel {
* due to store loading or editing. Applications also interested in the contents of the
* selection should use the {@link selectedRecord} getter instead.
*/
get selectedId(): StoreRecordId {
get selectedId(): TId {
return this.selModel.selectedId;
}

Expand Down Expand Up @@ -1752,7 +1759,7 @@ export class GridModel extends HoistModel {
// in this case GridModel should work out the required Store fields from column definitions.
private parseAndSetColumnsAndStore(
colConfigs: ColumnOrGroupSpec[],
storeOrConfig: Store | StoreConfig = {}
storeOrConfig: Store<TData, TId> | StoreConfig = {}
) {
// Enhance colConfigs with field-level metadata provided by store, if any.
colConfigs = this.enhanceColConfigsFromStore(colConfigs, storeOrConfig);
Expand All @@ -1761,12 +1768,12 @@ export class GridModel extends HoistModel {
this.setColumns(colConfigs);

// Set or create Store as needed.
let store: Store;
let store: Store<TData, TId>;
if (storeOrConfig instanceof Store) {
store = storeOrConfig;
} else {
storeOrConfig = this.enhanceStoreConfigFromColumns(storeOrConfig);
store = new Store({loadTreeData: this.treeMode, ...storeOrConfig});
store = new Store<TData, TId>({loadTreeData: this.treeMode, ...storeOrConfig});
store.xhImpl = this.xhImpl;
this.markManaged(store);
}
Expand Down Expand Up @@ -1950,10 +1957,12 @@ export class GridModel extends HoistModel {
return sizingMode;
}

private parseSelModel(selModel: GridConfig['selModel']): StoreSelectionModel {
private parseSelModel(
selModel: GridConfig<TData, TId>['selModel']
): StoreSelectionModel<TData, TId> {
// Return actual instance directly.
if (selModel instanceof StoreSelectionModel) {
return selModel;
return selModel as StoreSelectionModel<TData, TId>;
}

// Default unspecified based on platform, treat explicit null as disabled.
Expand Down
28 changes: 28 additions & 0 deletions cmp/grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,34 @@ gridModel.selectedRecords; // Array of records
gridModel.hasSelection; // Boolean
```

### Typed Records

Pass a data-shape interface as a type argument to `GridModel` to get typed access to
`selectedRecord`, `store.records`, and `record.id`:

```typescript
interface Trade {
id: number; // include `id` here to type record.id automatically
symbol: string;
quantity: number;
}

const gridModel = new GridModel<Trade>({
store: {fields: ['symbol', 'quantity']},
columns: [{field: 'symbol'}, {field: 'quantity', ...number}]
});

gridModel.selectedRecord?.data.symbol; // string
gridModel.selectedRecord?.id; // number
gridModel.store.records[0].data.quantity; // number
```

This is fully opt-in - omitting the type argument leaves records typed exactly as before. Note that
column renderer/editor callbacks (the `{record}` they receive) and `RecordAction` callbacks
currently still provide an **untyped** `StoreRecord` - typed callback context is planned for a
future release. See [Typing Records](../../data/README.md#typing-records) in the data package for
full details, including how `record.id` is derived and the id-less interface case.

### Filtering

```typescript
Expand Down
Loading
Loading