@xh/hoist 59.2.0 → 59.3.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 (104) hide show
  1. package/CHANGELOG.md +63 -14
  2. package/admin/AppComponent.ts +1 -1
  3. package/admin/tabs/activity/ActivityTab.ts +1 -1
  4. package/admin/tabs/general/GeneralTab.ts +2 -2
  5. package/admin/tabs/general/config/ConfigPanel.ts +1 -0
  6. package/admin/tabs/monitor/MonitorTab.ts +1 -1
  7. package/admin/tabs/server/ServerTab.ts +1 -1
  8. package/admin/tabs/userData/UserDataTab.ts +1 -1
  9. package/cmp/ag-grid/AgGrid.scss +51 -25
  10. package/cmp/ag-grid/AgGrid.ts +8 -2
  11. package/cmp/badge/Badge.ts +18 -5
  12. package/cmp/chart/Chart.ts +13 -11
  13. package/cmp/clock/Clock.ts +6 -5
  14. package/cmp/dataview/DataView.ts +5 -3
  15. package/cmp/form/Form.ts +25 -6
  16. package/cmp/grid/Grid.ts +41 -25
  17. package/cmp/grid/GridModel.ts +2 -2
  18. package/cmp/grid/Types.ts +1 -1
  19. package/cmp/grid/columns/Column.ts +45 -2
  20. package/cmp/grid/impl/GridHScrollbar.ts +140 -0
  21. package/cmp/grid/renderers/MultiFieldRenderer.ts +1 -1
  22. package/cmp/input/HoistInputModel.ts +4 -4
  23. package/cmp/input/HoistInputProps.ts +3 -1
  24. package/cmp/layout/Box.ts +4 -2
  25. package/cmp/relativetimestamp/RelativeTimestamp.ts +106 -40
  26. package/cmp/store/StoreFilterField.ts +2 -2
  27. package/cmp/tab/TabContainer.ts +1 -1
  28. package/cmp/zoneGrid/Types.ts +47 -0
  29. package/cmp/zoneGrid/ZoneGrid.ts +62 -0
  30. package/cmp/zoneGrid/ZoneGridModel.ts +666 -0
  31. package/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +143 -0
  32. package/cmp/zoneGrid/impl/ZoneMapperModel.ts +335 -0
  33. package/cmp/zoneGrid/index.ts +3 -0
  34. package/core/HoistComponent.ts +23 -10
  35. package/core/HoistProps.ts +25 -6
  36. package/core/XH.ts +49 -27
  37. package/core/elem.ts +11 -3
  38. package/core/impl/InstanceManager.ts +24 -1
  39. package/core/model/HoistModel.ts +4 -4
  40. package/data/RecordAction.ts +7 -4
  41. package/data/StoreRecord.ts +8 -1
  42. package/desktop/appcontainer/AppContainer.ts +2 -0
  43. package/desktop/cmp/appbar/AppBar.ts +8 -6
  44. package/desktop/cmp/button/Button.ts +14 -3
  45. package/desktop/cmp/button/ButtonGroup.ts +14 -3
  46. package/desktop/cmp/button/ZoneMapperButton.ts +82 -0
  47. package/desktop/cmp/button/index.ts +1 -0
  48. package/desktop/cmp/dash/canvas/DashCanvas.ts +14 -4
  49. package/desktop/cmp/dash/container/DashContainer.ts +11 -4
  50. package/desktop/cmp/error/ErrorMessage.ts +9 -8
  51. package/desktop/cmp/form/FormField.ts +34 -10
  52. package/desktop/cmp/grid/columns/Actions.ts +2 -1
  53. package/desktop/cmp/grid/impl/colchooser/ColChooser.ts +3 -2
  54. package/desktop/cmp/grouping/GroupingChooser.ts +29 -29
  55. package/desktop/cmp/input/ButtonGroupInput.ts +1 -1
  56. package/desktop/cmp/input/Checkbox.ts +3 -3
  57. package/desktop/cmp/input/CodeInput.ts +2 -1
  58. package/desktop/cmp/input/DateInput.ts +128 -123
  59. package/desktop/cmp/input/JsonInput.ts +1 -1
  60. package/desktop/cmp/input/NumberInput.ts +3 -2
  61. package/desktop/cmp/input/RadioInput.ts +3 -1
  62. package/desktop/cmp/input/Select.ts +31 -4
  63. package/desktop/cmp/input/SwitchInput.ts +2 -1
  64. package/desktop/cmp/input/TextArea.ts +3 -3
  65. package/desktop/cmp/input/TextInput.ts +51 -47
  66. package/desktop/cmp/panel/Panel.ts +21 -19
  67. package/desktop/cmp/panel/impl/ResizeContainer.ts +3 -2
  68. package/desktop/cmp/pinpad/impl/PinPad.ts +4 -3
  69. package/desktop/cmp/record/RecordActionBar.ts +12 -3
  70. package/desktop/cmp/record/impl/RecordActionButton.ts +1 -0
  71. package/desktop/cmp/rest/Actions.ts +10 -5
  72. package/desktop/cmp/rest/RestGrid.ts +20 -6
  73. package/desktop/cmp/rest/impl/RestForm.ts +5 -4
  74. package/desktop/cmp/rest/impl/RestGridToolbar.ts +3 -2
  75. package/desktop/cmp/tab/TabSwitcher.ts +8 -3
  76. package/desktop/cmp/tab/impl/Tab.ts +2 -1
  77. package/desktop/cmp/tab/impl/TabContainer.ts +18 -15
  78. package/desktop/cmp/toolbar/Toolbar.ts +3 -1
  79. package/desktop/cmp/treemap/SplitTreeMap.ts +2 -1
  80. package/desktop/cmp/treemap/TreeMap.ts +5 -3
  81. package/desktop/cmp/zoneGrid/impl/ZoneMapper.scss +71 -0
  82. package/desktop/cmp/zoneGrid/impl/ZoneMapper.ts +232 -0
  83. package/desktop/cmp/zoneGrid/impl/ZoneMapperDialog.ts +35 -0
  84. package/dynamics/desktop.ts +2 -0
  85. package/dynamics/mobile.ts +2 -0
  86. package/inspector/instances/InstancesModel.ts +2 -2
  87. package/mobile/appcontainer/AppContainer.ts +2 -0
  88. package/mobile/cmp/button/ZoneMapperButton.ts +41 -0
  89. package/mobile/cmp/button/index.ts +1 -0
  90. package/mobile/cmp/error/ErrorMessage.ts +4 -4
  91. package/mobile/cmp/input/Select.scss +1 -0
  92. package/mobile/cmp/input/Select.ts +7 -0
  93. package/mobile/cmp/input/TextInput.ts +1 -0
  94. package/mobile/cmp/panel/DialogPanel.scss +18 -6
  95. package/mobile/cmp/panel/DialogPanel.ts +3 -1
  96. package/mobile/cmp/zoneGrid/impl/ZoneMapper.scss +67 -0
  97. package/mobile/cmp/zoneGrid/impl/ZoneMapper.ts +236 -0
  98. package/package.json +4 -3
  99. package/styles/vars.scss +3 -3
  100. package/svc/InspectorService.ts +1 -1
  101. package/utils/js/DomUtils.ts +10 -0
  102. package/utils/js/LangUtils.ts +10 -0
  103. package/utils/js/TestUtils.ts +9 -0
  104. package/utils/js/index.ts +1 -0
