@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 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
- ### 🐞 New Features
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
- XH.setPref('xhTheme', value);
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)) values.add(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
 
@@ -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
@@ -13,6 +13,7 @@ export * from './GridAutosizeMode';
13
13
  export * from './helpers/GridCountLabel';
14
14
 
15
15
  export * from './renderers/MultiFieldRenderer';
16
+ export * from './renderers/TagsRenderer';
16
17
 
17
18
  export * from './Grid';
18
19
  export * from './GridModel';
@@ -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
- if (!disableXssProtection && isString(val)) val = DOMPurify.sanitize(val);
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 'value' - determines operations supported by this field.
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),
@@ -1,7 +1,8 @@
1
1
  .xh-dash-canvas {
2
2
  width: 100%;
3
3
  height: 100%;
4
- overflow: auto;
4
+ overflow-x: hidden;
5
+ overflow-y: auto;
5
6
 
6
7
  &--empty-overlay {
7
8
  display: flex !important;
@@ -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 = createRef();
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
- // TODO - Investigate why {model} must be passed explicitly here
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
- {extraMenuItems, contentLocked, renameLocked} = containerModel,
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
- {extraMenuItems, renameLocked} = dashContainerModel,
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 weight = valueCount <= 10 ? 2.5 : 1, // Prefer '=' for short lists
124
- op = included.length > (excluded.length * weight) ? '!=' : '=',
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
- renderer = this.fieldSpec.renderer ?? this.headerFilterModel.column.renderer;
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "49.0.0",
3
+ "version": "49.1.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",