Repository navigation
Expand file tree
/
Copy pathGroupingChooserModel.ts
More file actions
402 lines (344 loc) · 13 KB
/
Copy pathGroupingChooserModel.ts
File metadata and controls
402 lines (344 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
/*
* This file belongs to Hoist, an application development toolkit
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
*
* Copyright © 2026 Extremely Heavy Industries Inc.
*/
import {
HoistModel,
PersistableState,
PersistenceProvider,
PersistOptions,
persistOptions
} from '@xh/hoist/core';
import type {GridModel} from '@xh/hoist/cmp/grid';
import {Field, genDisplayName, View} from '@xh/hoist/data';
import {action, computed, observableRef, computedStruct} from '@xh/hoist/mobx';
import {executeIfFunction, throwIf} from '@xh/hoist/utils/js';
import {isArray, isEmpty, isEqual, isObject, isString, keys, sortBy} from 'lodash';
/**
* Configuration for a {@link GroupingChooserModel} - a control for selecting multi-level
* dimension groupings, typically bound to a Cube {@link View} or {@link GridModel}.
*
* @see GroupingChooserModel
* @see DimensionSpec
*/
export interface GroupingChooserConfig {
/** True to accept an empty list as a valid value. */
allowEmpty?: boolean;
/**
* Target ({@link GridModel} or Cube {@link View}) to which this model's grouping value
* should be automatically applied as it changes. When bound to a GridModel, calls
* `setGroupBy()`; when bound to a View, calls `updateQuery({dimensions: ...})`.
*
* This is a two-way binding - changes to the target's value are reflected back into
* the GroupingChooserModel automatically.
*/
bind?: GroupingBindTarget;
/**
* False (default) waits for the user to dismiss the popover before updating the
* external/observable value.
*/
commitOnChange?: boolean;
/**
* Dimensions available for selection. When using GroupingChooser to create Cube queries,
* it is recommended to pass the `dimensions` from the related cube (or a subset thereof).
* Note that {@link CubeField} meets the `DimensionSpec` interface.
*
* If omitted and `bind` is provided, dimensions will be auto-populated from the target:
* fields with `isDimension: true` from a GridModel's store, or from a View's associated Cube.
* If provided alongside `bind`, dimensions will be validated against the target's fields.
*/
dimensions?: (DimensionSpec | string)[];
/**
* Initial favorites as an array of dim name arrays, or a function to produce such an array.
* Ignored if `persistWith.persistFavorites: false`.
*/
initialFavorites?: string[][] | (() => string[][]);
/** Initial value as an array of dimension names, or a function to produce such an array. */
initialValue?: string[] | (() => string[]);
/** Maximum number of dimensions allowed in a single grouping. */
maxDepth?: number;
/** Options governing persistence. */
persistWith?: GroupingChooserPersistOptions;
/**
* True (default) to auto-sort dimensions by label. Set to false to show them in the order
* provided in the `dimensions` config.
*/
sortDimensions?: boolean;
/** See {@link HoistBase.xhName}. */
xhName?: string;
}
export interface GroupingChooserModelDefaults {
commitOnChange?: boolean;
}
/**
* Metadata for dimensions that are available for selection via a GroupingChooser control.
* Note that {@link CubeField} instances satisfy this interface.
*/
export interface DimensionSpec {
/** Shortname or code (almost always a `CubeField.name`). */
name: string;
/** User-friendly / longer name for display. */
displayName?: string;
}
export interface GroupingChooserPersistOptions extends PersistOptions {
/** True (default) to include value or provide value-specific PersistOptions. */
persistValue?: boolean | PersistOptions;
/** True (default) to include favorites or provide favorites-specific PersistOptions. */
persistFavorites?: boolean | PersistOptions;
}
/** Target to which GroupingChooser value changes should be automatically synced. */
export type GroupingBindTarget = GridModel | View;
/**
* Model for a control that allows users to select and order a list of dimensions for use with
* grouping APIs, such as Grid `groupBy` or Cube queries.
*
* Manages the current dimension selection, available dimensions, user-managed favorites, and
* drag-and-drop reordering. Supports bidirectional binding to a {@link GridModel} or Cube
* {@link View} via the `bind` config - grouping changes are automatically applied to the
* target, and external changes to the target are reflected back into this model.
*
* Dimensions can be auto-populated from the bind target or specified explicitly. When binding
* to a Cube View, {@link CubeField} instances satisfy the {@link DimensionSpec} interface.
*
* Supports persistence of both the current value and favorites via `persistWith`.
*
* @see GroupingChooser
*/
export class GroupingChooserModel extends HoistModel {
/** App-level defaults for GroupingChooserModel. Instance config takes precedence. */
static defaults: GroupingChooserModelDefaults = {
commitOnChange: false
};
@observableRef accessor value: string[];
@observableRef accessor favorites: string[][] = [];
allowEmpty: boolean;
bind: GroupingBindTarget;
commitOnChange: boolean;
maxDepth: number;
persistFavorites: boolean = false;
sortDimensions: boolean;
@observableRef accessor dimensions: Record<string, DimensionSpec>;
@observableRef accessor dimensionNames: string[];
@computed
get dimensionSpecs(): DimensionSpec[] {
return Object.values(this.dimensions);
}
@computed
get valueDisplayNames(): string[] {
return this.value.map(dimName => this.getDimDisplayName(dimName));
}
constructor({
allowEmpty = false,
bind = null,
commitOnChange = GroupingChooserModel.defaults.commitOnChange,
dimensions,
initialFavorites = [],
initialValue = [],
maxDepth = null,
persistWith = null,
sortDimensions = true,
xhName = null
}: GroupingChooserConfig) {
super();
this.xhName = xhName;
this.allowEmpty = allowEmpty;
this.bind = bind;
this.commitOnChange = commitOnChange;
this.maxDepth = maxDepth;
this.sortDimensions = sortDimensions;
// Auto-populate dimensions from bind target if not explicitly provided.
if (!dimensions && bind) {
dimensions = this.getDimensionsFromTarget();
}
this.setDimensions(dimensions);
// Read and validate value and favorites
let value = executeIfFunction(initialValue),
favorites = executeIfFunction(initialFavorites);
throwIf(isEmpty(value) && !this.allowEmpty, 'Initial value cannot be empty.');
throwIf(!this.validateValue(value), 'Initial value is invalid.');
this.setValue(value);
this.setFavorites(favorites);
if (persistWith) this.initPersist(persistWith);
if (bind) {
this.addReaction({
track: () => this.value,
run: value => this.updateTargetValue(value),
fireImmediately: true
});
this.addReaction({
track: () => this.targetValue,
run: targetValue => {
if (isEqual(this.value, targetValue)) return;
throwIf(
!this.validateValue(targetValue),
`Bound target has grouping dimensions not present in GroupingChooserModel: [${targetValue}].`
);
this.setValue(targetValue);
}
});
}
}
@action
setDimensions(dimensions: Array<DimensionSpec | string>) {
throwIf(
isEmpty(dimensions) && !this.allowEmpty,
'Must provide valid dimensions available for selection.'
);
this.dimensions = this.normalizeDimensions(dimensions);
this.dimensionNames = keys(this.dimensions);
if (this.bind) this.ensureDimensionsValid();
this.removeUnknownDimsFromValue();
}
@action
setValue(value: string[]) {
if (!this.validateValue(value)) {
this.logWarn('Attempted to set invalid value', value);
return;
}
this.value = value;
}
validateValue(value: string[]) {
if (!isArray(value)) return false;
if (isEmpty(value) && !this.allowEmpty) return false;
return value.every(dim => this.dimensionNames.includes(dim));
}
getValueLabel(value: string[]): string {
return value.map(dimName => this.getDimDisplayName(dimName)).join(' › ');
}
getDimDisplayName(dimName: string) {
return this.dimensions[dimName]?.displayName ?? dimName;
}
//--------------------
// Favorites
//--------------------
get favoritesOptions() {
return sortBy(
this.favorites.map(value => ({
value,
label: this.getValueLabel(value)
})),
'label'
);
}
@computed
get hasFavorites() {
return !isEmpty(this.favorites);
}
@action
setFavorites(favorites: string[][]) {
this.favorites = favorites.filter(v => this.validateValue(v));
}
@action
addFavorite(value: string[]) {
if (isEmpty(value) || this.isFavorite(value)) return;
this.favorites = [...this.favorites, value];
}
@action
removeFavorite(value: string[]) {
this.favorites = this.favorites.filter(v => !isEqual(v, value));
}
isFavorite(value: string[]) {
return this.favorites?.some(v => isEqual(v, value));
}
//------------------------
// Implementation
//------------------------
@computedStruct
private get targetValue(): string[] {
const {bind} = this;
if (!bind) return null;
if (bind instanceof View) {
return bind.query?.dimensions?.map(d => d.name) ?? [];
} else {
return bind.groupBy ?? [];
}
}
private updateTargetValue(value: string[]) {
const {bind} = this;
if (bind instanceof View) {
bind.updateQuery({dimensions: value});
} else {
bind.setGroupBy(value);
}
}
private get targetFields(): Field[] {
const {bind} = this;
return bind instanceof View ? bind.cube.fields : bind.store.fields;
}
private getDimensionsFromTarget(): DimensionSpec[] {
return this.targetFields.filter(f => f.isDimension);
}
private ensureDimensionsValid() {
const targetFieldNames = this.targetFields.map(f => f.name);
throwIf(
this.dimensionNames.some(d => !targetFieldNames.includes(d)),
"GroupingChooserModel has dimensions not found in bound target's fields."
);
}
private initPersist({
persistValue = true,
persistFavorites = true,
path = 'groupingChooser',
...rootPersistWith
}: GroupingChooserPersistOptions) {
if (persistValue) {
PersistenceProvider.create({
persistOptions: persistOptions(
{path: `${path}.value`},
rootPersistWith,
isObject(persistValue) ? persistValue : null
),
target: {
getPersistableState: () => new PersistableState(this.value),
setPersistableState: ({value}) => this.setValue(value)
},
owner: this
});
}
if (persistFavorites) {
const provider = PersistenceProvider.create({
persistOptions: persistOptions(
{path: `${path}.favorites`},
rootPersistWith,
isObject(persistFavorites) ? persistFavorites : null
),
target: {
getPersistableState: () => new PersistableState(this.favorites),
setPersistableState: ({value}) => this.setFavorites(value)
},
owner: this
});
if (provider) this.persistFavorites = true;
}
}
private normalizeDimensions(
dims: Array<DimensionSpec | string>
): Record<string, DimensionSpec> {
dims = dims ?? [];
const ret = {};
dims.forEach(it => {
const dim = this.createDimension(it);
ret[dim.name] = dim;
});
return ret;
}
private createDimension(src: DimensionSpec | string) {
src = isString(src) ? {name: src} : src;
throwIf(
!src.hasOwnProperty('name'),
"Dimensions provided as Objects must define a 'name' property."
);
return {displayName: genDisplayName(src.name), ...src};
}
private removeUnknownDimsFromValue() {
const {value, dimensionNames, allowEmpty} = this,
cleanValue = value?.filter(dim => dimensionNames.includes(dim));
if (isEqual(value, cleanValue)) return;
if (isEmpty(cleanValue) && !allowEmpty) {
cleanValue.push(dimensionNames[0]);
}
this.setValue(cleanValue);
}
}