@xh/hoist 59.1.0 → 59.2.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,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 59.2.0 - 2023-10-16
4
+
5
+ ### 🎁 New Features
6
+
7
+ * New `DockViewConfig.onClose` hook invoked when a user attempts to remove a `DockContainer` view
8
+ * Add `GridModel` APIs to lookup and show / hide entire column groups
9
+ * Left / right borders are now rendered along `Grid` `ColumnGroup` edges by default. Control
10
+ with new boolean property `ColumnGroupSpec.borders`
11
+ * The Cube package has been enhanced to support `Query` specific post-processing functions. See
12
+ new properties `Query.omitFn`, `Query.bucketSpecFn` and `Query.lockFn`. These properties default
13
+ to their respective properties on `Cube`.
14
+
15
+ ### 🐞 Bug Fixes
16
+
17
+ * `DashContainerModel` fixes:
18
+ * Fix bug where `addView` would throw when adding a view to a row or column
19
+ * Fix bug where `allowRemove` flag was dropped from state for containers
20
+ * Fix bug in `DockContainer` where adding / removing views would cause other views to be remounted
21
+ * Fix erroneous `GridModel` warning when using a tree column within a column group
22
+ * Fix regression to alert banners. Resume allowing elements as messages
23
+
24
+ ### ⚙️ Typescript API Adjustments
25
+
26
+ * Add type for `ActionFnData.record`
27
+
3
28
  ## 59.1.0 - 2023-09-20
4
29
 
5
30
  ### 🎁 New Features
@@ -11,7 +11,7 @@ import {HoistModel, LoadSpec, managed, XH, Intent, PlainObject} from '@xh/hoist/
11
11
  import {dateIs, required} from '@xh/hoist/data';
12
12
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
13
13
  import {AppModel} from '@xh/hoist/admin/AppModel';
14
- import _, {sortBy, without} from 'lodash';
14
+ import {some, sortBy, without} from 'lodash';
15
15
  import {computed} from 'mobx';
16
16
 