@@ -0,0 +1,666 @@
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 {
8
+ HoistModel,
9
+ LoadSpec,
10
+ PlainObject,
11
+ Some,
12
+ managed,
13
+ XH,
14
+ Awaitable,
15
+ VSide
16
+ } from '@xh/hoist/core';
17
+ import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
18
+ import {
19
+ RecordAction,
20
+ Store,
21
+ StoreConfig,
22
+ StoreRecordOrId,
23
+ StoreSelectionConfig,
24
+ StoreSelectionModel,
25
+ StoreTransaction
26
+ } from '@xh/hoist/data';
27
+ import {
28
+ Column,
29
+ ColumnSpec,
30
+ Grid,
31
+ GridConfig,
32
+ GridContextMenuSpec,
33
+ GridGroupSortFn,
34
+ GridModel,
35
+ GridSorter,
36
+ GridSorterLike,
37
+ GroupRowRenderer,
38
+ RowClassFn,
39
+ RowClassRuleFn,
40
+ TreeStyle,
41
+ multiFieldRenderer
42
+ } from '@xh/hoist/cmp/grid';
43
+ import {
44
+ CellClickedEvent,
45
+ CellContextMenuEvent,
46
+ CellDoubleClickedEvent,
47
+ RowClickedEvent,
48
+ RowDoubleClickedEvent
49
+ } from '@ag-grid-community/core';
50
+ import {Icon} from '@xh/hoist/icon';
51
+ import {throwIf, withDefault} from '@xh/hoist/utils/js';
52
+ import {castArray, forOwn, isEmpty, isFinite, isPlainObject, isString} from 'lodash';
53
+ import {ReactNode} from 'react';
54
+ import {ZoneMapperConfig, ZoneMapperModel} from './impl/ZoneMapperModel';
55
+ import {ZoneGridPersistenceModel} from './impl/ZoneGridPersistenceModel';
56
+ import {ZoneGridModelPersistOptions, Zone, ZoneLimit, ZoneMapping} from './Types';
57
+
58
+ export interface ZoneGridConfig {
59
+ /**
60
+ * Available columns for this grid. Note that the actual display of
61
+ * the zone columns is managed via `mappings` below.
62
+ */
63
+ columns: Array<ColumnSpec>;
64
+
65
+ /** Mappings of columns to zones. */
66
+ mappings: Record<Zone, Some<string | ZoneMapping>>;
67
+
68
+ /** Optional configurations for zone constraints. */
69
+ limits?: Partial<Record<Zone, ZoneLimit>>;
70
+
71
+ /**
72
+ * Optional configs to apply to left column. Intended for use as an `escape hatch`, and should be used with care.
73
+ * Settings made here may interfere with the implementation of this component.
74
+ */
75
+ leftColumnSpec?: Partial<ColumnSpec>;
76
+
77
+ /**
78
+ * Optional configs to apply to right column. Intended for use as an `escape hatch`, and should be used with care.
79
+ * Settings made here may interfere with the implementation of this component.
80
+ */
81
+ rightColumnSpec?: Partial<ColumnSpec>;
82
+
83
+ /** String rendered between consecutive SubFields. */
84
+ delimiter?: string;
85
+
86
+ /** Config with which to create a ZoneMapperModel, or boolean `true` to enable default. */
87
+ zoneMapperModel?: ZoneMapperConfig | boolean;
88
+
89
+ /**
90
+ * A Store instance, or a config with which to create a Store. If not supplied,
91
+ * store fields will be inferred from columns config.
92
+ */
93
+ store?: Store | StoreConfig;
94
+
95
+ /** True if grid is a tree grid (default false). */
96
+ treeMode?: boolean;
97
+
98
+ /** Location for a docked summary row. Requires `store.SummaryRecord` to be populated. */
99
+ showSummary?: boolean | VSide;
100
+
101
+ /** Specification of selection behavior. Defaults to 'single' (desktop) and 'disabled' (mobile) */
102
+ selModel?: StoreSelectionModel | StoreSelectionConfig | 'single' | 'multiple' | 'disabled';
103
+
104
+ /**
105
+ * Function to be called when the user triggers ZoneGridModel.restoreDefaultsAsync().
106
+ * This function will be called after the built-in defaults have been restored, and can be
107
+ * used to restore application specific defaults.
108
+ */
109
+ restoreDefaultsFn?: () => Awaitable<boolean>;
110
+
111
+ /**
112
+ * Confirmation warning to be presented to user before restoring default state. Set to
113
+ * null to skip user confirmation.
114
+ */
115
+ restoreDefaultsWarning?: ReactNode;
116
+
117
+ /** Options governing persistence. */
118
+ persistWith?: ZoneGridModelPersistOptions;
119
+
120
+ /**
121
+ * Text/element to display if grid has no records. Defaults to null, in which case no empty
122
+ * text will be shown.
123
+ */
124
+ emptyText?: ReactNode;
125
+
126
+ /** True (default) to hide empty text until after the Store has been loaded at least once. */
127
+ hideEmptyTextBeforeLoad?: boolean;
128
+
129
+ /**
130
+ * Initial sort to apply to grid data.
131
+ * Note that unlike GridModel, multi-sort is not supported.
132
+ */
133
+ sortBy?: GridSorterLike;
134
+
135
+ /** Column ID(s) by which to do full-width grouping. */
136
+ groupBy?: Some<string>;
137
+
138
+ /** True (default) to show a count of group member rows within each full-width group row. */
139
+ showGroupRowCounts?: boolean;
140
+
141
+ /** True to highlight the currently hovered row. */
142
+ showHover?: boolean;
143
+
144
+ /** True to render row borders. */
145
+ rowBorders?: boolean;
146
+
147
+ /** Specify treeMode-specific styling. */
148
+ treeStyle?: TreeStyle;
149
+
150
+ /** True to use alternating backgrounds for rows. */
151
+ stripeRows?: boolean;
152
+
153
+ /** True to render cell borders. */
154
+ cellBorders?: boolean;
155
+
156
+ /** True to highlight the focused cell with a border. */
157
+ showCellFocus?: boolean;
158
+
159
+ /** True to suppress display of the grid's header row. */
160
+ hideHeaders?: boolean;
161
+
162
+ /**
163
+ * Closure to generate CSS class names for a row.
164
+ * NOTE that, once added, classes will *not* be removed if the data changes.
165
+ * Use `rowClassRules` instead if StoreRecord data can change across refreshes.
166
+ */
167
+ rowClassFn?: RowClassFn;
168
+
169
+ /**
170
+ * Object keying CSS class names to functions determining if they should be added or
171
+ * removed from the row. See Ag-Grid docs on "row styles" for details.
172
+ */
173
+ rowClassRules?: Record<string, RowClassRuleFn>;
174
+
175
+ /** Height (in px) of a group row. Note that this will override `sizingMode` for group rows. */
176
+ groupRowHeight?: number;
177
+
178
+ /** Function used to render group rows. */
179
+ groupRowRenderer?: GroupRowRenderer;
180
+
181
+ /**
182
+ * Function to use to sort full-row groups. Called with two group values to compare
183
+ * in the form of a standard JS comparator. Default is an ascending string sort.
184
+ * Set to `null` to prevent sorting of groups.
185
+ */
186
+ groupSortFn?: GridGroupSortFn;
187
+
188
+ /**
189
+ * Callback when a key down event is detected on the grid. Note that the ag-Grid API provides
190
+ * limited ability to customize keyboard handling. This handler is designed to allow
191
+ * applications to work around this.
192
+ */
193
+ onKeyDown?: (e: KeyboardEvent) => void;
194
+
195
+ /**
196
+ * Callback when a row is clicked. (Note that the event received may be null - e.g. for
197
+ * clicks on full-width group rows.)
198
+ */
199
+ onRowClicked?: (e: RowClickedEvent) => void;
200
+
201
+ /**
202
+ * Callback when a row is double-clicked. (Note that the event received may be null - e.g.
203
+ * for clicks on full-width group rows.)
204
+ */
205
+ onRowDoubleClicked?: (e: RowDoubleClickedEvent) => void;
206
+
207
+ /**
208
+ * Callback when a cell is clicked.
209
+ */
210
+ onCellClicked?: (e: CellClickedEvent) => void;
211
+
212
+ /**
213
+ * Callback when a cell is double-clicked.
214
+ */
215
+ onCellDoubleClicked?: (e: CellDoubleClickedEvent) => void;
216
+
217
+ /**
218
+ * Callback when the context menu is opened. Note that the event received can also be
219
+ * triggered via a long press (aka tap and hold) on mobile devices.
220
+ */
221
+ onCellContextMenu?: (e: CellContextMenuEvent) => void;
222
+
223
+ /**
224
+ * Number of clicks required to expand / collapse a parent row in a tree grid. Defaults
225
+ * to 2 for desktop, 1 for mobile. Any other value prevents clicks on row body from
226
+ * expanding / collapsing (requires click on tree col affordance to expand/collapse).
227
+ */
228
+ clicksToExpand?: number;
229
+
230
+ /**
231
+ * Array of RecordActions, dividers, or token strings with which to create a context menu.
232
+ * May also be specified as a function returning same.
233
+ */
234
+ contextMenu?: GridContextMenuSpec;
235
+
236
+ /**
237
+ * Governs if the grid should reuse a limited set of DOM elements for columns visible in the
238
+ * scroll area (versus rendering all columns). Consider this performance optimization for
239
+ * grids with a very large number of columns obscured by horizontal scrolling. Note that
240
+ * setting this value to true may limit the ability of the grid to autosize offscreen columns
241
+ * effectively. Default false.
242
+ */
243
+ useVirtualColumns?: boolean;
244
+
245
+ /**
246
+ * Set to true to if application will be reloading data when the sortBy property changes on
247
+ * this model (either programmatically, or via user-click.) Useful for applications with large
248
+ * data sets that are performing external, or server-side sorting and filtering. Setting this
249
+ * flag means that the grid should not immediately respond to user or programmatic changes to
250
+ * the sortBy property, but will instead wait for the next load of data, which is assumed to be
251
+ * pre-sorted. Default false.
252
+ */
253
+ externalSort?: boolean;
254
+
255
+ /**
256
+ * Set to true to highlight a row on click. Intended to provide feedback to users in grids
257
+ * without selection. Note this setting overrides the styling used by Column.highlightOnChange,
258
+ * and is not recommended for use alongside that feature. Default true for mobiles,
259
+ * otherwise false.
260
+ */
261
+ highlightRowOnClick?: boolean;
262
+
263
+ /**
264
+ * Flags for experimental features. These features are designed for early client-access and
265
+ * testing, but are not yet part of the Hoist API.
266
+ */
267
+ experimental?: PlainObject;
268
+
269
+ /** Extra app-specific data for the GridModel. */
270
+ appData?: PlainObject;
271
+
272
+ /** @internal */
273
+ xhImpl?: boolean;
274
+ }
275
+
276
+ /**
277
+ * ZoneGridModel is a wrapper around GridModel, which shows date in a grid with multi-line
278
+ * full-width rows, each broken into four zones for top/bottom and left/right.
279
+ *
280
+ * This is the primary app entry-point for specifying ZoneGrid component options and behavior.
281
+ */
282
+ export class ZoneGridModel extends HoistModel {
283
+ @managed
284
+ gridModel: GridModel;
285
+
286
+ @managed
287
+ mapperModel: ZoneMapperModel;
288
+
289
+ @observable.ref
290
+ mappings: Record<Zone, ZoneMapping[]>;
291
+
292
+ @bindable.ref
293
+ leftColumnSpec: Partial<ColumnSpec>;
294
+
295
+ @bindable.ref
296
+ rightColumnSpec: Partial<ColumnSpec>;
297
+
298
+ availableColumns: ColumnSpec[];
299
+ limits: Partial<Record<Zone, ZoneLimit>>;
300
+ delimiter: string;
301
+ restoreDefaultsFn: () => Awaitable<boolean>;
302
+ restoreDefaultsWarning: ReactNode;
303
+
304
+ private _defaultState; // initial state provided to ctor - powers restoreDefaults().
305
+ @managed persistenceModel: ZoneGridPersistenceModel;
306
+
307
+ constructor(config: ZoneGridConfig) {
308
+ super();
309
+ makeObservable(this);
310
+
311
+ const {
312
+ columns,
313
+ limits,
314
+ mappings,
315
+ sortBy,
316
+ groupBy,
317
+ leftColumnSpec,
318
+ rightColumnSpec,
319
+ delimiter,
320
+ zoneMapperModel,
321
+ restoreDefaultsFn,
322
+ restoreDefaultsWarning,
323
+ persistWith,
324
+ ...rest
325
+ } = config;
326
+
327
+ this.availableColumns = columns.map(it => ({...it, hidden: true}));
328
+ this.limits = limits;
329
+ this.mappings = this.parseMappings(mappings);
330
+
331
+ this.leftColumnSpec = leftColumnSpec;
332
+ this.rightColumnSpec = rightColumnSpec;
333
+ this.delimiter = delimiter ?? ' • ';
334
+ this.restoreDefaultsFn = restoreDefaultsFn;
335
+ this.restoreDefaultsWarning = restoreDefaultsWarning;
336
+
337
+ this._defaultState = {
338
+ mappings: this.mappings,
339
+ sortBy: sortBy,
340
+ groupBy: groupBy
341
+ };
342
+
343
+ this.gridModel = this.createGridModel(rest);
344
+
345
+ this.setSortBy(sortBy);
346
+ this.setGroupBy(groupBy);
347
+
348
+ this.mapperModel = this.parseMapperModel(zoneMapperModel);
349
+ this.persistenceModel = persistWith
350
+ ? new ZoneGridPersistenceModel(this, persistWith)
351
+ : null;
352
+
353
+ this.addReaction({
354
+ track: () => [this.leftColumnSpec, this.rightColumnSpec],
355
+ run: () => this.gridModel.setColumns(this.getColumns())
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Restore the mapping, sorting, and grouping configs as specified by the application at
361
+ * construction time. This is the state without any user changes applied.
362
+ * This method will clear the persistent grid state saved for this grid, if any.
363
+ *
364
+ * @returns true if defaults were restored
365
+ */
366
+ async restoreDefaultsAsync(): Promise<boolean> {
367
+ if (this.restoreDefaultsWarning) {
368
+ const confirmed = await XH.confirm({
369
+ title: 'Please Confirm',
370
+ icon: Icon.warning(),
371
+ message: this.restoreDefaultsWarning,
372
+ confirmProps: {
373
+ text: 'Yes, restore defaults',
374
+ intent: 'primary'
375
+ }
376
+ });
377
+ if (!confirmed) return false;
378
+ }
379
+
380
+ const {mappings, sortBy, groupBy} = this._defaultState;
381
+ this.setMappings(mappings);
382
+ this.setSortBy(sortBy);
383
+ this.setGroupBy(groupBy);
384
+
385
+ this.persistenceModel?.clear();
386
+
387
+ if (this.restoreDefaultsFn) {
388
+ await this.restoreDefaultsFn();
389
+ }
390
+
391
+ return true;
392
+ }
393
+
394
+ showMapper() {
395
+ this.mapperModel.open();
396
+ }
397
+
398
+ @action
399
+ setMappings(mappings: Record<Zone, Some<string | ZoneMapping>>) {
400
+ this.mappings = this.parseMappings(mappings);
401
+ this.gridModel.setColumns(this.getColumns());
402
+ }
403
+
404
+ getDefaultContextMenu = () => [
405
+ 'filter',
406
+ '-',
407
+ 'copy',
408
+ 'copyWithHeaders',
409
+ 'copyCell',
410
+ '-',
411
+ 'expandCollapseAll',
412
+ '-',
413
+ 'restoreDefaults',
414
+ '-',
415
+ new RecordAction({
416
+ text: 'Customize Fields',
417
+ icon: Icon.gridLarge(),
418
+ hidden: !this?.mapperModel,
419
+ actionFn: () => (this?.mapperModel as any)?.open()
420
+ })
421
+ ];
422
+
423
+ //-----------------------
424
+ // Getters and methods trampolined from GridModel.
425
+ //-----------------------
426
+ get sortBy(): GridSorter {
427
+ const ret = this.gridModel.sortBy?.[0];
428
+ if (!ret) return null;
429
+
430
+ // Normalize 'left_column' and 'right_column' to actual underlying fields
431
+ if (ret?.colId === 'left_column') {
432
+ const colId = this.mappings.tl[0]?.field;
433
+ return colId ? new GridSorter({...ret, colId}) : null;
434
+ } else if (ret?.colId === 'right_column') {
435
+ const colId = this.mappings.tr[0]?.field;
436
+ return colId ? new GridSorter({...ret, colId}) : null;
437
+ }
438
+
439
+ return ret;
440
+ }
441
+
442
+ setSortBy(cfg: GridSorterLike) {
443
+ // If the field is mapping to the primary field in a left/right column, set
444
+ // 'left_column'/'right_column' colId instead to display the arrows in the header.
445
+ const sorter = GridSorter.parse(cfg);
446
+ if (sorter?.colId === this.mappings.tl[0]?.field) {
447
+ return this.gridModel.setSortBy({...sorter, colId: 'left_column'});
448
+ }
449
+ if (sorter?.colId === this.mappings.tr[0]?.field) {
450
+ return this.gridModel.setSortBy({...sorter, colId: 'right_column'});
451
+ }
452
+ return this.gridModel.setSortBy(sorter);
453
+ }
454
+
455
+ get store() {
456
+ return this.gridModel.store;
457
+ }
458
+
459
+ get empty() {
460
+ return this.gridModel.empty;
461
+ }
462
+
463
+ get selModel() {
464
+ return this.gridModel.selModel;
465
+ }
466
+
467
+ get hasSelection() {
468
+ return this.gridModel.hasSelection;
469
+ }
470
+
471
+ get selectedRecords() {
472
+ return this.gridModel.selectedRecords;
473
+ }
474
+
475
+ get selectedRecord() {
476
+ return this.gridModel.selectedRecord;
477
+ }
478
+
479
+ get selectedId() {
480
+ return this.gridModel.selectedId;
481
+ }
482
+
483
+ get groupBy() {
484
+ return this.gridModel.groupBy;
485
+ }
486
+
487
+ selectAsync(
488
+ records: Some<StoreRecordOrId>,
489
+ opts: {ensureVisible?: boolean; clearSelection?: boolean}
490
+ ) {
491
+ return this.gridModel.selectAsync(records, opts);
492
+ }
493
+
494
+ preSelectFirstAsync() {
495
+ return this.gridModel.preSelectFirstAsync();
496
+ }
497
+
498
+ selectFirstAsync(opts: {ensureVisible?: boolean} = {}) {
499
+ return this.gridModel.selectFirstAsync(opts);
500
+ }
501
+
502
+ ensureSelectionVisibleAsync() {
503
+ return this.gridModel.ensureSelectionVisibleAsync();
504
+ }
505
+
506
+ override doLoadAsync(loadSpec: LoadSpec) {
507
+ return this.gridModel.doLoadAsync(loadSpec);
508
+ }
509
+
510
+ loadData(rawData: any[], rawSummaryData?: PlainObject) {
511
+ return this.gridModel.loadData(rawData, rawSummaryData);
512
+ }
513
+
514
+ updateData(rawData: PlainObject[] | StoreTransaction) {
515
+ return this.gridModel.updateData(rawData);
516
+ }
517
+
518
+ clear() {
519
+ return this.gridModel.clear();
520
+ }
521
+
522
+ setGroupBy(colIds: Some<string>) {
523
+ return this.gridModel.setGroupBy(colIds);
524
+ }
525
+
526
+ //-----------------------
527
+ // Implementation
528
+ //-----------------------
529
+ private createGridModel(config: GridConfig): GridModel {
530
+ return new GridModel({
531
+ ...config,
532
+ xhImpl: true,
533
+ contextMenu: withDefault(config.contextMenu, this.getDefaultContextMenu),
534
+ sizingMode: 'standard',
535
+ cellBorders: true,
536
+ rowBorders: true,
537
+ stripeRows: false,
538
+ autosizeOptions: {mode: 'disabled'},
539
+ columns: this.getColumns()
540
+ });
541
+ }
542
+
543
+ private getColumns(): ColumnSpec[] {
544
+ return [
545
+ this.buildZoneColumn(true),
546
+ this.buildZoneColumn(false),
547
+ // Ensure all available columns are provided as hidden columns for lookup by multifield renderer
548
+ ...this.availableColumns
549
+ ];
550
+ }
551
+
552
+ private buildZoneColumn(isLeft: boolean): ColumnSpec {
553
+ const topMappings = this.mappings[isLeft ? 'tl' : 'tr'],
554
+ bottomMappings = this.mappings[isLeft ? 'bl' : 'br'];
555
+
556
+ throwIf(
557
+ isEmpty(topMappings),
558
+ `${isLeft ? 'Left' : 'Right'} column requires at least one top mapping`
559
+ );
560
+
561
+ // Extract the primary column from the top mappings
562
+ const primaryCol = new Column(this.findColumnSpec(topMappings[0]), this.gridModel);
563
+
564
+ // Extract the sub-fields from the other mappings
565
+ const subFields = [];
566
+ topMappings.slice(1).forEach(it => {
567
+ subFields.push({colId: it.field, label: it.showLabel, position: 'top'});
568
+ });
569
+ bottomMappings.forEach(it => {
570
+ subFields.push({colId: it.field, label: it.showLabel, position: 'bottom'});
571
+ });
572
+
573
+ return {
574
+ // Controlled properties
575
+ field: isLeft ? 'left_column' : 'right_column',
576
+ flex: isLeft ? 2 : 1,
577
+ align: isLeft ? 'left' : 'right',
578
+ renderer: multiFieldRenderer,
579
+ rowHeight: Grid['MULTIFIELD_ROW_HEIGHT'],
580
+ resizable: false,
581
+ movable: false,
582
+ hideable: false,
583
+ appData: {
584
+ multiFieldConfig: {
585
+ mainRenderer: primaryCol.renderer,
586
+ delimiter: this.delimiter,
587
+ subFields
588
+ }
589
+ },
590
+
591
+ // Properties inherited from primary column
592
+ headerName: primaryCol.headerName,
593
+ absSort: primaryCol.absSort,
594
+ sortingOrder: primaryCol.sortingOrder,
595
+ sortValue: primaryCol.sortValue,
596
+ sortToBottom: primaryCol.sortToBottom,
597
+ comparator: primaryCol.comparator,
598
+ sortable: primaryCol.sortable,
599
+ getValueFn: primaryCol.getValueFn,
600
+
601
+ // Optional overrides
602
+ ...(isLeft ? this.leftColumnSpec : this.rightColumnSpec)
603
+ };
604
+ }
605
+
606
+ private findColumnSpec(mapping: ZoneMapping): ColumnSpec {
607
+ return this.availableColumns.find(it => {
608
+ const {field} = it;
609
+ return isString(field) ? field === mapping.field : field.name === mapping.field;
610
+ });
611
+ }
612
+
613
+ private parseMappings(
614
+ mappings: Record<Zone, Some<string | ZoneMapping>>
615
+ ): Record<Zone, ZoneMapping[]> {
616
+ const ret = {} as Record<Zone, ZoneMapping[]>;
617
+ forOwn(mappings, (rawMapping, zone) => {
618
+ // 1) Standardize mapping into an array of ZoneMappings
619
+ const mapping = [];
620
+ castArray(rawMapping).forEach(it => {
621
+ if (!it) return;
622
+
623
+ const ret = isString(it) ? {field: it} : it,
624
+ col = this.findColumnSpec(ret);
625
+
626
+ throwIf(!col, `Column not found for field ${ret.field}`);
627
+ return mapping.push(ret);
628
+ });
629
+
630
+ // 2) Ensure mapping respects configured limits
631
+ const limit = this.limits?.[zone];
632
+ if (limit) {
633
+ throwIf(
634
+ isFinite(limit.min) && mapping.length < limit.min,
635
+ `Requires minimum ${limit.min} mappings in zone "${zone}"`
636
+ );
637
+ throwIf(
638
+ isFinite(limit.max) && mapping.length > limit.max,
639
+ `Exceeds maximum ${limit.max} mappings in zone "${zone}"`
640
+ );
641
+
642
+ if (!isEmpty(limit.only)) {
643
+ mapping.forEach(it => {
644
+ throwIf(
645
+ !limit.only.includes(it.field),
646
+ `Field "${it.field}" not allowed in zone "${zone}"`
647
+ );
648
+ });
649
+ }
650
+ }
651
+
652
+ ret[zone] = mapping;
653
+ });
654
+ return ret;
655
+ }
656
+
657
+ private parseMapperModel(mapperModel: ZoneMapperConfig | boolean): ZoneMapperModel {
658
+ if (isPlainObject(mapperModel)) {
659
+ return new ZoneMapperModel({
660
+ ...(mapperModel as ZoneMapperConfig),
661
+ zoneGridModel: this
662
+ });
663
+ }
664
+ return mapperModel ? new ZoneMapperModel({zoneGridModel: this}) : null;
665
+ }
666
+ }