@xh/hoist 59.0.3 → 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.
Files changed (78) hide show
  1. package/CHANGELOG.md +59 -2
  2. package/admin/AdminUtils.ts +23 -0
  3. package/admin/tabs/activity/clienterrors/ClientErrorsModel.ts +2 -1
  4. package/admin/tabs/activity/feedback/FeedbackPanel.ts +3 -3
  5. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +2 -1
  6. package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +3 -2
  7. package/admin/tabs/general/alertBanner/AlertBannerModel.ts +2 -2
  8. package/admin/tabs/general/config/ConfigPanelModel.ts +2 -2
  9. package/admin/tabs/general/users/UserModel.ts +2 -2
  10. package/admin/tabs/monitor/MonitorResultsModel.ts +48 -11
  11. package/admin/tabs/monitor/MonitorResultsPanel.ts +71 -8
  12. package/admin/tabs/server/connectionpool/ConnPoolMonitorModel.ts +2 -2
  13. package/admin/tabs/server/ehcache/EhCacheModel.ts +2 -2
  14. package/admin/tabs/server/environment/ServerEnvModel.ts +3 -3
  15. package/admin/tabs/server/logLevel/LogLevelPanel.ts +3 -3
  16. package/admin/tabs/server/logViewer/LogViewerModel.ts +2 -2
  17. package/admin/tabs/server/memory/MemoryMonitorModel.ts +2 -2
  18. package/admin/tabs/server/services/ServiceModel.ts +3 -3
  19. package/admin/tabs/server/websocket/WebSocketModel.ts +3 -2
  20. package/admin/tabs/userData/jsonblob/JsonBlobModel.ts +2 -2
  21. package/admin/tabs/userData/prefs/PreferenceModel.ts +2 -2
  22. package/admin/tabs/userData/prefs/UserPreferencePanel.ts +3 -3
  23. package/appcontainer/ExceptionDialogModel.ts +6 -0
  24. package/cmp/ag-grid/AgGrid.scss +27 -5
  25. package/cmp/error/ErrorBoundary.ts +68 -0
  26. package/cmp/error/ErrorBoundaryModel.ts +77 -0
  27. package/cmp/grid/Grid.scss +10 -0
  28. package/cmp/grid/GridModel.ts +137 -33
  29. package/cmp/grid/columns/ColumnGroup.ts +11 -0
  30. package/cmp/markdown/Markdown.ts +32 -0
  31. package/cmp/markdown/index.ts +1 -0
  32. package/core/XH.ts +5 -9
  33. package/core/exception/ExceptionHandler.ts +15 -0
  34. package/data/RecordAction.ts +1 -1
  35. package/data/cube/Query.ts +37 -3
  36. package/data/cube/View.ts +16 -4
  37. package/data/cube/row/BaseRow.ts +2 -2
  38. package/desktop/appcontainer/AppContainer.ts +17 -3
  39. package/desktop/appcontainer/Banner.scss +25 -0
  40. package/desktop/appcontainer/Banner.ts +3 -2
  41. package/desktop/cmp/dash/canvas/impl/DashCanvasView.ts +4 -1
  42. package/desktop/cmp/dash/container/DashContainerModel.ts +3 -2
  43. package/desktop/cmp/dash/container/impl/DashContainerUtils.ts +4 -4
  44. package/desktop/cmp/dash/container/impl/DashContainerView.ts +2 -1
  45. package/desktop/cmp/dock/DockViewModel.ts +10 -8
  46. package/desktop/cmp/dock/impl/DockContainer.ts +1 -0
  47. package/desktop/cmp/dock/impl/DockView.ts +2 -1
  48. package/desktop/cmp/error/ErrorMessage.scss +1 -0
  49. package/desktop/cmp/error/ErrorMessage.ts +61 -23
  50. package/desktop/cmp/input/Checkbox.scss +13 -0
  51. package/desktop/cmp/input/Checkbox.ts +2 -0
  52. package/desktop/cmp/modalsupport/ModalSupport.scss +2 -0
  53. package/desktop/cmp/panel/Panel.ts +37 -14
  54. package/desktop/cmp/panel/PanelModel.ts +35 -7
  55. package/desktop/cmp/panel/impl/PanelHeader.scss +5 -0
  56. package/desktop/cmp/panel/impl/PanelHeader.ts +53 -38
  57. package/desktop/cmp/tab/impl/Tab.ts +2 -1
  58. package/dynamics/desktop.ts +15 -17
  59. package/dynamics/mobile.ts +10 -8
  60. package/inspector/Inspector.scss +5 -10
  61. package/inspector/InspectorPanel.ts +2 -0
  62. package/inspector/instances/InstancesModel.ts +1 -1
  63. package/kit/react-markdown/index.ts +11 -0
  64. package/mobile/appcontainer/AppContainer.ts +17 -3
  65. package/mobile/appcontainer/Banner.scss +25 -0
  66. package/mobile/appcontainer/Banner.ts +3 -2
  67. package/mobile/cmp/error/ErrorMessage.ts +58 -18
  68. package/mobile/cmp/menu/impl/Menu.scss +7 -1
  69. package/mobile/cmp/navigator/PageModel.ts +1 -0
  70. package/mobile/cmp/navigator/impl/Page.ts +2 -1
  71. package/mobile/cmp/panel/Panel.ts +5 -1
  72. package/mobile/cmp/tab/impl/Tab.ts +2 -1
  73. package/package.json +5 -3
  74. package/styles/vars.scss +2 -0
  75. package/svc/AlertBannerService.ts +2 -2
  76. package/svc/GridExportService.ts +1 -1
  77. package/admin/tabs/monitor/MonitorResultsToolbar.ts +0 -66
  78. package/appcontainer/ErrorBoundary.ts +0 -36
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils';
7
8
  import {AppModel} from '@xh/hoist/admin/AppModel';