17
17
  export class AlertBannerModel extends HoistModel {
@@ -151,7 +151,7 @@ export class AlertBannerModel extends HoistModel {
151
151
  @computed
152
152
  get currentValuesSavedAsPreset() {
153
153
  const {message, intent, iconName, enableClose} = this.formModel.values;
154
- return _(this.savedPresets).some({message, intent, iconName, enableClose});
154
+ return some(this.savedPresets, {message, intent, iconName, enableClose});
155
155
  }
156
156
 
157
157
  async loadPresetsAsync() {
@@ -5,6 +5,14 @@
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
+ @mixin group-border($side) {
9
+ border-#{$side}: var(--xh-border-solid);
10
+ }
11
+
12
+ @mixin pinned-border {
13
+ border-right: 1px solid var(--xh-grid-pinned-column-border-color);
14
+ }
15
+
8
16
  // Ag-grid installs an outer div around itself.
9
17
  .xh-ag-grid > div {
10
18
  width: 100%;
@@ -121,12 +129,14 @@
121
129
 
122
130
  &--no-row-borders {
123
131
  .ag-row {
124
- border-color: transparent;
132
+ border-bottom: none;
133
+ border-top: none;
125
134
 
126
135
  // Deliberately keep border on full-width grouped rows even when we aren't adding row borders.
127
136
  // Without this collapsed groups (which don't stripe) blend together in a solid block.
128
137
  &.ag-row-group.ag-full-width-row {
129
- border-color: var(--xh-grid-group-border-color);
138
+ border-bottom: 1px solid var(--xh-grid-group-border-color);
139
+ border-top: 1px solid var(--xh-grid-group-border-color);
130
140
  }
131
141
  }
132
142
  }
@@ -188,7 +198,7 @@
188
198
  }
189
199
 
190
200
  .ag-cell.ag-cell-last-left-pinned:not(.ag-cell-focus) {
191
- border-right: 1px solid var(--xh-grid-pinned-column-border-color);
201
+ @include pinned-border;
192
202
  }
193
203
 
194
204
  // We use flexbox to consistently vertically center cell contents across
@@ -227,7 +237,7 @@
227
237
  &--no-cell-borders {
228
238
  .ag-cell,
229
239
  .ag-context-menu-open .ag-cell-focus:not(.ag-cell-range-selected) {
230
- border-color: transparent;
240
+ border: none;
231
241
  }
232
242
  }
233
243
 
@@ -235,7 +245,19 @@
235
245
  &--no-cell-focus {
236
246
  .ag-has-focus {
237
247
  .ag-cell-focus:not(.ag-cell-range-selected) {
238
- border-color: transparent;
248
+ border: none;
249
+
250
+ &.ag-cell.ag-cell-last-left-pinned {
251
+ @include pinned-border;
252
+ }
253
+
254
+ &.ag-cell.xh-cell--group-border-left {
255
+ @include group-border(left);
256
+ }
257
+
258
+ &.ag-cell.xh-cell--group-border-right {
259
+ @include group-border(right);
260
+ }
239
261
  }
240
262
  }
241
263
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  @use 'sass:math';
9
+ @use '../ag-grid/AgGrid';
9
10
 
10
11
  .xh-grid:not(.xh-data-view) {
11
12
  //------------------------
@@ -124,6 +125,15 @@
124
125
  }
125
126
  }
126
127
 
128
+ // Render left / right group borders
129
+ .ag-cell.xh-cell--group-border-left {
130
+ @include AgGrid.group-border(left);
131
+ }
132
+
133
+ .ag-cell.xh-cell--group-border-right {
134
+ @include AgGrid.group-border(right);
135
+ }
136
+
127
137
  .xh-ag-grid {
128
138
  &--tiny {
129
139
  .ag-cell.xh-cell--invalid::before {
@@ -8,6 +8,7 @@ import {
8
8
  CellClickedEvent,
9
9
  CellContextMenuEvent,
10
10
  CellDoubleClickedEvent,
11
+ ColumnEvent,
11
12
  RowClickedEvent,
12
13
  RowDoubleClickedEvent
13
14
  } from '@ag-grid-community/core';
@@ -20,7 +21,8 @@ import {
20
21
  GridAutosizeMode,
21
22
  GridFilterModelConfig,
22
23
  GridGroupSortFn,
23
- TreeStyle
24
+ TreeStyle,
25
+ ColumnCellClassRuleFn
24
26
  } from '@xh/hoist/cmp/grid';
25
27
  import {GridFilterModel} from '@xh/hoist/cmp/grid/filter/GridFilterModel';
26
28
  import {br, fragment} from '@xh/hoist/cmp/layout';
@@ -63,7 +65,7 @@ import {
63
65
  withDefault
64
66
  } from '@xh/hoist/utils/js';
65
67
  import equal from 'fast-deep-equal';
66
- import _, {
68
+ import {
67
69
  castArray,
68
70
  clone,
69
71
  cloneDeep,
@@ -72,6 +74,7 @@ import _, {
72
74
  defaultsDeep,
73
75
  every,
74
76
  find,
77
+ first,
75
78
  forEach,
76
79
  isArray,
77
80
  isEmpty,
@@ -81,6 +84,7 @@ import _, {
81
84
  isString,
82
85
  isUndefined,
83
86
  keysIn,
87
+ last,
84
88
  max,
85
89
  min,
86
90
  omit,
@@ -1179,6 +1183,10 @@ export class GridModel extends HoistModel {
1179
1183
  return this.findColumn(this.columns, colId);
1180
1184
  }
1181
1185
 
1186
+ getColumnGroup(groupId: string): ColumnGroup {
1187
+ return this.findColumnGroup(this.columns, groupId);
1188
+ }
1189
+
1182
1190
  /** Return all leaf-level columns - i.e. excluding column groups. */
1183
1191
  getLeafColumns(): Column[] {
1184
1192
  return this.gatherLeaves(this.columns);
@@ -1217,6 +1225,22 @@ export class GridModel extends HoistModel {
1217
1225
  this.setColumnVisible(colId, false);
1218
1226
  }
1219
1227
 
1228
+ setColumnGroupVisible(groupId: string, visible: boolean) {
1229
+ this.applyColumnStateChanges(
1230
+ this.getColumnGroup(groupId)
1231
+ .getLeafColumns()
1232
+ .map(({colId}) => ({colId, hidden: !visible}))
1233
+ );
1234
+ }
1235
+
1236
+ showColumnGroup(groupId: string) {
1237
+ this.setColumnGroupVisible(groupId, true);
1238
+ }
1239
+
1240
+ hideColumnGroup(groupId: string) {
1241
+ this.setColumnGroupVisible(groupId, false);
1242
+ }
1243
+
1220
1244
  /**
1221
1245
  * Determine if a leaf-level column is currently pinned.
1222
1246
  *
@@ -1228,7 +1252,7 @@ export class GridModel extends HoistModel {
1228
1252
  return state ? state.pinned : null;
1229
1253
  }
1230
1254
 
1231
- /** Return matching leaf-level Column object from the provided collection.*/
1255
+ /** Return matching leaf-level Column object from the provided collection. */
1232
1256
  findColumn(cols: Array<Column | ColumnGroup>, colId: string): Column {
1233
1257
  for (let col of cols) {
1234
1258
  if (col instanceof ColumnGroup) {
@@ -1241,6 +1265,18 @@ export class GridModel extends HoistModel {
1241
1265
  return null;
1242
1266
  }
1243
1267
 
1268
+ /** Return matching ColumnGroup from the provided collection. */
1269
+ findColumnGroup(cols: Array<Column | ColumnGroup>, groupId: string): ColumnGroup {
1270
+ for (let col of cols) {
1271
+ if (col instanceof ColumnGroup) {
1272
+ if (col.groupId === groupId) return col;
1273
+ const ret = this.findColumnGroup(col.children, groupId);
1274
+ if (ret) return ret;
1275
+ }
1276
+ }
1277
+ return null;
1278
+ }
1279
+
1244
1280
  /**
1245
1281
  * Return the current state object representation for the given colId.
1246
1282
  *
@@ -1252,30 +1288,6 @@ export class GridModel extends HoistModel {
1252
1288
  return find(this.columnState, {colId});
1253
1289
  }
1254
1290
 
1255
- buildColumn(config: ColumnGroupSpec | ColumnSpec) {
1256
- // Merge leaf config with defaults.
1257
- // Ensure *any* tooltip setting on column itself always wins.
1258
- if (this.colDefaults && !this.isGroupSpec(config)) {
1259
- let colDefaults = {...this.colDefaults};
1260
- if (config.tooltip) colDefaults.tooltip = null;
1261
- config = defaultsDeep({}, config, colDefaults);
1262
- }
1263
-
1264
- const omit = isFunction(config.omit) ? config.omit() : config.omit;
1265
- if (omit) return null;
1266
-
1267
- if (this.isGroupSpec(config)) {
1268
- const children = compact(config.children.map(c => this.buildColumn(c))) as Array<
1269
- ColumnGroup | Column
1270
- >;
1271
- return !isEmpty(children)
1272
- ? new ColumnGroup(config as ColumnGroupSpec, this, children)
1273
- : null;
1274
- }
1275
-
1276
- return new Column(config, this);
1277
- }
1278
-
1279
1291
  /**
1280
1292
  * Autosize columns to fit their contents.
1281
1293
  *
@@ -1436,6 +1448,35 @@ export class GridModel extends HoistModel {
1436
1448
  //-----------------------
1437
1449
  // Implementation
1438
1450
  //-----------------------
1451
+ private buildColumn(config: ColumnGroupSpec | ColumnSpec, borderedGroup?: ColumnGroupSpec) {
1452
+ // Merge leaf config with defaults.
1453
+ // Ensure *any* tooltip setting on column itself always wins.
1454
+ if (this.colDefaults && !this.isGroupSpec(config)) {
1455
+ let colDefaults = {...this.colDefaults};
1456
+ if (config.tooltip) colDefaults.tooltip = null;
1457
+ config = defaultsDeep({}, config, colDefaults);
1458
+ }
1459
+
1460
+ const omit = isFunction(config.omit) ? config.omit() : config.omit;
1461
+ if (omit) return null;
1462
+
1463
+ if (this.isGroupSpec(config)) {
1464
+ if (config.borders) borderedGroup = config;
1465
+ const children = compact(
1466
+ config.children.map(c => this.buildColumn(c, borderedGroup))
1467
+ ) as Array<ColumnGroup | Column>;
1468
+ return !isEmpty(children)
1469
+ ? new ColumnGroup(config as ColumnGroupSpec, this, children)
1470
+ : null;
1471
+ }
1472
+
1473
+ if (borderedGroup) {
1474
+ config = this.enhanceConfigWithGroupBorders(config, borderedGroup);
1475
+ }
1476
+
1477
+ return new Column(config, this);
1478
+ }
1479
+
1439
1480
  private async autosizeColsInternalAsync(colIds, options) {
1440
1481
  await this.whenReadyAsync();
1441
1482
  if (!this.isReady) return;
@@ -1543,18 +1584,16 @@ export class GridModel extends HoistModel {
1543
1584
  if (isEmpty(cols)) return;
1544
1585
 
1545
1586
  const ids = this.collectIds(cols);
1546
- const nonUnique = _(ids)
1547
- .groupBy()
1548
- .pickBy(x => x.length > 1)
1549
- .keys();
1550
- if (!nonUnique.isEmpty()) {
1587
+ const nonUnique = ids.filter((item, index) => ids.indexOf(item) !== index);
1588
+
1589
+ if (!isEmpty(nonUnique)) {
1551
1590
  const msg =
1552
1591
  `Non-unique ids: [${nonUnique}] ` +
1553
1592
  "Use 'ColumnSpec'/'ColumnGroupSpec' configs to resolve a unique ID for each column/group.";
1554
1593
  throw XH.exception(msg);
1555
1594
  }
1556
1595
 
1557
- const treeCols = cols.filter(it => it.isTreeColumn);
1596
+ const treeCols = this.gatherLeaves(cols).filter(it => it.isTreeColumn);
1558
1597
  warnIf(
1559
1598
  this.treeMode && treeCols.length != 1,
1560
1599
  'Grids in treeMode should include exactly one column with isTreeColumn:true.'
@@ -1752,4 +1791,69 @@ export class GridModel extends HoistModel {
1752
1791
  defaultGroupSortFn = (a, b) => {
1753
1792
  return a < b ? -1 : a > b ? 1 : 0;
1754
1793
  };
1794
+
1795
+ private readonly LEFT_BORDER_CLASS = 'xh-cell--group-border-left';
1796
+ private readonly RIGHT_BORDER_CLASS = 'xh-cell--group-border-right';
1797
+
1798
+ private enhanceConfigWithGroupBorders(config: ColumnSpec, group: ColumnGroupSpec): ColumnSpec {
1799
+ return {
1800
+ ...config,
1801
+ cellClassRules: {
1802
+ ...config.cellClassRules,
1803
+ [this.LEFT_BORDER_CLASS]: this.createGroupBorderFn('left', group),
1804
+ [this.RIGHT_BORDER_CLASS]: this.createGroupBorderFn('right', group)
1805
+ }
1806
+ };
1807
+ }
1808
+
1809
+ private createGroupBorderFn(
1810
+ side: 'left' | 'right',
1811
+ group: ColumnGroupSpec
1812
+ ): ColumnCellClassRuleFn {
1813
+ return ({api, column, columnApi, ...ctx}) => {
1814
+ if (!api || !column || !columnApi) return false;
1815
+
1816
+ // Re-evaluate cell class rules when column is re-ordered
1817
+ // See https://www.ag-grid.com/javascript-data-grid/column-object/#reference-events
1818
+ if (!column['xhAppliedGroupBorderListener']) {
1819
+ column['xhAppliedGroupBorderListener'] = true;
1820
+ column.addEventListener('leftChanged', ({api, columns, source}: ColumnEvent) => {
1821
+ if (source === 'uiColumnMoved') api.refreshCells({columns});
1822
+ });
1823
+ }
1824
+
1825
+ // Don't render a left-border if col is first or if prev col already has right-border
1826
+ if (side === 'left') {
1827
+ const prevCol = columnApi.getDisplayedColBefore(column);
1828
+
1829
+ if (!prevCol) return false;
1830
+
1831
+ const prevColDef = prevCol.getColDef(),
1832
+ prevRule = prevColDef.cellClassRules[this.RIGHT_BORDER_CLASS];
1833
+ if (
1834
+ isFunction(prevRule) &&
1835
+ prevRule({
1836
+ ...ctx,
1837
+ api,
1838
+ colDef: prevColDef,
1839
+ column: prevCol,
1840
+ columnApi
1841
+ })
1842
+ ) {
1843
+ return false;
1844
+ }
1845
+ }
1846
+
1847
+ // Walk up parent groups to find "bordered" group. Return true if on relevant edge.
1848
+ const getter = side === 'left' ? first : last;
1849
+ for (let parent = column?.getParent(); parent; parent = parent.getParent()) {
1850
+ if (
1851
+ group.groupId === parent.getGroupId() &&
1852
+ getter(parent.getDisplayedLeafColumns()) === column
1853
+ ) {
1854
+ return true;
1855
+ }
1856
+ }
1857
+ };
1858
+ }
1755
1859
  }
@@ -26,6 +26,8 @@ export interface ColumnGroupSpec {
26
26
  headerClass?: Some<string> | ColumnHeaderClassFn;
27
27
  /** Horizontal alignment of header contents. */
28
28
  headerAlign?: HAlign;
29
+ /** True to render borders on column group edges. */
30
+ borders?: boolean;
29
31
 
30
32
  /**
31
33
  * "Escape hatch" object to pass directly to Ag-Grid for desktop implementations. Note
@@ -50,6 +52,7 @@ export class ColumnGroup {
50
52
  readonly headerName: ReactNode | ColumnHeaderNameFn;
51
53
  readonly headerClass: Some<string> | ColumnHeaderClassFn;
52
54
  readonly headerAlign: HAlign;
55
+ readonly borders: boolean;
53
56
 
54
57
  /**
55
58
  * "Escape hatch" object to pass directly to Ag-Grid for desktop implementations. Note
@@ -79,6 +82,7 @@ export class ColumnGroup {
79
82
  headerClass,
80
83
  headerAlign,
81
84
  agOptions,
85
+ borders,
82
86
  ...rest
83
87
  } = config;
84
88
 
@@ -94,6 +98,7 @@ export class ColumnGroup {
94
98
  this.headerName = withDefault(headerName, genDisplayName(this.groupId));
95
99
  this.headerClass = headerClass;
96
100
  this.headerAlign = headerAlign;
101
+ this.borders = withDefault(borders, true);
97
102
  this.children = children;
98
103
  this.gridModel = gridModel;
99
104
  this.agOptions = agOptions ? clone(agOptions) : {};
@@ -119,4 +124,10 @@ export class ColumnGroup {
119
124
  ...this.agOptions
120
125
  };
121
126
  }
127
+
128
+ getLeafColumns(): Column[] {
129
+ return this.children.flatMap(child =>
130
+ child instanceof Column ? child : child.getLeafColumns()
131
+ );
132
+ }
122
133
  }
@@ -66,7 +66,7 @@ export interface ActionFnData {
66
66
  action?: RecordAction;
67
67
 
68
68
  /** Row data object (entire row, if any).*/
69
- record?: any;
69
+ record?: StoreRecord;
70
70
 
71
71
  /** All currently selected records (if any).*/
72
72
  selectedRecords?: StoreRecord[];
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {Filter, parseFilter, StoreRecord} from '@xh/hoist/data';
8
+ import {BucketSpecFn, Filter, LockFn, OmitFn, parseFilter, StoreRecord} from '@xh/hoist/data';
9
9
  import {isEqual, find} from 'lodash';
10
10
  import {FilterLike, FilterTestFn} from '../filter/Types';
11
11
  import {CubeField} from './CubeField';
@@ -51,6 +51,25 @@ export interface QueryConfig {
51
51
 
52
52
  /** True to include leaf nodes in return.*/
53
53
  includeLeaves?: boolean;
54
+
55
+ /**
56
+ * Optional function to be called for each aggregate node to determine if it should be "locked",
57
+ * preventing drill-down into its children. Defaults to Cube.lockFn.
58
+ */
59
+ lockFn?: LockFn;
60
+
61
+ /**
62
+ * Optional function to be called for each dimension during row generation to determine if the
63
+ * children of that dimension should be bucketed into additional dynamic dimensions.
64
+ * Defaults to Cube.bucketSpecFn.
65
+ */
66
+ bucketSpecFn?: BucketSpecFn;
67
+
68
+ /**
69
+ * Optional function to be called on all single child rows during view processing.
70
+ * Return true to omit the row. Defaults to Cube.omitFn.
71
+ */
72
+ omitFn?: OmitFn;
54
73
  }
55
74
 
56
75
  /** {@inheritDoc QueryConfig} */
@@ -61,6 +80,9 @@ export class Query {
61
80
  readonly includeRoot: boolean;
62
81
  readonly includeLeaves: boolean;
63
82
  readonly cube: Cube;
83
+ readonly lockFn: LockFn;
84
+ readonly bucketSpecFn: BucketSpecFn;
85
+ readonly omitFn: OmitFn;
64
86
 
65
87
  private readonly _testFn: FilterTestFn;
66
88
 
@@ -70,7 +92,10 @@ export class Query {
70
92
  dimensions,
71
93
  filter = null,
72
94
  includeRoot = false,
73
- includeLeaves = false
95
+ includeLeaves = false,
96
+ lockFn = cube.lockFn,
97
+ bucketSpecFn = cube.bucketSpecFn,
98
+ omitFn = cube.omitFn
74
99
  }: QueryConfig) {
75
100
  this.cube = cube;
76
101
  this.fields = this.parseFields(fields);
@@ -78,6 +103,9 @@ export class Query {
78
103
  this.includeRoot = includeRoot;
79
104
  this.includeLeaves = includeLeaves;
80
105
  this.filter = parseFilter(filter);
106
+ this.lockFn = lockFn;
107
+ this.bucketSpecFn = bucketSpecFn;
108
+ this.omitFn = omitFn;
81
109
 
82
110
  this._testFn = this.filter?.getTestFn(this.cube.store) ?? null;
83
111
  }
@@ -89,6 +117,9 @@ export class Query {
89
117
  filter: this.filter,
90
118
  includeRoot: this.includeRoot,
91
119
  includeLeaves: this.includeLeaves,
120
+ lockFn: this.lockFn,
121
+ bucketSpecFn: this.bucketSpecFn,
122
+ omitFn: this.omitFn,
92
123
  cube: this.cube,
93
124
  ...overrides
94
125
  };
@@ -121,7 +152,10 @@ export class Query {
121
152
  isEqual(this.dimensions, other.dimensions) &&
122
153
  this.cube === other.cube &&
123
154
  this.includeRoot === other.includeRoot &&
124
- this.includeLeaves === other.includeLeaves
155
+ this.includeLeaves === other.includeLeaves &&
156
+ this.bucketSpecFn == other.bucketSpecFn &&
157
+ this.omitFn == other.omitFn &&
158
+ this.lockFn == other.lockFn
125
159
  );
126
160
  }
127
161
 
package/data/cube/View.ts CHANGED
@@ -351,9 +351,9 @@ export class View extends HoistBase {
351
351
  parentId: string,
352
352
  appliedDimensions: PlainObject
353
353
  ): BaseRow[] {
354
- if (!this.cube.bucketSpecFn) return rows;
354
+ if (!this.query.bucketSpecFn) return rows;
355
355
 
356
- const bucketSpec = this.cube.bucketSpecFn(rows);
356
+ const bucketSpec = this.query.bucketSpecFn(rows);
357
357
  if (!bucketSpec) return rows;
358
358
 
359
359
  if (!this.query.includeLeaves && rows[0]?.isLeaf) return rows;
@@ -59,7 +59,7 @@ export abstract class BaseRow {
59
59
  let dataChildren = this.getVisibleChildrenDatas();
60
60
 
61
61
  // 2) If omitting ourselves, we are done, return visible children.
62
- if (!isLeaf && view.cube.omitFn?.(this as any)) return dataChildren;
62
+ if (!isLeaf && view.query.omitFn?.(this as any)) return dataChildren;
63
63
 
64
64
  // 3) Otherwise, we can attach this data to the children data and return.
65
65
 
@@ -89,7 +89,7 @@ export abstract class BaseRow {
89
89
  }
90
90
 
91
91
  // Skip all children in a locked node
92
- if (view.cube.lockFn?.(this as any)) {
92
+ if (view.query.lockFn?.(this as any)) {
93
93
  this.locked = true;
94
94
  return null;
95
95
  }
@@ -11,7 +11,7 @@ import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
11
11
  import {button} from '@xh/hoist/desktop/cmp/button';
12
12
  import {Icon} from '@xh/hoist/icon';
13
13
  import {markdown} from '@xh/hoist/cmp/markdown';
14
- import {isFunction, isEmpty} from 'lodash';
14
+ import {isFunction, isEmpty, isString} from 'lodash';
15
15
  import classNames from 'classnames';
16
16
 
17
17
  import './Banner.scss';
@@ -44,7 +44,7 @@ export const banner = hoistCmp.factory({
44
44
  icon,
45
45
  div({
46
46
  className: 'xh-banner__message',
47
- item: markdown({content: message}),
47
+ item: isString(message) ? markdown({content: message}) : message,
48
48
  onClick
49
49
  })
50
50
  ]
@@ -22,7 +22,7 @@ import {wait} from '@xh/hoist/promise';
22
22
  import {isOmitted} from '@xh/hoist/utils/impl';
23
23
  import {debounced, ensureUniqueBy, throwIf} from '@xh/hoist/utils/js';
24
24
  import {createObservableRef} from '@xh/hoist/utils/react';
25
- import {cloneDeep, defaultsDeep, find, isFinite, isNil, reject, startCase} from 'lodash';
25
+ import {cloneDeep, defaultsDeep, find, isFinite, isNil, last, reject, startCase} from 'lodash';
26
26
  import {createRoot} from 'react-dom/client';
27
27
  import {DashConfig, DashModel} from '../';
28
28
  import {DashViewModel, DashViewState} from '../DashViewModel';
@@ -290,7 +290,8 @@ export class DashContainerModel extends DashModel<
290
290
 
291
291
  if (!isFinite(index)) index = container.contentItems.length;
292
292
  container.addChild(goldenLayoutConfig(viewSpec), index);
293
- wait(1).then(() => this.onStackActiveItemChange(container));
293
+ const stack = container.isStack ? container : last(container.contentItems);
294
+ wait(1).then(() => this.onStackActiveItemChange(stack));
294
295
  }
295
296
 
296
297
  /**
@@ -46,8 +46,8 @@ function convertGLToStateInner(configItems = [], contentItems = [], dashContaine
46
46
 
47
47
  ret.push(view);
48
48
  } else {
49
- const {type, width, height, activeItemIndex, content} = configItem,
50
- container = {type} as PlainObject;
49
+ const {type, width, height, activeItemIndex, content, isClosable} = configItem,
50
+ container = {type, allowRemove: isClosable} as PlainObject;
51
51
 
52
52
  if (isFinite(width)) container.width = round(width, 2);
53
53
  if (isFinite(height)) container.height = round(height, 2);
@@ -138,12 +138,12 @@ function convertStateToGLInner(items = [], viewSpecs = [], containerSize, contai
138
138
  const content = convertStateToGLInner(item.content, viewSpecs, itemSize, item).filter(
139
139
  it => !isNil(it)
140
140
  );
141
- if (!content.length) return null;
141
+ if (!content.length && item.allowRemove) return null;
142
142
 
143
143
  // Below is a workaround for issue https://github.com/golden-layout/golden-layout/issues/418
144
144
  // GoldenLayouts can sometimes export its state with an out-of-bounds `activeItemIndex`.
145
145
  // If we encounter this, we overwrite `activeItemIndex` to point to the last item.
146
- const ret = {...item, content};
146
+ const ret = {...item, content, isClosable: item.allowRemove};
147
147
  if (
148
148
  type === 'stack' &&
149
149
  isFinite(ret.activeItemIndex) &&
@@ -13,7 +13,7 @@ import {
13
13
  RefreshContextModel,
14
14
  RefreshMode,
15
15
  RenderMode,
16
- XH
16
+ Awaitable
17
17
  } from '@xh/hoist/core';
18
18
  import {ModalSupportModel} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupportModel';
19
19
  import '@xh/hoist/desktop/register';
@@ -53,6 +53,8 @@ export interface DockViewConfig {
53
53
  allowClose?: boolean;
54
54
  /** true (default) to allow popping out of the dock and displaying in a modal Dialog. */
55
55
  allowDialog?: boolean;
56
+ /** Awaitable callback invoked on close. Return false to prevent close. */
57
+ onClose?: () => Awaitable<boolean | void>;
56
58
  }
57
59
 
58
60
  /**
@@ -75,6 +77,7 @@ export class DockViewModel extends HoistModel {
75
77
  collapsedWidth: number;
76
78
  allowClose: boolean;
77
79
  allowDialog: boolean;
80
+ onClose?: () => Awaitable<boolean | void>;
78
81
 
79
82
  containerModel: DockContainerModel;
80
83
  @managed refreshContextModel: RefreshContextModel;
@@ -109,7 +112,8 @@ export class DockViewModel extends HoistModel {
109
112
  docked = true,
110
113
  collapsed = false,
111
114
  allowClose = true,
112
- allowDialog = true
115
+ allowDialog = true,
116
+ onClose
113
117
  }: DockViewConfig) {
114
118
  super();
115
119
  makeObservable(this);
@@ -128,6 +132,7 @@ export class DockViewModel extends HoistModel {
128
132
  this.collapsed = collapsed;
129
133
  this.allowClose = allowClose;
130
134
  this.allowDialog = allowDialog;
135
+ this.onClose = onClose;
131
136
 
132
137
  this._renderMode = renderMode;
133
138
  this._refreshMode = refreshMode;
@@ -195,11 +200,8 @@ export class DockViewModel extends HoistModel {
195
200
  // Actions
196
201
  //-----------------------
197
202
  close() {
198
- this.containerModel.removeView(this.id);
199
- }
200
-
201
- override destroy() {
202
- XH.safeDestroy(this.content);
203
- super.destroy();
203
+ Promise.resolve(this.onClose?.()).then(v => {
204
+ if (v !== false) this.containerModel.removeView(this.id);
205
+ });
204
206
  }
205
207
  }
@@ -25,6 +25,7 @@ export function dockContainerImpl(
25
25
  className: classNames(className, `xh-dock-container--${model.direction}`),
26
26
  items: model.views.map(viewModel => {
27
27
  return dockView({
28
+ key: viewModel.xhId,
28
29
  model: viewModel,
29
30
  compactHeaders
30
31
  });
@@ -218,7 +218,7 @@ export class InstancesModel extends HoistModel {
218
218
  icon: Icon.refresh({intent: 'success'}),
219
219
  tooltip: 'Call loadAsync()',
220
220
  actionFn: ({record}) =>
221
- (this.getInstance(record.id) as any)?.loadAsync(),
221
+ (this.getInstance(record.id as string) as any)?.loadAsync(),
222
222
  displayFn: ({record}) => ({hidden: !record.data.hasLoadSupport})
223
223
  }
224
224
  ]
@@ -10,7 +10,7 @@ import {hframe, div} from '@xh/hoist/cmp/layout';
10
10
  import {button} from '@xh/hoist/mobile/cmp/button';
11
11
  import {Icon} from '@xh/hoist/icon';
12
12
  import {markdown} from '@xh/hoist/cmp/markdown';
13
- import {isEmpty, isFunction} from 'lodash';
13
+ import {isEmpty, isFunction, isString} from 'lodash';
14
14
  import classNames from 'classnames';
15
15
 
16
16
  import './Banner.scss';
@@ -42,7 +42,7 @@ export const banner = hoistCmp.factory({
42
42
  icon,
43
43
  div({
44
44
  className: 'xh-banner__message',
45
- item: markdown({content: message}),
45
+ item: isString(message) ? markdown({content: message}) : message,
46
46
  onClick
47
47
  })
48
48
  ]
@@ -2,7 +2,13 @@
2
2
  position: fixed;
3
3
  margin: 0 !important;
4
4
  z-index: 21;
5
- max-height: 90vh;
5
+ /**
6
+ * Use `svh` units target the *smallest* viewport height, which is preferred to using `vh` on mobile.
7
+ * Including `px` units as a fallback for older browsers that may not support `vh`/`svh` units.
8
+ * https://css-tricks.com/the-large-small-and-dynamic-viewports/
9
+ */
10
+ max-height: 500px;
11
+ max-height: 80svh;
6
12
 
7
13
  &__title {
8
14
  padding: var(--xh-pad-half-px);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "59.1.0",
3
+ "version": "59.2.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",