@xh/hoist 49.0.0 → 49.1.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 +17 -2
- package/appcontainer/ThemeModel.js +4 -3
- package/cmp/filter/FilterChooserFieldSpec.js +8 -3
- package/cmp/filter/impl/Option.js +2 -0
- package/cmp/grid/columns/Core.js +5 -0
- package/cmp/grid/filter/GridFilterFieldSpec.js +2 -2
- package/cmp/grid/index.js +1 -0
- package/cmp/grid/renderers/TagsRenderer.js +12 -0
- package/cmp/grid/renderers/TagsRenderer.scss +31 -0
- package/core/XH.js +3 -2
- package/data/Field.js +15 -3
- package/data/filter/BaseFilterFieldSpec.js +6 -2
- package/data/filter/FieldFilter.js +20 -7
- package/desktop/cmp/dash/DashViewModel.js +10 -0
- package/desktop/cmp/dash/canvas/DashCanvas.js +1 -0
- package/desktop/cmp/dash/canvas/DashCanvas.scss +2 -1
- package/desktop/cmp/dash/canvas/DashCanvasModel.js +20 -2
- package/desktop/cmp/dash/canvas/DashCanvasViewModel.js +12 -1
- package/desktop/cmp/dash/canvas/DashCanvasViewSpec.js +4 -4
- package/desktop/cmp/dash/canvas/impl/DashCanvasView.js +6 -4
- package/desktop/cmp/dash/container/impl/DashContainerContextMenu.js +7 -3
- package/desktop/cmp/grid/impl/filter/ColumnHeaderFilterModel.js +9 -1
- package/desktop/cmp/grid/impl/filter/values/ValuesTabModel.js +19 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v49.1.0 - 2022-06-03
|
|
4
|
+
|
|
5
|
+
### 🎁 New Features
|
|
6
|
+
|
|
7
|
+
* A `DashCanvasViewModel` now supports `headerItems` and `extraMenuItems`
|
|
8
|
+
* `Store` now supports a `tags` field type
|
|
9
|
+
* `FieldFilter` supports `includes` and `excludes` operators for `tags` fields
|
|
10
|
+
|
|
11
|
+
### 🐞 Bug Fixes
|
|
12
|
+
* Fix regression with `begins`, `ends`, and `not like` filters.
|
|
13
|
+
* Fix `DashCanvas` styling so drag-handles no longer cause horizontal scroll bar to appear
|
|
14
|
+
* Fix bug where `DashCanvas` would not resize appropriately on scrollbar visibility change
|
|
15
|
+
|
|
16
|
+
[Commit Log](https://github.com/xh/hoist-react/compare/v49.0.0...v49.1.0)
|
|
17
|
+
|
|
3
18
|
## v49.0.0 - 2022-05-24
|
|
4
19
|
|
|
5
|
-
###
|
|
20
|
+
### 🎁 New Features
|
|
6
21
|
|
|
7
22
|
* Improved desktop `NumberInput`:
|
|
8
23
|
* Re-implemented `min` and `max` props to properly constrain the value entered and fix several
|
|
@@ -30,7 +45,7 @@
|
|
|
30
45
|
* Model classes passed to `HoistComponents` or configured in their factory must now
|
|
31
46
|
extend `HoistModel`. This has long been a core assumption, but was not previously enforced.
|
|
32
47
|
* Nested model instances stored at properties with a `_` prefix are now considered private and will
|
|
33
|
-
not be auto-wired or returned by model lookups. This should affect most apps, but will require
|
|
48
|
+
not be auto-wired or returned by model lookups. This should not affect most apps, but will require
|
|
34
49
|
minor changes for apps that were binding components to non-standard or "private" models.
|
|
35
50
|
* Hoist will now throw if `Store.summaryRecord` does not have a unique ID.
|
|
36
51
|
|
|
@@ -32,11 +32,10 @@ export class ThemeModel extends HoistModel {
|
|
|
32
32
|
classList.toggle('xh-dark', value);
|
|
33
33
|
classList.toggle('bp3-dark', value);
|
|
34
34
|
this.darkTheme = value;
|
|
35
|
-
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
@action
|
|
39
|
-
setTheme(value) {
|
|
38
|
+
setTheme(value, persist) {
|
|
40
39
|
switch (value) {
|
|
41
40
|
case 'system':
|
|
42
41
|
this.setDarkTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
@@ -50,7 +49,9 @@ export class ThemeModel extends HoistModel {
|
|
|
50
49
|
default:
|
|
51
50
|
throw XH.exception("Unrecognized value for theme pref. Must be either 'system', 'dark', or 'light'.");
|
|
52
51
|
}
|
|
53
|
-
|
|
52
|
+
if (persist) {
|
|
53
|
+
XH.setPref('xhTheme', value);
|
|
54
|
+
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
init() {
|
|
@@ -87,12 +87,11 @@ export class FilterChooserFieldSpec extends BaseFilterFieldSpec {
|
|
|
87
87
|
|
|
88
88
|
parseValue(value, op) {
|
|
89
89
|
try {
|
|
90
|
-
const {fieldType} = this;
|
|
91
|
-
|
|
92
90
|
if (isFunction(this.valueParser)) {
|
|
93
91
|
return this.valueParser(value, op);
|
|
94
92
|
}
|
|
95
93
|
|
|
94
|
+
const fieldType = this.fieldType === FieldType.TAGS ? FieldType.STRING : this.fieldType;
|
|
96
95
|
return parseFieldValue(value, fieldType, undefined);
|
|
97
96
|
} catch (e) {
|
|
98
97
|
return undefined;
|
|
@@ -119,7 +118,13 @@ export class FilterChooserFieldSpec extends BaseFilterFieldSpec {
|
|
|
119
118
|
const sourceStore = source.isView ? source.cube.store : source;
|
|
120
119
|
sourceStore.allRecords.forEach(rec => {
|
|
121
120
|
const val = rec.get(field);
|
|
122
|
-
if (!isNil(val))
|
|
121
|
+
if (!isNil(val)) {
|
|
122
|
+
if (sourceStore.getField(field).type === FieldType.TAGS) {
|
|
123
|
+
val.forEach(it => values.add(it));
|
|
124
|
+
} else {
|
|
125
|
+
values.add(val);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
this.values = Array.from(values);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {parseFieldValue} from '@xh/hoist/data';
|
|
9
9
|
import {isNil} from 'lodash';
|
|
10
|
+
import {FieldType} from '../../../data';
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------
|
|
12
13
|
// Generate Options for FilterChooserModel query responses.
|
|
@@ -56,6 +57,7 @@ export function fieldFilterOption({filter, fieldSpec, isExact = false}) {
|
|
|
56
57
|
displayValue = (filter.op === '!=' ? 'not blank' : 'blank');
|
|
57
58
|
} else {
|
|
58
59
|
displayOp = filter.op;
|
|
60
|
+
fieldType = fieldType === FieldType.TAGS ? FieldType.STRING : fieldType;
|
|
59
61
|
displayValue = fieldSpec.renderValue(parseFieldValue(filter.value, fieldType, null));
|
|
60
62
|
}
|
|
61
63
|
|
package/cmp/grid/columns/Core.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {numberRenderer} from '@xh/hoist/format';
|
|
8
8
|
import {Icon} from '@xh/hoist/icon';
|
|
9
|
+
import {tagsRenderer} from '../renderers/TagsRenderer';
|
|
9
10
|
|
|
10
11
|
/** Column config to render truthy values with a standardized green check icon. */
|
|
11
12
|
export const boolCheck = {
|
|
@@ -28,6 +29,10 @@ export const fileExt = {
|
|
|
28
29
|
renderer: (v) => v ? Icon.fileIcon({filename: v, title: v}) : null
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
export const tags = {
|
|
33
|
+
renderer: tagsRenderer
|
|
34
|
+
};
|
|
35
|
+
|
|
31
36
|
// Deprecated aliases with `Col` suffix
|
|
32
37
|
export const boolCheckCol = boolCheck;
|
|
33
38
|
export const numberCol = number;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {BaseFilterFieldSpec} from '@xh/hoist/data/filter/BaseFilterFieldSpec';
|
|
8
8
|
import {parseFilter} from '@xh/hoist/data';
|
|
9
|
-
import {castArray, compact, isDate, isEmpty, uniqBy} from 'lodash';
|
|
9
|
+
import {castArray, compact, flatten, isDate, isEmpty, uniqBy} from 'lodash';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Apps should NOT instantiate this class directly. Instead {@see GridFilterModel.fieldSpecs}
|
|
@@ -81,7 +81,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
|
|
|
81
81
|
|
|
82
82
|
// Combine unique values from record sets and column filters.
|
|
83
83
|
const allValues = uniqBy([
|
|
84
|
-
...allRecords.map(rec => this.valueFromRecord(rec)),
|
|
84
|
+
...flatten(allRecords.map(rec => this.valueFromRecord(rec))),
|
|
85
85
|
...filterValues
|
|
86
86
|
], this.getUniqueValue);
|
|
87
87
|
let values;
|
package/cmp/grid/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {isEmpty} from 'lodash';
|
|
2
|
+
import {div, hbox} from '@xh/hoist/cmp/layout';
|
|
3
|
+
import './TagsRenderer.scss';
|
|
4
|
+
|
|
5
|
+
export function tagsRenderer(v) {
|
|
6
|
+
if (isEmpty(v)) return null;
|
|
7
|
+
|
|
8
|
+
return hbox({
|
|
9
|
+
className: 'xh-tags-renderer',
|
|
10
|
+
items: v.map(tag => div({className: 'xh-tags-renderer__tag', item: tag}))
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.xh-tags-renderer {
|
|
2
|
+
&__tag {
|
|
3
|
+
--tag-bg: var(--xh-intent-neutral);
|
|
4
|
+
|
|
5
|
+
.xh-dark & {
|
|
6
|
+
--tag-bg: var(--xh-intent-neutral-darkest);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
position: relative;
|
|
10
|
+
border-radius: 1.5px;
|
|
11
|
+
font-size: 85%;
|
|
12
|
+
padding: 3px 6px 3px 3px;
|
|
13
|
+
margin: 2px 2px 2px 12px;
|
|
14
|
+
width: fit-content;
|
|
15
|
+
background-color: var(--tag-bg);
|
|
16
|
+
height: 20px;
|
|
17
|
+
line-height: 15px;
|
|
18
|
+
|
|
19
|
+
&::before {
|
|
20
|
+
content: '';
|
|
21
|
+
position: absolute;
|
|
22
|
+
left: -10px;
|
|
23
|
+
top: 0;
|
|
24
|
+
width: 0;
|
|
25
|
+
height: 0;
|
|
26
|
+
border-top: 10px solid transparent;
|
|
27
|
+
border-bottom: 10px solid transparent;
|
|
28
|
+
border-right: 10px solid var(--tag-bg);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/core/XH.js
CHANGED
|
@@ -343,9 +343,10 @@ class XHClass extends HoistBase {
|
|
|
343
343
|
/**
|
|
344
344
|
* Sets the theme directly (useful for custom app option controls).
|
|
345
345
|
* @param {string} value - 'light', 'dark', or 'system'
|
|
346
|
+
* @param {boolean} persist - true (default) to persist with preference
|
|
346
347
|
*/
|
|
347
|
-
setTheme(value) {
|
|
348
|
-
return this.acm.themeModel.setTheme(value);
|
|
348
|
+
setTheme(value, persist = true) {
|
|
349
|
+
return this.acm.themeModel.setTheme(value, persist);
|
|
349
350
|
}
|
|
350
351
|
|
|
351
352
|
/** Is the app currently rendering in dark theme? */
|
package/data/Field.js
CHANGED
|
@@ -10,7 +10,7 @@ import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
|
|
|
10
10
|
import {withDefault} from '@xh/hoist/utils/js';
|
|
11
11
|
import {Rule} from '@xh/hoist/data';
|
|
12
12
|
import equal from 'fast-deep-equal';
|
|
13
|
-
import {isDate, isString, toNumber, isFinite, startCase, isFunction} from 'lodash';
|
|
13
|
+
import {isDate, isString, toNumber, isFinite, startCase, isFunction, castArray} from 'lodash';
|
|
14
14
|
import DOMPurify from 'dompurify';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -84,13 +84,23 @@ export function parseFieldValue(val, type, defaultValue = null, disableXssProtec
|
|
|
84
84
|
if (val === undefined || val === null) val = defaultValue;
|
|
85
85
|
if (val === null) return val;
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
const sanitizeValue = (v) => {
|
|
88
|
+
if (disableXssProtection || !isString(v)) return v;
|
|
89
|
+
return DOMPurify.sanitize(v);
|
|
90
|
+
};
|
|
88
91
|
|
|
89
92
|
const FT = FieldType;
|
|
90
93
|
switch (type) {
|
|
94
|
+
case FT.TAGS:
|
|
95
|
+
val = castArray(val);
|
|
96
|
+
val = val.map(v => {
|
|
97
|
+
v = sanitizeValue(v);
|
|
98
|
+
return v.toString();
|
|
99
|
+
});
|
|
100
|
+
return val;
|
|
91
101
|
case FT.AUTO:
|
|
92
102
|
case FT.JSON:
|
|
93
|
-
return val;
|
|
103
|
+
return sanitizeValue(val);
|
|
94
104
|
case FT.INT:
|
|
95
105
|
val = toNumber(val);
|
|
96
106
|
return isFinite(val) ? Math.trunc(val) : null;
|
|
@@ -100,6 +110,7 @@ export function parseFieldValue(val, type, defaultValue = null, disableXssProtec
|
|
|
100
110
|
return !!val;
|
|
101
111
|
case FT.PWD:
|
|
102
112
|
case FT.STRING:
|
|
113
|
+
val = sanitizeValue(val);
|
|
103
114
|
return val.toString();
|
|
104
115
|
case FT.DATE:
|
|
105
116
|
return isDate(val) ? val : new Date(val);
|
|
@@ -112,6 +123,7 @@ export function parseFieldValue(val, type, defaultValue = null, disableXssProtec
|
|
|
112
123
|
|
|
113
124
|
/** @enum {string} - data types for Fields used within Hoist Store Records and Cubes. */
|
|
114
125
|
export const FieldType = Object.freeze({
|
|
126
|
+
TAGS: 'tags',
|
|
115
127
|
AUTO: 'auto',
|
|
116
128
|
BOOL: 'bool',
|
|
117
129
|
DATE: 'date',
|
|
@@ -93,7 +93,7 @@ export class BaseFilterFieldSpec extends HoistBase {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* @return {string} - 'range' or '
|
|
96
|
+
* @return {string} - 'range', 'value', or 'collection' - determines operations supported by this field.
|
|
97
97
|
* Type 'range' indicates the field should use mathematical / logical operations
|
|
98
98
|
* ('>', '>=', '<', '<=', '=', '!='). Type 'value' indicates the field should use equality
|
|
99
99
|
* operators ('=', '!=', 'like', 'not like', 'begins', 'ends') against a suggested
|
|
@@ -107,6 +107,8 @@ export class BaseFilterFieldSpec extends HoistBase {
|
|
|
107
107
|
case FT.DATE:
|
|
108
108
|
case FT.LOCAL_DATE:
|
|
109
109
|
return 'range';
|
|
110
|
+
case FT.TAGS:
|
|
111
|
+
return 'collection';
|
|
110
112
|
default:
|
|
111
113
|
return 'value';
|
|
112
114
|
}
|
|
@@ -114,6 +116,7 @@ export class BaseFilterFieldSpec extends HoistBase {
|
|
|
114
116
|
|
|
115
117
|
get isRangeType() { return this.filterType === 'range' }
|
|
116
118
|
get isValueType() { return this.filterType === 'value' }
|
|
119
|
+
get isCollectionType() { return this.filterType === 'collection' }
|
|
117
120
|
|
|
118
121
|
get isDateBasedFieldType() {
|
|
119
122
|
const {fieldType} = this;
|
|
@@ -151,7 +154,7 @@ export class BaseFilterFieldSpec extends HoistBase {
|
|
|
151
154
|
return this.values &&
|
|
152
155
|
this.enableValues &&
|
|
153
156
|
this.supportsOperator(op) &&
|
|
154
|
-
(op === '=' || op === '!=');
|
|
157
|
+
(op === '=' || op === '!=' || op === 'includes' || op === 'excludes');
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
//------------------------
|
|
@@ -169,6 +172,7 @@ export class BaseFilterFieldSpec extends HoistBase {
|
|
|
169
172
|
|
|
170
173
|
getDefaultOperators() {
|
|
171
174
|
if (this.isBoolFieldType) return ['='];
|
|
175
|
+
if (this.isCollectionType) return ['includes', 'excludes'];
|
|
172
176
|
return this.isValueType ?
|
|
173
177
|
['=', '!=', 'like', 'not like', 'begins', 'ends'] :
|
|
174
178
|
['>', '>=', '<', '<=', '=', '!='];
|
|
@@ -9,6 +9,7 @@ import {XH} from '@xh/hoist/core';
|
|
|
9
9
|
import {parseFieldValue} from '@xh/hoist/data';
|
|
10
10
|
import {throwIf} from '@xh/hoist/utils/js';
|
|
11
11
|
import {castArray, difference, escapeRegExp, isArray, isNil, isUndefined, isString} from 'lodash';
|
|
12
|
+
import {FieldType} from '../Field';
|
|
12
13
|
|
|
13
14
|
import {Filter} from './Filter';
|
|
14
15
|
|
|
@@ -32,8 +33,8 @@ export class FieldFilter extends Filter {
|
|
|
32
33
|
/** @member {*} */
|
|
33
34
|
value;
|
|
34
35
|
|
|
35
|
-
static OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'like', 'not like', 'begins', 'ends'];
|
|
36
|
-
static ARRAY_OPERATORS = ['=', '!=', 'like', 'not like', 'begins', 'ends'];
|
|
36
|
+
static OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'like', 'not like', 'begins', 'ends', 'includes', 'excludes'];
|
|
37
|
+
static ARRAY_OPERATORS = ['=', '!=', 'like', 'not like', 'begins', 'ends', 'includes', 'excludes'];
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Constructor - not typically called by apps - create from config via `parseFilter()` instead.
|
|
@@ -82,7 +83,7 @@ export class FieldFilter extends Filter {
|
|
|
82
83
|
const storeField = store.getField(field);
|
|
83
84
|
if (!storeField) return () => true; // Ignore (do not filter out) if field not in store
|
|
84
85
|
|
|
85
|
-
const fieldType = storeField.type;
|
|
86
|
+
const fieldType = storeField.type === FieldType.TAGS ? FieldType.STRING : storeField.type;
|
|
86
87
|
value = isArray(value) ?
|
|
87
88
|
value.map(v => parseFieldValue(v, fieldType)) :
|
|
88
89
|
parseFieldValue(value, fieldType);
|
|
@@ -97,7 +98,7 @@ export class FieldFilter extends Filter {
|
|
|
97
98
|
switch (op) {
|
|
98
99
|
case '=':
|
|
99
100
|
return r => {
|
|
100
|
-
if (doNotFilter(r)) return true;
|
|
101
|
+
if (doNotFilter(r)) return true;
|
|
101
102
|
let v = getVal(r);
|
|
102
103
|
if (isNil(v) || v === '') v = null;
|
|
103
104
|
return value.includes(v);
|
|
@@ -143,19 +144,31 @@ export class FieldFilter extends Filter {
|
|
|
143
144
|
regExps = value.map(v => new RegExp(escapeRegExp(v), 'i'));
|
|
144
145
|
return r => {
|
|
145
146
|
if (doNotFilter(r)) return true;
|
|
146
|
-
regExps.every(re => !re.test(getVal(r)));
|
|
147
|
+
return regExps.every(re => !re.test(getVal(r)));
|
|
147
148
|
};
|
|
148
149
|
case 'begins':
|
|
149
150
|
regExps = value.map(v => new RegExp('^' + escapeRegExp(v), 'i'));
|
|
150
151
|
return r => {
|
|
151
152
|
if (doNotFilter(r)) return true;
|
|
152
|
-
regExps.some(re => re.test(getVal(r)));
|
|
153
|
+
return regExps.some(re => re.test(getVal(r)));
|
|
153
154
|
};
|
|
154
155
|
case 'ends':
|
|
155
156
|
regExps = value.map(v => new RegExp(escapeRegExp(v) + '$', 'i'));
|
|
156
157
|
return r => {
|
|
157
158
|
if (doNotFilter(r)) return true;
|
|
158
|
-
regExps.some(re => re.test(getVal(r)));
|
|
159
|
+
return regExps.some(re => re.test(getVal(r)));
|
|
160
|
+
};
|
|
161
|
+
case 'includes':
|
|
162
|
+
return r => {
|
|
163
|
+
if (doNotFilter(r)) return true;
|
|
164
|
+
const v = getVal(r);
|
|
165
|
+
return !isNil(v) && v.some(it => value.includes(it));
|
|
166
|
+
};
|
|
167
|
+
case 'excludes':
|
|
168
|
+
return r => {
|
|
169
|
+
if (doNotFilter(r)) return true;
|
|
170
|
+
const v = getVal(r);
|
|
171
|
+
return isNil(v) || !v.some(it => value.includes(it));
|
|
159
172
|
};
|
|
160
173
|
default:
|
|
161
174
|
throw XH.exception(`Unknown operator: ${op}`);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {HoistModel, managed, ManagedRefreshContextModel} from '@xh/hoist/core';
|
|
8
8
|
import {bindable, makeObservable} from '@xh/hoist/mobx';
|
|
9
9
|
import {throwIf} from '@xh/hoist/utils/js';
|
|
10
|
+
import {action, observable} from 'mobx';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Model for a content item within a DashContainer. Supports state management,
|
|
@@ -30,6 +31,7 @@ export class DashViewModel extends HoistModel {
|
|
|
30
31
|
@bindable title;
|
|
31
32
|
@bindable.ref viewState;
|
|
32
33
|
@bindable isActive;
|
|
34
|
+
@observable.ref extraMenuItems = [];
|
|
33
35
|
|
|
34
36
|
@managed refreshContextModel;
|
|
35
37
|
|
|
@@ -79,4 +81,12 @@ export class DashViewModel extends HoistModel {
|
|
|
79
81
|
this.setViewState({...this.viewState, [key]: value});
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Specify array with which to create additional panel menu items
|
|
86
|
+
* @param {Object[]} items
|
|
87
|
+
*/
|
|
88
|
+
@action
|
|
89
|
+
setExtraMenuItems(items) {
|
|
90
|
+
this.extraMenuItems = items;
|
|
91
|
+
}
|
|
82
92
|
}
|
|
@@ -53,6 +53,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory({
|
|
|
53
53
|
autoSize: true,
|
|
54
54
|
isBounded: true,
|
|
55
55
|
draggableHandle: '.xh-panel > .xh-panel__content > .xh-panel-header',
|
|
56
|
+
draggableCancel: '.xh-button',
|
|
56
57
|
// Resizing always pins to the nw corner, so dragging from anywhere other than se sides/corner is unintuitive
|
|
57
58
|
resizeHandles: ['s', 'e', 'se'],
|
|
58
59
|
onLayoutChange: (layout) => model.setLayout(layout),
|
|
@@ -4,9 +4,9 @@ import {DashCanvasViewModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/das
|
|
|
4
4
|
import {Icon} from '@xh/hoist/icon';
|
|
5
5
|
import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
|
|
6
6
|
import {ensureUniqueBy} from '@xh/hoist/utils/js';
|
|
7
|
+
import {createObservableRef} from '@xh/hoist/utils/react';
|
|
7
8
|
import {defaultsDeep, isEqual, find, without, times} from 'lodash';
|
|
8
9
|
import {computed} from 'mobx';
|
|
9
|
-
import {createRef} from 'react';
|
|
10
10
|
import {throwIf} from '../../../../utils/js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -75,7 +75,9 @@ export class DashCanvasModel extends HoistModel {
|
|
|
75
75
|
// Implementation properties
|
|
76
76
|
//------------------------
|
|
77
77
|
/** @member {RefObject<DOMElement>} */
|
|
78
|
-
ref =
|
|
78
|
+
ref = createObservableRef();
|
|
79
|
+
/** @member {boolean} scrollbarVisible */
|
|
80
|
+
scrollbarVisible;
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
83
|
* ---------- !! NOTE: THIS COMPONENT IS CURRENTLY IN BETA !! ----------
|
|
@@ -168,6 +170,14 @@ export class DashCanvasModel extends HoistModel {
|
|
|
168
170
|
track: () => [this.viewState, this.layout],
|
|
169
171
|
run: () => this.publishState()
|
|
170
172
|
});
|
|
173
|
+
|
|
174
|
+
this.addReaction({
|
|
175
|
+
when: () => this.ref.current,
|
|
176
|
+
run: () => {
|
|
177
|
+
const {current: node} = this.ref;
|
|
178
|
+
this.scrollbarVisible = node.offsetWidth > node.clientWidth;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
/** @returns {boolean} */
|
|
@@ -338,6 +348,14 @@ export class DashCanvasModel extends HoistModel {
|
|
|
338
348
|
layout = layout.map(({i, x, y, w, h}) => ({i, x, y, w, h}));
|
|
339
349
|
if (!isEqual(this.layout, layout)) {
|
|
340
350
|
this.layout = layout;
|
|
351
|
+
|
|
352
|
+
// Check if scrollbar visibility has changed, and force resize event if so
|
|
353
|
+
const {current: node} = this.ref,
|
|
354
|
+
scrollbarVisible = node.offsetWidth > node.clientWidth;
|
|
355
|
+
if (scrollbarVisible !== this.scrollbarVisible) {
|
|
356
|
+
window.dispatchEvent(new Event('resize'));
|
|
357
|
+
this.scrollbarVisible = scrollbarVisible;
|
|
358
|
+
}
|
|
341
359
|
}
|
|
342
360
|
}
|
|
343
361
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {DashViewModel} from '@xh/hoist/desktop/cmp/dash/DashViewModel';
|
|
8
8
|
import {createObservableRef} from '@xh/hoist/utils/react';
|
|
9
|
-
import {makeObservable, observable} from 'mobx';
|
|
9
|
+
import {action, makeObservable, observable} from 'mobx';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Model for a content item within a DashCanvas. Extends {@see DashViewModel}
|
|
@@ -23,6 +23,8 @@ export class DashCanvasViewModel extends DashViewModel {
|
|
|
23
23
|
@observable hidePanelHeader;
|
|
24
24
|
/** @member {boolean} */
|
|
25
25
|
@observable hideMenuButton;
|
|
26
|
+
/** @member {Array} */
|
|
27
|
+
@observable.ref headerItems = [];
|
|
26
28
|
|
|
27
29
|
constructor(cfg) {
|
|
28
30
|
super(cfg);
|
|
@@ -44,4 +46,13 @@ export class DashCanvasViewModel extends DashViewModel {
|
|
|
44
46
|
run: () => ref.current.scrollIntoView({behavior: 'smooth', block: 'nearest'})
|
|
45
47
|
});
|
|
46
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Specify array of items to be added to the right-side of the panel header
|
|
52
|
+
* @param {ReactNode[]} items
|
|
53
|
+
*/
|
|
54
|
+
@action
|
|
55
|
+
setHeaderItems(items) {
|
|
56
|
+
this.headerItems = items;
|
|
57
|
+
}
|
|
47
58
|
}
|
|
@@ -27,10 +27,10 @@ export class DashCanvasViewSpec extends DashViewSpec {
|
|
|
27
27
|
hideMenuButton;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* @param {number} height - initial height of view when added to canvas (default 5)
|
|
31
|
-
* @param {number} width - initial width of view when added to canvas (default 5)
|
|
32
|
-
* @param {boolean} hidePanelHeader - true to hide the panel header (default false)
|
|
33
|
-
* @param {boolean} hideMenuButton - true to hide the panel header menu button (default false)
|
|
30
|
+
* @param {number} [height] - initial height of view when added to canvas (default 5)
|
|
31
|
+
* @param {number} [width] - initial width of view when added to canvas (default 5)
|
|
32
|
+
* @param {boolean} [hidePanelHeader] - true to hide the panel header (default false)
|
|
33
|
+
* @param {boolean} [hideMenuButton] - true to hide the panel header menu button (default false)
|
|
34
34
|
*/
|
|
35
35
|
constructor({
|
|
36
36
|
height = 5,
|
|
@@ -29,13 +29,13 @@ export const dashCanvasView = hoistCmp.factory({
|
|
|
29
29
|
className: 'xh-dash-tab',
|
|
30
30
|
model: uses(DashCanvasViewModel, {publishMode: ModelPublishMode.LIMITED}),
|
|
31
31
|
render({model, className}) {
|
|
32
|
-
const {viewSpec, ref, hidePanelHeader} = model,
|
|
32
|
+
const {viewSpec, ref, hidePanelHeader, headerItems} = model,
|
|
33
33
|
headerProps = hidePanelHeader ? {} : {
|
|
34
34
|
compactHeader: true,
|
|
35
35
|
title: model.title,
|
|
36
36
|
icon: model.icon,
|
|
37
37
|
headerItems: [
|
|
38
|
-
|
|
38
|
+
...headerItems,
|
|
39
39
|
headerMenu({model})
|
|
40
40
|
]
|
|
41
41
|
};
|
|
@@ -53,7 +53,7 @@ const headerMenu = hoistCmp.factory(
|
|
|
53
53
|
if (model.hideMenuButton) return null;
|
|
54
54
|
|
|
55
55
|
const {viewState, viewSpec, id, containerModel, positionParams, title} = model,
|
|
56
|
-
{
|
|
56
|
+
{contentLocked, renameLocked} = containerModel,
|
|
57
57
|
|
|
58
58
|
addMenuItems = createViewMenuItems({
|
|
59
59
|
dashCanvasModel: containerModel,
|
|
@@ -104,7 +104,9 @@ const headerMenu = hoistCmp.factory(
|
|
|
104
104
|
}).ensureVisible()
|
|
105
105
|
},
|
|
106
106
|
'-',
|
|
107
|
-
...(extraMenuItems ?? [])
|
|
107
|
+
...(model.extraMenuItems ?? []),
|
|
108
|
+
'-',
|
|
109
|
+
...(containerModel.extraMenuItems ?? [])
|
|
108
110
|
]
|
|
109
111
|
});
|
|
110
112
|
|
|
@@ -31,7 +31,7 @@ export const dashContainerContextMenu = hoistCmp.factory({
|
|
|
31
31
|
//---------------------------
|
|
32
32
|
function createMenuItems(props) {
|
|
33
33
|
const {dashContainerModel, viewModel} = props,
|
|
34
|
-
{
|
|
34
|
+
{renameLocked} = dashContainerModel,
|
|
35
35
|
ret = [];
|
|
36
36
|
|
|
37
37
|
// Add context sensitive items if clicked on a tab
|
|
@@ -69,10 +69,14 @@ function createMenuItems(props) {
|
|
|
69
69
|
items: addMenuItems
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
if (viewModel?.extraMenuItems) {
|
|
73
|
+
ret.push('-');
|
|
74
|
+
viewModel.extraMenuItems.forEach(it => ret.push(it));
|
|
75
|
+
}
|
|
72
76
|
|
|
73
|
-
if (extraMenuItems) {
|
|
77
|
+
if (dashContainerModel.extraMenuItems) {
|
|
74
78
|
ret.push('-');
|
|
75
|
-
extraMenuItems.forEach(it => ret.push(it));
|
|
79
|
+
dashContainerModel.extraMenuItems.forEach(it => ret.push(it));
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
return ret;
|
|
@@ -36,6 +36,14 @@ export class ColumnHeaderFilterModel extends HoistModel {
|
|
|
36
36
|
return this.fieldSpec.field;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
get store() {
|
|
40
|
+
return this.gridFilterModel.gridModel.store;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get fieldType() {
|
|
44
|
+
return this.store.getField(this.field).type;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
get currentGridFilter() {
|
|
40
48
|
return this.gridFilterModel.filter;
|
|
41
49
|
}
|
|
@@ -64,7 +72,7 @@ export class ColumnHeaderFilterModel extends HoistModel {
|
|
|
64
72
|
const {columnCompoundFilter, columnFilters} = this;
|
|
65
73
|
if (columnCompoundFilter) return true;
|
|
66
74
|
if (isEmpty(columnFilters)) return false;
|
|
67
|
-
return columnFilters.some(it => !['=', '!='].includes(it.op));
|
|
75
|
+
return columnFilters.some(it => !['=', '!=', 'includes'].includes(it.op));
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
get commitOnChange() {
|
|
@@ -8,7 +8,8 @@ import {HoistModel, managed, SizingMode} from '@xh/hoist/core';
|
|
|
8
8
|
import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
|
|
9
9
|
import {GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid';
|
|
10
10
|
import {checkbox} from '@xh/hoist/desktop/cmp/input';
|
|
11
|
-
import {castArray, difference, isEmpty, partition, without} from 'lodash';
|
|
11
|
+
import {castArray, difference, isEmpty, partition, uniq, without} from 'lodash';
|
|
12
|
+
import {FieldType} from '@xh/hoist/data';
|
|
12
13
|
|
|
13
14
|
export class ValuesTabModel extends HoistModel {
|
|
14
15
|
/** @member {ColumnHeaderFilterModel} */
|
|
@@ -104,7 +105,7 @@ export class ValuesTabModel extends HoistModel {
|
|
|
104
105
|
setRecsChecked(isChecked, values) {
|
|
105
106
|
values = castArray(values);
|
|
106
107
|
this.pendingValues = isChecked ?
|
|
107
|
-
[...this.pendingValues, ...values] :
|
|
108
|
+
uniq([...this.pendingValues, ...values]) :
|
|
108
109
|
without(this.pendingValues, ...values);
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -120,9 +121,16 @@ export class ValuesTabModel extends HoistModel {
|
|
|
120
121
|
return null;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
const
|
|
124
|
-
|
|
124
|
+
const {fieldType} = this.headerFilterModel;
|
|
125
|
+
let arr, op;
|
|
126
|
+
if (fieldType === FieldType.TAGS) {
|
|
127
|
+
arr = included;
|
|
128
|
+
op = 'includes';
|
|
129
|
+
} else {
|
|
130
|
+
const weight = valueCount <= 10 ? 2.5 : 1; // Prefer '=' for short lists
|
|
131
|
+
op = included.length > (excluded.length * weight) ? '!=' : '=';
|
|
125
132
|
arr = op === '=' ? included : excluded;
|
|
133
|
+
}
|
|
126
134
|
|
|
127
135
|
if (isEmpty(arr)) return null;
|
|
128
136
|
|
|
@@ -132,14 +140,16 @@ export class ValuesTabModel extends HoistModel {
|
|
|
132
140
|
|
|
133
141
|
@action
|
|
134
142
|
doSyncWithFilter() {
|
|
135
|
-
const {values, columnFilters, gridFilterModel} = this
|
|
143
|
+
const {values, columnFilters, gridFilterModel} = this,
|
|
144
|
+
{fieldType} = this.headerFilterModel;
|
|
145
|
+
|
|
136
146
|
if (isEmpty(columnFilters)) {
|
|
137
|
-
this.pendingValues = values;
|
|
147
|
+
this.pendingValues = fieldType === FieldType.TAGS ? [] : values;
|
|
138
148
|
return;
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
// We are only interested '!=' filters if we have no '=' filters.
|
|
142
|
-
const [equalsFilters, notEqualsFilters] = partition(columnFilters, f => f.op === '='),
|
|
152
|
+
const [equalsFilters, notEqualsFilters] = partition(columnFilters, f => f.op === '=' || f.op === 'includes'),
|
|
143
153
|
useNotEquals = isEmpty(equalsFilters),
|
|
144
154
|
arr = useNotEquals ? notEqualsFilters : equalsFilters,
|
|
145
155
|
filterValues = [];
|
|
@@ -170,7 +180,8 @@ export class ValuesTabModel extends HoistModel {
|
|
|
170
180
|
createGridModel() {
|
|
171
181
|
const {BLANK_STR} = this.gridFilterModel,
|
|
172
182
|
{align, headerAlign, displayName} = this.headerFilterModel.column,
|
|
173
|
-
|
|
183
|
+
{fieldType} = this.headerFilterModel,
|
|
184
|
+
renderer = this.fieldSpec.renderer ?? (fieldType !== FieldType.TAGS ? this.headerFilterModel.column.renderer : null);
|
|
174
185
|
|
|
175
186
|
return new GridModel({
|
|
176
187
|
store: {
|