@gitlab/ui 61.2.0 → 61.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 (24) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/components/base/filtered_search/common_story_options.js +2 -1
  3. package/dist/components/base/filtered_search/filtered_search.js +28 -4
  4. package/dist/components/base/filtered_search/filtered_search_suggestion_list.js +13 -5
  5. package/dist/components/base/filtered_search/filtered_search_term.js +51 -4
  6. package/dist/components/base/filtered_search/filtered_search_token.js +1 -2
  7. package/dist/components/base/filtered_search/filtered_search_token_segment.js +18 -5
  8. package/dist/components/base/filtered_search/filtered_search_utils.js +18 -7
  9. package/package.json +1 -1
  10. package/src/components/base/filtered_search/__snapshots__/filtered_search_term.spec.js.snap +2 -5
  11. package/src/components/base/filtered_search/common_story_options.js +1 -0
  12. package/src/components/base/filtered_search/filtered_search.md +10 -1
  13. package/src/components/base/filtered_search/filtered_search.spec.js +14 -2
  14. package/src/components/base/filtered_search/filtered_search.stories.js +17 -0
  15. package/src/components/base/filtered_search/filtered_search.vue +29 -1
  16. package/src/components/base/filtered_search/filtered_search_suggestion_list.spec.js +222 -75
  17. package/src/components/base/filtered_search/filtered_search_suggestion_list.vue +15 -6
  18. package/src/components/base/filtered_search/filtered_search_term.spec.js +73 -14
  19. package/src/components/base/filtered_search/filtered_search_term.vue +64 -6
  20. package/src/components/base/filtered_search/filtered_search_token.spec.js +1 -0
  21. package/src/components/base/filtered_search/filtered_search_token.vue +2 -2
  22. package/src/components/base/filtered_search/filtered_search_token_segment.spec.js +46 -2
  23. package/src/components/base/filtered_search/filtered_search_token_segment.vue +22 -5
  24. package/src/components/base/filtered_search/filtered_search_utils.js +20 -7
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [61.3.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v61.2.0...v61.3.0) (2023-04-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **GlFilteredSearch:** Terms as tokens ([7149a54](https://gitlab.com/gitlab-org/gitlab-ui/commit/7149a541f4dc29d7f364ee1ab02a1c88be3824ed))
7
+
1
8
  # [61.2.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v61.1.3...v61.2.0) (2023-04-18)
2
9
 
3
10
 
@@ -7,7 +7,8 @@ const noop = () => {};
7
7
  const provide = () => ({
8
8
  portalName: 'portal',
9
9
  alignSuggestions: noop,
10
- suggestionsListClass: noop
10
+ suggestionsListClass: noop,
11
+ termsAsTokens: () => false
11
12
  });
12
13
 
13
14
  export { provide };
@@ -6,7 +6,7 @@ import { GlTooltipDirective } from '../../../directives/tooltip';
6
6
  import GlIcon from '../icon/icon';
7
7
  import GlSearchBoxByClick from '../search_box_by_click/search_box_by_click';
8
8
  import GlFilteredSearchTerm from './filtered_search_term';
9
- import { createTerm, needDenormalization, denormalizeTokens, isEmptyTerm, INTENT_ACTIVATE_PREVIOUS, ensureTokenId, normalizeTokens } from './filtered_search_utils';
9
+ import { termTokenDefinition, createTerm, needDenormalization, denormalizeTokens, isEmptyTerm, INTENT_ACTIVATE_PREVIOUS, ensureTokenId, normalizeTokens } from './filtered_search_utils';
10
10
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
11
11
 
12
12
  Vue.use(PortalVue);
@@ -32,7 +32,8 @@ var script = {
32
32
  // Return a function reference instead of a prop to work around vue-apollo@3 bug.
33
33
  // TODO: This can be reverted once https://github.com/vuejs/vue-apollo/pull/1153
34
34
  // has been merged and we consume it, or we upgrade to vue-apollo@4.
35
- suggestionsListClass: () => this.suggestionsListClass
35
+ suggestionsListClass: () => this.suggestionsListClass,
36
+ termsAsTokens: () => this.termsAsTokens
36
37
  };
37
38
  },
38
39
  inheritAttrs: false,
@@ -128,6 +129,28 @@ var script = {
128
129
  type: Boolean,
129
130
  required: false,
130
131
  default: false
132
+ },
133
+ /**
134
+ * Render search terms as GlTokens. Ideally, this prop will be as
135
+ * short-lived as possible, and this behavior will become the default and
136
+ * only behavior.
137
+ *
138
+ * This prop is *not* reactive.
139
+ *
140
+ * See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159.
141
+ */
142
+ termsAsTokens: {
143
+ type: Boolean,
144
+ required: false,
145
+ default: false
146
+ },
147
+ /**
148
+ * The title of the text search option. Ignored unless termsAsTokens is enabled.
149
+ */
150
+ searchTextOptionLabel: {
151
+ type: String,
152
+ required: false,
153
+ default: termTokenDefinition.title
131
154
  }
132
155
  },
133
156
  data() {
@@ -200,7 +223,7 @@ var script = {
200
223
  },
201
224
  methods: {
202
225
  applyNewValue(newValue) {
203
- this.tokens = needDenormalization(newValue) ? denormalizeTokens(newValue) : newValue;
226
+ this.tokens = needDenormalization(newValue) ? denormalizeTokens(newValue, this.termsAsTokens) : newValue;
204
227
  },
205
228
  isActiveToken(idx) {
206
229
  return this.activeTokenIdx === idx;
@@ -300,6 +323,7 @@ var script = {
300
323
  }));
301
324
  this.activeTokenIdx = idx;
302
325
  },
326
+ // This method can be deleted once termsAsTokens behavior is the default.
303
327
  createTokens(idx) {
304
328
  let newStrings = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [''];
305
329
  if (!this.isLastTokenActive && newStrings.length === 1 && newStrings[0] === '') {
@@ -331,7 +355,7 @@ var script = {
331
355
  const __vue_script__ = script;
332
356
 
333
357
  /* template */
334
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-search-box-by-click',_vm._b({attrs:{"value":_vm.tokens,"history-items":_vm.historyItems,"clearable":_vm.hasValue,"search-button-attributes":_vm.searchButtonAttributes,"disabled":_vm.viewOnly,"data-testid":"filtered-search-input"},on:{"submit":_vm.submit,"input":_vm.applyNewValue,"history-item-selected":function($event){return _vm.$emit('history-item-selected', $event)},"clear":function($event){return _vm.$emit('clear')},"clear-history":function($event){return _vm.$emit('clear-history')}},scopedSlots:_vm._u([{key:"history-item",fn:function(slotScope){return [_vm._t("history-item",null,null,slotScope)]}},{key:"input",fn:function(){return [_c('div',{staticClass:"gl-filtered-search-scrollable",class:{ 'gl-bg-gray-10! gl-inset-border-1-gray-100!': _vm.viewOnly }},_vm._l((_vm.tokens),function(token,idx){return _c(_vm.getTokenComponent(token.type),{key:token.id,ref:"tokens",refInFor:true,tag:"component",class:_vm.getTokenClassList(idx),attrs:{"config":_vm.getTokenEntry(token.type),"active":_vm.activeTokenIdx === idx,"cursor-position":_vm.intendedCursorPosition,"available-tokens":_vm.currentAvailableTokens,"current-value":_vm.tokens,"index":idx,"placeholder":_vm.termPlaceholder,"show-friendly-text":_vm.showFriendlyText,"search-input-attributes":_vm.searchInputAttributes,"view-only":_vm.viewOnly,"is-last-token":_vm.isLastToken(idx)},on:{"activate":function($event){return _vm.activate(idx)},"deactivate":function($event){return _vm.deactivate(token)},"destroy":function($event){return _vm.destroyToken(idx, $event)},"replace":function($event){return _vm.replaceToken(idx, $event)},"complete":_vm.completeToken,"submit":_vm.submit,"split":function($event){return _vm.createTokens(idx, $event)},"previous":_vm.activatePreviousToken,"next":_vm.activateNextToken},model:{value:(token.value),callback:function ($$v) {_vm.$set(token, "value", $$v);},expression:"token.value"}})}),1),_vm._v(" "),_c('portal-target',{key:_vm.activeTokenIdx,ref:"menu",style:(_vm.suggestionsStyle),attrs:{"name":_vm.portalName,"slim":""}})]},proxy:true}],null,true)},'gl-search-box-by-click',_vm.$attrs,false))};
358
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-search-box-by-click',_vm._b({attrs:{"value":_vm.tokens,"history-items":_vm.historyItems,"clearable":_vm.hasValue,"search-button-attributes":_vm.searchButtonAttributes,"disabled":_vm.viewOnly,"data-testid":"filtered-search-input"},on:{"submit":_vm.submit,"input":_vm.applyNewValue,"history-item-selected":function($event){return _vm.$emit('history-item-selected', $event)},"clear":function($event){return _vm.$emit('clear')},"clear-history":function($event){return _vm.$emit('clear-history')}},scopedSlots:_vm._u([{key:"history-item",fn:function(slotScope){return [_vm._t("history-item",null,null,slotScope)]}},{key:"input",fn:function(){return [_c('div',{staticClass:"gl-filtered-search-scrollable",class:{ 'gl-bg-gray-10! gl-inset-border-1-gray-100!': _vm.viewOnly }},_vm._l((_vm.tokens),function(token,idx){return _c(_vm.getTokenComponent(token.type),{key:token.id,ref:"tokens",refInFor:true,tag:"component",class:_vm.getTokenClassList(idx),attrs:{"config":_vm.getTokenEntry(token.type),"active":_vm.activeTokenIdx === idx,"cursor-position":_vm.intendedCursorPosition,"available-tokens":_vm.currentAvailableTokens,"current-value":_vm.tokens,"index":idx,"placeholder":_vm.termPlaceholder,"show-friendly-text":_vm.showFriendlyText,"search-input-attributes":_vm.searchInputAttributes,"view-only":_vm.viewOnly,"is-last-token":_vm.isLastToken(idx),"search-text-option-label":_vm.searchTextOptionLabel},on:{"activate":function($event){return _vm.activate(idx)},"deactivate":function($event){return _vm.deactivate(token)},"destroy":function($event){return _vm.destroyToken(idx, $event)},"replace":function($event){return _vm.replaceToken(idx, $event)},"complete":_vm.completeToken,"submit":_vm.submit,"split":function($event){return _vm.createTokens(idx, $event)},"previous":_vm.activatePreviousToken,"next":_vm.activateNextToken},model:{value:(token.value),callback:function ($$v) {_vm.$set(token, "value", $$v);},expression:"token.value"}})}),1),_vm._v(" "),_c('portal-target',{key:_vm.activeTokenIdx,ref:"menu",style:(_vm.suggestionsStyle),attrs:{"name":_vm.portalName,"slim":""}})]},proxy:true}],null,true)},'gl-search-box-by-click',_vm.$attrs,false))};
335
359
  var __vue_staticRenderFns__ = [];
336
360
 
337
361
  /* style */
@@ -5,7 +5,7 @@ const DEFER_TO_INITIAL_VALUE = -1;
5
5
  const NO_ACTIVE_ITEM = -2;
6
6
  var script = {
7
7
  name: 'GlFilteredSearchSuggestionList',
8
- inject: ['suggestionsListClass'],
8
+ inject: ['suggestionsListClass', 'termsAsTokens'],
9
9
  provide() {
10
10
  return {
11
11
  filteredSearchSuggestionListInstance: this
@@ -35,7 +35,7 @@ var script = {
35
35
  return this.registeredItems[this.initialActiveIdx];
36
36
  },
37
37
  activeItem() {
38
- if (this.activeIdx === NO_ACTIVE_ITEM) return null;
38
+ if (!this.termsAsTokens() && this.activeIdx === NO_ACTIVE_ITEM) return null;
39
39
  if (this.activeIdx === DEFER_TO_INITIAL_VALUE) return this.initialActiveItem;
40
40
  return this.registeredItems[this.activeIdx];
41
41
  },
@@ -66,13 +66,21 @@ var script = {
66
66
  }
67
67
  },
68
68
  nextItem() {
69
- this.stepItem(1, this.registeredItems.length - 1);
69
+ if (this.termsAsTokens()) {
70
+ this.stepItem(1);
71
+ } else {
72
+ this.stepItem(1, this.registeredItems.length - 1);
73
+ }
70
74
  },
71
75
  prevItem() {
72
- this.stepItem(-1, 0);
76
+ if (this.termsAsTokens()) {
77
+ this.stepItem(-1);
78
+ } else {
79
+ this.stepItem(-1, 0);
80
+ }
73
81
  },
74
82
  stepItem(direction, endIdx) {
75
- if (this.activeIdx === endIdx || this.activeIdx === DEFER_TO_INITIAL_VALUE && this.initialActiveIdx === endIdx) {
83
+ if (!this.termsAsTokens() && (this.activeIdx === endIdx || this.activeIdx === DEFER_TO_INITIAL_VALUE && this.initialActiveIdx === endIdx)) {
76
84
  // The user wants to move past the end of the list, so ensure nothing is selected.
77
85
  this.activeIdx = NO_ACTIVE_ITEM;
78
86
  } else {
@@ -1,12 +1,16 @@
1
+ import GlToken from '../token/token';
2
+ import { stopEvent } from '../../../utils/utils';
1
3
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment';
2
- import { match, tokenToOption, INTENT_ACTIVATE_PREVIOUS } from './filtered_search_utils';
4
+ import { termTokenDefinition, match, tokenToOption, INTENT_ACTIVATE_PREVIOUS, TOKEN_CLOSE_SELECTOR, TERM_TOKEN_TYPE } from './filtered_search_utils';
3
5
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
4
6
 
5
7
  var script = {
6
8
  name: 'GlFilteredSearchTerm',
7
9
  components: {
8
- GlFilteredSearchTokenSegment
10
+ GlFilteredSearchTokenSegment,
11
+ GlToken
9
12
  },
13
+ inject: ['termsAsTokens'],
10
14
  inheritAttrs: false,
11
15
  props: {
12
16
  /**
@@ -69,6 +73,14 @@ var script = {
69
73
  default: 'end',
70
74
  validator: value => ['start', 'end'].includes(value)
71
75
  },
76
+ /**
77
+ * The title of the text search option. Ignored unless termsAsTokens is enabled.
78
+ */
79
+ searchTextOptionLabel: {
80
+ type: String,
81
+ required: false,
82
+ default: termTokenDefinition.title
83
+ },
72
84
  viewOnly: {
73
85
  type: Boolean,
74
86
  required: false,
@@ -76,8 +88,21 @@ var script = {
76
88
  }
77
89
  },
78
90
  computed: {
91
+ showInput() {
92
+ return this.termsAsTokens() || Boolean(this.placeholder);
93
+ },
94
+ showToken() {
95
+ return this.termsAsTokens() && Boolean(this.value.data);
96
+ },
79
97
  suggestedTokens() {
80
- return this.availableTokens.filter(token => match(token.title, this.value.data)).map(tokenToOption);
98
+ const tokens = this.availableTokens.filter(token => match(token.title, this.value.data));
99
+ if (this.termsAsTokens() && this.value.data) {
100
+ tokens.push({
101
+ ...termTokenDefinition,
102
+ title: this.searchTextOptionLabel
103
+ });
104
+ }
105
+ return tokens.map(tokenToOption);
81
106
  },
82
107
  internalValue: {
83
108
  get() {
@@ -94,6 +119,11 @@ var script = {
94
119
  data
95
120
  });
96
121
  }
122
+ },
123
+ eventListeners() {
124
+ return this.viewOnly ? {} : {
125
+ mousedown: this.destroyByClose
126
+ };
97
127
  }
98
128
  },
99
129
  methods: {
@@ -108,6 +138,23 @@ var script = {
108
138
  this.$emit('destroy', {
109
139
  intent: INTENT_ACTIVATE_PREVIOUS
110
140
  });
141
+ },
142
+ destroyByClose(event) {
143
+ if (event.target.closest(TOKEN_CLOSE_SELECTOR)) {
144
+ stopEvent(event);
145
+ this.$emit('destroy');
146
+ }
147
+ },
148
+ onComplete(type) {
149
+ if (type === TERM_TOKEN_TYPE) {
150
+ // We've completed this term token
151
+ this.$emit('complete');
152
+ } else {
153
+ // We're changing the current token type
154
+ this.$emit('replace', {
155
+ type
156
+ });
157
+ }
111
158
  }
112
159
  }
113
160
  };
@@ -116,7 +163,7 @@ var script = {
116
163
  const __vue_script__ = script;
117
164
 
118
165
  /* template */
119
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-h-auto gl-filtered-search-term",attrs:{"data-testid":"filtered-search-term"}},[_c('gl-filtered-search-token-segment',{ref:"segment",staticClass:"gl-filtered-search-term-token",attrs:{"is-term":"","active":_vm.active,"cursor-position":_vm.cursorPosition,"search-input-attributes":_vm.searchInputAttributes,"is-last-token":_vm.isLastToken,"current-value":_vm.currentValue,"view-only":_vm.viewOnly,"options":_vm.suggestedTokens},on:{"activate":function($event){return _vm.$emit('activate')},"deactivate":function($event){return _vm.$emit('deactivate')},"complete":function($event){return _vm.$emit('replace', { type: $event })},"backspace":_vm.onBackspace,"submit":function($event){return _vm.$emit('submit')},"split":function($event){return _vm.$emit('split', $event)},"previous":function($event){return _vm.$emit('previous')},"next":function($event){return _vm.$emit('next')}},scopedSlots:_vm._u([{key:"view",fn:function(){return [(_vm.placeholder)?_c('input',_vm._b({staticClass:"gl-filtered-search-term-input",class:{ 'gl-bg-gray-10': _vm.viewOnly },attrs:{"placeholder":_vm.placeholder,"aria-label":_vm.placeholder,"readonly":_vm.viewOnly,"data-testid":"filtered-search-term-input"},on:{"focusin":function($event){return _vm.$emit('activate')},"focusout":function($event){return _vm.$emit('deactivate')}}},'input',_vm.searchInputAttributes,false)):[_vm._v(_vm._s(_vm.value.data))]]},proxy:true}]),model:{value:(_vm.internalValue),callback:function ($$v) {_vm.internalValue=$$v;},expression:"internalValue"}})],1)};
166
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-h-auto gl-filtered-search-term",attrs:{"data-testid":"filtered-search-term"}},[_c('gl-filtered-search-token-segment',{ref:"segment",staticClass:"gl-filtered-search-term-token",attrs:{"is-term":"","active":_vm.active,"cursor-position":_vm.cursorPosition,"search-input-attributes":_vm.searchInputAttributes,"is-last-token":_vm.isLastToken,"current-value":_vm.currentValue,"view-only":_vm.viewOnly,"options":_vm.suggestedTokens},on:{"activate":function($event){return _vm.$emit('activate')},"deactivate":function($event){return _vm.$emit('deactivate')},"complete":_vm.onComplete,"backspace":_vm.onBackspace,"submit":function($event){return _vm.$emit('submit')},"split":function($event){return _vm.$emit('split', $event)},"previous":function($event){return _vm.$emit('previous')},"next":function($event){return _vm.$emit('next')}},scopedSlots:_vm._u([{key:"view",fn:function(){return [(_vm.showToken)?_c('gl-token',_vm._g({class:{ 'gl-cursor-pointer': !_vm.viewOnly },attrs:{"view-only":_vm.viewOnly}},_vm.eventListeners),[_vm._v(_vm._s(_vm.value.data))]):(_vm.showInput)?_c('input',_vm._b({staticClass:"gl-filtered-search-term-input",class:{ 'gl-bg-gray-10': _vm.viewOnly },attrs:{"placeholder":_vm.placeholder,"aria-label":_vm.placeholder,"readonly":_vm.viewOnly,"data-testid":"filtered-search-term-input"},on:{"focusin":function($event){return _vm.$emit('activate')},"focusout":function($event){return _vm.$emit('deactivate')}}},'input',_vm.searchInputAttributes,false)):[_vm._v(_vm._s(_vm.value.data))]]},proxy:true}]),model:{value:(_vm.internalValue),callback:function ($$v) {_vm.internalValue=$$v;},expression:"internalValue"}})],1)};
120
167
  var __vue_staticRenderFns__ = [];
121
168
 
122
169
  /* style */
@@ -2,13 +2,12 @@ import cloneDeep from 'lodash/cloneDeep';
2
2
  import { COMMA } from '../../../utils/constants';
3
3
  import GlToken from '../token/token';
4
4
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment';
5
- import { tokenToOption, createTerm } from './filtered_search_utils';
5
+ import { tokenToOption, createTerm, TOKEN_CLOSE_SELECTOR } from './filtered_search_utils';
6
6
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
7
7
 
8
8
  const SEGMENT_TITLE = 'TYPE';
9
9
  const SEGMENT_OPERATOR = 'OPERATOR';
10
10
  const SEGMENT_DATA = 'DATA';
11
- const TOKEN_CLOSE_SELECTOR = '.gl-token-close';
12
11
  const DEFAULT_OPERATORS = [{
13
12
  value: '=',
14
13
  description: 'is',
@@ -3,7 +3,7 @@ import { Portal } from 'portal-vue';
3
3
  import { COMMA, LEFT_MOUSE_BUTTON } from '../../../utils/constants';
4
4
  import GlFilteredSearchSuggestion from './filtered_search_suggestion';
5
5
  import GlFilteredSearchSuggestionList from './filtered_search_suggestion_list';
6
- import { splitOnQuotes, match, wrapTokenInQuotes } from './filtered_search_utils';
6
+ import { TERM_TOKEN_TYPE, splitOnQuotes, match, wrapTokenInQuotes } from './filtered_search_utils';
7
7
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
8
8
 
9
9
  // We need some helpers to ensure @vue/compat compatibility
@@ -49,7 +49,7 @@ var script = {
49
49
  GlFilteredSearchSuggestionList,
50
50
  GlFilteredSearchSuggestion
51
51
  },
52
- inject: ['portalName', 'alignSuggestions'],
52
+ inject: ['portalName', 'alignSuggestions', 'termsAsTokens'],
53
53
  inheritAttrs: false,
54
54
  props: {
55
55
  /**
@@ -136,6 +136,16 @@ var script = {
136
136
  };
137
137
  },
138
138
  computed: {
139
+ hasTermSuggestion() {
140
+ if (!this.termsAsTokens()) return false;
141
+ if (!this.options) return false;
142
+ return this.options.some(_ref => {
143
+ let {
144
+ value
145
+ } = _ref;
146
+ return value === TERM_TOKEN_TYPE;
147
+ });
148
+ },
139
149
  matchingOption() {
140
150
  var _this$options;
141
151
  return (_this$options = this.options) === null || _this$options === void 0 ? void 0 : _this$options.find(o => o.value === this.value);
@@ -173,7 +183,9 @@ var script = {
173
183
  const option = this.getMatchingOptionForInputValue(this.inputValue) || this.getMatchingOptionForInputValue(this.inputValue, {
174
184
  loose: true
175
185
  });
176
- return option === null || option === void 0 ? void 0 : option.value;
186
+ if (option) return option.value;
187
+ if (this.hasTermSuggestion) return TERM_TOKEN_TYPE;
188
+ return null;
177
189
  }
178
190
  const defaultOption = this.options.find(op => op.default);
179
191
  if (defaultOption) {
@@ -197,6 +209,7 @@ var script = {
197
209
  }
198
210
  },
199
211
  inputValue(newValue) {
212
+ if (this.termsAsTokens()) return;
200
213
  const hasUnclosedQuote = newValue.split('"').length % 2 === 0;
201
214
  if (newValue.indexOf(' ') === -1 || hasUnclosedQuote) {
202
215
  return;
@@ -260,7 +273,7 @@ var script = {
260
273
  }
261
274
  },
262
275
  applySuggestion(suggestedValue) {
263
- const formattedSuggestedValue = wrapTokenInQuotes(suggestedValue);
276
+ const formattedSuggestedValue = this.termsAsTokens() ? suggestedValue : wrapTokenInQuotes(suggestedValue);
264
277
 
265
278
  /**
266
279
  * Emitted when autocomplete entry is selected.
@@ -269,7 +282,7 @@ var script = {
269
282
  */
270
283
  this.$emit('select', formattedSuggestedValue);
271
284
  if (!this.multiSelect) {
272
- this.$emit('input', formattedSuggestedValue);
285
+ this.$emit('input', formattedSuggestedValue === TERM_TOKEN_TYPE ? this.inputValue : formattedSuggestedValue);
273
286
  this.$emit('complete', formattedSuggestedValue);
274
287
  }
275
288
  },
@@ -5,6 +5,7 @@ import { modulo } from '../../../utils/number_utils';
5
5
 
6
6
  const TERM_TOKEN_TYPE = 'filtered-search-term';
7
7
  const INTENT_ACTIVATE_PREVIOUS = 'intent-activate-previous';
8
+ const TOKEN_CLOSE_SELECTOR = '.gl-token-close';
8
9
  function isEmptyTerm(token) {
9
10
  return token.type === TERM_TOKEN_TYPE && token.value.data.trim() === '';
10
11
  }
@@ -137,18 +138,23 @@ function createTerm() {
137
138
  };
138
139
  }
139
140
  function denormalizeTokens(inputTokens) {
141
+ let termsAsTokens = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
140
142
  assertValidTokens(inputTokens);
141
143
  const tokens = Array.isArray(inputTokens) ? inputTokens : [inputTokens];
142
- const result = [];
143
- tokens.forEach(t => {
144
+ return tokens.reduce((result, t) => {
144
145
  if (typeof t === 'string') {
145
- const stringTokens = t.split(' ').filter(Boolean);
146
- stringTokens.forEach(strToken => result.push(createTerm(strToken)));
146
+ if (termsAsTokens) {
147
+ const trimmedText = t.trim();
148
+ if (trimmedText) result.push(createTerm(trimmedText));
149
+ } else {
150
+ const stringTokens = t.split(' ').filter(Boolean);
151
+ stringTokens.forEach(strToken => result.push(createTerm(strToken)));
152
+ }
147
153
  } else {
148
154
  result.push(ensureTokenId(t));
149
155
  }
150
- });
151
- return result;
156
+ return result;
157
+ }, []);
152
158
  }
153
159
 
154
160
  /**
@@ -163,6 +169,11 @@ function denormalizeTokens(inputTokens) {
163
169
  function match(text, query) {
164
170
  return text.toLowerCase().includes(query.toLowerCase());
165
171
  }
172
+ const termTokenDefinition = {
173
+ type: TERM_TOKEN_TYPE,
174
+ icon: 'title',
175
+ title: 'Search for this text'
176
+ };
166
177
  function splitOnQuotes(str) {
167
178
  if (first(str) === "'" && last(str) === "'") {
168
179
  return [str];
@@ -228,4 +239,4 @@ function wrapTokenInQuotes(token) {
228
239
  return `"${token}"`;
229
240
  }
230
241
 
231
- export { INTENT_ACTIVATE_PREVIOUS, TERM_TOKEN_TYPE, createTerm, denormalizeTokens, ensureTokenId, isEmptyTerm, match, needDenormalization, normalizeTokens, splitOnQuotes, stepIndexAndWrap, tokenToOption, wrapTokenInQuotes };
242
+ export { INTENT_ACTIVATE_PREVIOUS, TERM_TOKEN_TYPE, TOKEN_CLOSE_SELECTOR, createTerm, denormalizeTokens, ensureTokenId, isEmptyTerm, match, needDenormalization, normalizeTokens, splitOnQuotes, stepIndexAndWrap, termTokenDefinition, tokenToOption, wrapTokenInQuotes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "61.2.0",
3
+ "version": "61.3.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -1,19 +1,16 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
- exports[`Filtered search term renders input with value in active mode 1`] = `
3
+ exports[`Filtered search term renders nothing with value in active mode (delegates to segment) 1`] = `
4
4
  <div
5
5
  class="gl-h-auto gl-filtered-search-term"
6
6
  data-testid="filtered-search-term"
7
7
  >
8
8
  <div
9
- active="true"
10
9
  class="gl-filtered-search-term-token"
11
10
  cursor-position="end"
12
11
  is-term=""
13
12
  value="test-value"
14
- >
15
- test-value
16
- </div>
13
+ />
17
14
  </div>
18
15
  `;
19
16
 
@@ -8,4 +8,5 @@ export const provide = () => ({
8
8
  portalName: 'portal',
9
9
  alignSuggestions: noop,
10
10
  suggestionsListClass: noop,
11
+ termsAsTokens: () => false,
11
12
  });
@@ -48,6 +48,15 @@ The token should emit the following events:
48
48
  - `split`: token requests adding string values after the current token.
49
49
  - `complete`: token indicates its editing is completed.
50
50
 
51
+ ### Improve space handling
52
+
53
+ Set the `terms-as-tokens` prop to `true` to enable new term rendering and
54
+ interaction behavior. This makes it easier to input/edit free text tokens, and
55
+ removes the need for quoting values with spaces and other workarounds.
56
+
57
+ In future, this prop will be enabled by default and eventually removed. Opt in
58
+ to this earlier rather than later to ease migration.
59
+
51
60
  ## Examples
52
61
 
53
62
  Define a list of available tokens:
@@ -65,5 +74,5 @@ realtime updates:
65
74
  <!-- Empty initial line is a workaround for https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2102 -->
66
75
  ```html
67
76
 
68
- <gl-filtered-search :available-tokens="tokens" v-model="value" />
77
+ <gl-filtered-search :available-tokens="tokens" v-model="value" terms-as-tokens />
69
78
  ```
@@ -95,7 +95,7 @@ describe('Filtered search', () => {
95
95
  });
96
96
  await nextTick();
97
97
 
98
- const inputEventArgs = wrapper.emitted().input[1][0];
98
+ const [inputEventArgs] = wrapper.emitted().input.at(-1);
99
99
  expect(inputEventArgs.every((t) => t.type === TERM_TOKEN_TYPE)).toBe(true);
100
100
  expect(inputEventArgs.map((t) => t.value.data)).toStrictEqual(['one', 'two', '']);
101
101
  });
@@ -106,10 +106,22 @@ describe('Filtered search', () => {
106
106
  });
107
107
  await nextTick();
108
108
 
109
- const inputEventArgs = wrapper.emitted().input[1][0];
109
+ const [inputEventArgs] = wrapper.emitted().input.at(-1);
110
110
  expect(inputEventArgs.every((t) => t.type === TERM_TOKEN_TYPE)).toBe(true);
111
111
  expect(inputEventArgs.map((t) => t.value.data)).toStrictEqual(['one', 'two', '']);
112
112
  });
113
+
114
+ it('does not split strings with termsAsTokens', async () => {
115
+ createComponent({
116
+ termsAsTokens: true,
117
+ value: ['one two'],
118
+ });
119
+ await nextTick();
120
+
121
+ const [inputEventArgs] = wrapper.emitted().input.at(-1);
122
+ expect(inputEventArgs.every((t) => t.type === TERM_TOKEN_TYPE)).toBe(true);
123
+ expect(inputEventArgs.map((t) => t.value.data)).toStrictEqual(['one two', '']);
124
+ });
113
125
  });
114
126
 
115
127
  describe('event handling', () => {
@@ -329,6 +329,23 @@ export const Default = () => ({
329
329
  template: `<gl-filtered-search :available-tokens="tokens" :value="value" />`,
330
330
  });
331
331
 
332
+ export const WithTermsAsTokens = () => ({
333
+ data() {
334
+ return {
335
+ tokens,
336
+ value: [
337
+ { type: 'author', value: { data: 'beta', operator: '=' } },
338
+ { type: 'label', value: { data: 'Bug', operator: '=' } },
339
+ 'raw text',
340
+ ],
341
+ };
342
+ },
343
+ components,
344
+ template: `
345
+ <gl-filtered-search :available-tokens="tokens" v-model="value" terms-as-tokens />
346
+ `,
347
+ });
348
+
332
349
  export const ViewOnly = () => ({
333
350
  data() {
334
351
  return {
@@ -15,6 +15,7 @@ import {
15
15
  normalizeTokens,
16
16
  denormalizeTokens,
17
17
  needDenormalization,
18
+ termTokenDefinition,
18
19
  } from './filtered_search_utils';
19
20
 
20
21
  Vue.use(PortalVue);
@@ -43,6 +44,7 @@ export default {
43
44
  // TODO: This can be reverted once https://github.com/vuejs/vue-apollo/pull/1153
44
45
  // has been merged and we consume it, or we upgrade to vue-apollo@4.
45
46
  suggestionsListClass: () => this.suggestionsListClass,
47
+ termsAsTokens: () => this.termsAsTokens,
46
48
  };
47
49
  },
48
50
  inheritAttrs: false,
@@ -137,6 +139,28 @@ export default {
137
139
  required: false,
138
140
  default: false,
139
141
  },
142
+ /**
143
+ * Render search terms as GlTokens. Ideally, this prop will be as
144
+ * short-lived as possible, and this behavior will become the default and
145
+ * only behavior.
146
+ *
147
+ * This prop is *not* reactive.
148
+ *
149
+ * See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159.
150
+ */
151
+ termsAsTokens: {
152
+ type: Boolean,
153
+ required: false,
154
+ default: false,
155
+ },
156
+ /**
157
+ * The title of the text search option. Ignored unless termsAsTokens is enabled.
158
+ */
159
+ searchTextOptionLabel: {
160
+ type: String,
161
+ required: false,
162
+ default: termTokenDefinition.title,
163
+ },
140
164
  },
141
165
  data() {
142
166
  return {
@@ -212,7 +236,9 @@ export default {
212
236
 
213
237
  methods: {
214
238
  applyNewValue(newValue) {
215
- this.tokens = needDenormalization(newValue) ? denormalizeTokens(newValue) : newValue;
239
+ this.tokens = needDenormalization(newValue)
240
+ ? denormalizeTokens(newValue, this.termsAsTokens)
241
+ : newValue;
216
242
  },
217
243
 
218
244
  isActiveToken(idx) {
@@ -317,6 +343,7 @@ export default {
317
343
  this.activeTokenIdx = idx;
318
344
  },
319
345
 
346
+ // This method can be deleted once termsAsTokens behavior is the default.
320
347
  createTokens(idx, newStrings = ['']) {
321
348
  if (!this.isLastTokenActive && newStrings.length === 1 && newStrings[0] === '') {
322
349
  this.activeTokenIdx = this.lastTokenIdx;
@@ -391,6 +418,7 @@ export default {
391
418
  :view-only="viewOnly"
392
419
  :is-last-token="isLastToken(idx)"
393
420
  :class="getTokenClassList(idx)"
421
+ :search-text-option-label="searchTextOptionLabel"
394
422
  @activate="activate(idx)"
395
423
  @deactivate="deactivate(token)"
396
424
  @destroy="destroyToken(idx, $event)"