8
9
  import * as Col from '@xh/hoist/admin/columns';
9
10
  import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
@@ -18,7 +19,6 @@ import {
18
19
  } from '@xh/hoist/desktop/cmp/rest';
19
20
  import {fmtDateTime} from '@xh/hoist/format';
20
21
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
21
- import {LocalDate} from '@xh/hoist/utils/datetime';
22
22
  import {isDate} from 'lodash';
23
23
  import {DifferModel} from '../../../differ/DifferModel';
24
24
  import * as JBCol from './JsonBlobColumns';
@@ -46,7 +46,7 @@ export class JsonBlobModel extends HoistModel {
46
46
  persistWith: this.persistWith,
47
47
  colChooserModel: true,
48
48
  enableExport: true,
49
- exportOptions: {filename: `${XH.appCode}-json-blobs-${LocalDate.today()}`},
49
+ exportOptions: {filename: exportFilenameWithDate('json-blobs')},
50
50
  selModel: 'multiple',
51
51
  store: {
52
52
  url: 'rest/jsonBlobAdmin',
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils';
7
8
  import {AppModel} from '@xh/hoist/admin/AppModel';
8
9
  import * as Col from '@xh/hoist/admin/columns';
9
10
  import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
@@ -11,7 +12,6 @@ import {FieldSpec} from '@xh/hoist/data';
11
12
  import {textArea} from '@xh/hoist/desktop/cmp/input';
12
13
  import {addAction, deleteAction, editAction, RestGridModel} from '@xh/hoist/desktop/cmp/rest';
13
14
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
14
- import {LocalDate} from '@xh/hoist/utils/datetime';
15
15
  import {DifferModel} from '../../../differ/DifferModel';
16
16
  import {RegroupDialogModel} from '../../../regroup/RegroupDialogModel';
17
17
 
@@ -41,7 +41,7 @@ export class PreferenceModel extends HoistModel {
41
41
  persistWith: this.persistWith,
42
42
  colChooserModel: true,
43
43
  enableExport: true,
44
- exportOptions: {filename: `${XH.appCode}-prefs-${LocalDate.today()}`},
44
+ exportOptions: {filename: exportFilenameWithDate('prefs')},
45
45
  selModel: 'multiple',
46
46
  store: {
47
47
  url: 'rest/preferenceAdmin',
@@ -4,12 +4,12 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils';
7
8
  import {AppModel} from '@xh/hoist/admin/AppModel';
8
9
  import * as Col from '@xh/hoist/admin/columns';
9
- import {hoistCmp, XH} from '@xh/hoist/core';
10
+ import {hoistCmp} from '@xh/hoist/core';
10
11
  import {FieldSpec} from '@xh/hoist/data';
11
12
  import {restGrid, RestGridConfig} from '@xh/hoist/desktop/cmp/rest';
12
- import {LocalDate} from '@xh/hoist/utils/datetime';
13
13
 
14
14
  export const userPreferencePanel = hoistCmp.factory(() =>
15
15
  restGrid({modelConfig: {...modelSpec, readonly: AppModel.readonly}})
@@ -22,7 +22,7 @@ const modelSpec: RestGridConfig = {
22
22
  persistWith: {localStorageKey: 'xhAdminUserPreferenceState'},
23
23
  colChooserModel: true,
24
24
  enableExport: true,
25
- exportOptions: {filename: `${XH.appCode}-user-prefs-${LocalDate.today()}`},
25
+ exportOptions: {filename: exportFilenameWithDate('user-prefs')},
26
26
  selModel: 'multiple',
27
27
  store: {
28
28
  url: 'rest/userPreferenceAdmin',
@@ -49,6 +49,12 @@ export class ExceptionDialogModel extends HoistModel {
49
49
  this.displayData = {exception, options};
50
50
  }
51
51
 
52
+ @action
53
+ showDetails(exception: HoistException, options: ExceptionHandlerOptions) {
54
+ this.show(exception, options);
55
+ this.openDetails();
56
+ }
57
+
52
58
  @action
53
59
  close() {
54
60
  this.displayData = null;
@@ -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
  }
@@ -0,0 +1,68 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2023 Extremely Heavy Industries Inc.
6
+ */
7
+ import {elementFactory, hoistCmp, uses, XH} from '@xh/hoist/core';
8
+ import {errorMessage as desktopErrorMessage} from '@xh/hoist/dynamics/desktop';
9
+ import {errorMessage as mobileErrorMessage} from '@xh/hoist/dynamics/mobile';
10
+ import {Component, ReactNode} from 'react';
11
+ import {ErrorBoundaryModel} from './ErrorBoundaryModel';
12
+
13
+ /**
14
+ * A wrapper component that will catch an otherwise unhandled React lifecycle error from any child
15
+ * component, preventing such an error from bringing down the entire app. Upon catching an error,
16
+ * this comp will swap out its children with an `ErrorMessage` component (or other configured
17
+ * renderer), giving the user the chance to report the exception and optionally try again.
18
+ *
19
+ * This wrapper will automatically only catch and handle exceptions that occur during the React
20
+ * lifecycle, but applications that wish to use this component to display other caught exceptions
21
+ * may explicitly use it to handle those exceptions.
22
+ */
23
+ export const [ErrorBoundary, errorBoundary] = hoistCmp.withFactory<ErrorBoundaryModel>({
24
+ displayName: 'ErrorBoundary',
25
+ model: uses(ErrorBoundaryModel, {
26
+ createDefault: true,
27
+ fromContext: false,
28
+ publishMode: 'limited'
29
+ }),
30
+
31
+ render({model, ...props}) {
32
+ let {error, errorRenderer} = model;
33
+
34
+ if (!error) return reactErrorBoundary({model, ...props});
35
+ if (errorRenderer) return errorRenderer(error);
36
+
37
+ const cmp = XH.isMobileApp ? mobileErrorMessage : desktopErrorMessage;
38
+ return cmp({
39
+ error,
40
+ title: 'Unexpected error while rendering this component',
41
+ actionFn: () => model.clear(),
42
+ detailsFn: () => XH.exceptionHandler.showExceptionDetails(error)
43
+ });
44
+ }
45
+ });
46
+
47
+ //------------------------------------------------------------------
48
+ // Standard recipe from React Docs, requires class based component
49
+ // See https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
50
+ //------------------------------------------------------------------
51
+ class ReactErrorBoundary extends Component<
52
+ {children: ReactNode; model: ErrorBoundaryModel},
53
+ {error: unknown}
54
+ > {
55
+ override state = {error: null};
56
+ override render() {
57
+ return !this.state.error ? this.props.children : null;
58
+ }
59
+
60
+ static getDerivedStateFromError(error: unknown) {
61
+ return {error};
62
+ }
63
+
64
+ override componentDidCatch(e: unknown) {
65
+ this.props.model.handleError(e);
66
+ }
67
+ }
68
+ const reactErrorBoundary = elementFactory(ReactErrorBoundary);
@@ -0,0 +1,77 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2023 Extremely Heavy Industries Inc.
6
+ */
7
+ import {ExceptionHandlerOptions, HoistModel, XH} from '@xh/hoist/core';
8
+ import {isFunction} from 'lodash';
9
+ import {action, makeObservable, observable} from 'mobx';
10
+ import {ReactNode} from 'react';
11
+
12
+ export interface ErrorBoundaryConfig {
13
+ /**
14
+ * Config for {@link XH.handleException}, or a custom function to handle any error caught by
15
+ * the boundary. Defaults to `{showAlert: false}`.
16
+ */
17
+ errorHandler?: ExceptionHandlerOptions | ((e: unknown) => void);
18
+
19
+ /**
20
+ * Function to render any error caught by the boundary - return will be rendered in lieu of the
21
+ * component's normal children. Defaults to a platform-appropriate {@link ErrorMessage}.
22
+ */
23
+ errorRenderer?: (e: unknown) => ReactNode;
24
+ }
25
+
26
+ export class ErrorBoundaryModel extends HoistModel {
27
+ errorHandler: ExceptionHandlerOptions | ((e: unknown) => void);
28
+ errorRenderer: (e: unknown) => ReactNode;
29
+
30
+ /**
31
+ * Caught error being displayed instead of the content.
32
+ * Null if content rendering normally.
33
+ */
34
+ @observable.ref error: unknown;
35
+
36
+ constructor(config?: ErrorBoundaryConfig) {
37
+ super();
38
+ makeObservable(this);
39
+ this.errorHandler = config?.errorHandler ?? {showAlert: false};
40
+ this.errorRenderer = config?.errorRenderer;
41
+ }
42
+
43
+ /**
44
+ * Handle the exception and replace the contents of the component with a rendered error.
45
+ *
46
+ * This method does not need to be called for React Lifecycle events that occur within its
47
+ * rendered content - that is handled automatically by the component. It is publicly available
48
+ * for apps that wish to use this component to handle and display other caught exceptions.
49
+ *
50
+ * For exceptions that have already been handled, call {@link showError} instead.
51
+ */
52
+ @action
53
+ handleError(e: unknown) {
54
+ let handler = this.errorHandler;
55
+ if (handler) {
56
+ isFunction(handler) ? handler(e) : XH.handleException(e, handler as any);
57
+ }
58
+ this.error = e;
59
+ }
60
+
61
+ /**
62
+ * Replace the contents of the component with a rendered error.
63
+ *
64
+ * Note that unlike {@link handleError} this method will *not* report or take any other action
65
+ * on the error. It is intended for use with exceptions that have already been handled.
66
+ */
67
+ @action
68
+ showError(e: unknown) {
69
+ this.error = e;
70
+ }
71
+
72
+ /** Reset this component to clear the current error and attempt to re-render its contents. */
73
+ @action
74
+ clear() {
75
+ this.error = null;
76
+ }
77
+ }
@@ -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
  }
@@ -0,0 +1,32 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2023 Extremely Heavy Industries Inc.
6
+ */
7
+ import {hoistCmp, HoistProps} from '@xh/hoist/core';
8
+ import {reactMarkdown} from '@xh/hoist/kit/react-markdown';
9
+ import remarkBreaks from 'remark-breaks';
10
+
11
+ interface MarkdownProps extends HoistProps {
12
+ /**
13
+ * Markdown formatted string to render.
14
+ */
15
+ content: string;
16
+
17
+ /** True (default) to render new lines with <br/> tags. */
18
+ lineBreaks?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Render Markdown formatted strings as HTML (e.g. **foo** becomes <strong>foo</strong>).
23
+ */
24
+ export const [Markdown, markdown] = hoistCmp.withFactory<MarkdownProps>({
25
+ displayName: 'Markdown',
26
+ render({content, lineBreaks = true}) {
27
+ return reactMarkdown({
28
+ item: content,
29
+ remarkPlugins: lineBreaks ? [remarkBreaks] : null
30
+ });
31
+ }
32
+ });
@@ -0,0 +1 @@
1
+ export * from './Markdown';