@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
@@ -7,13 +7,14 @@ describe('Filtered search suggestion list component', () => {
7
7
  let wrapper;
8
8
 
9
9
  describe('suggestions API', () => {
10
- beforeEach(() => {
10
+ const createComponent = ({ termsAsTokens = false } = {}) => {
11
11
  wrapper = shallowMount(FilteredSearchSuggestionList, {
12
- provide: { suggestionsListClass: () => 'custom-class' },
12
+ provide: { suggestionsListClass: () => 'custom-class', termsAsTokens: () => termsAsTokens },
13
13
  });
14
- });
14
+ };
15
15
 
16
16
  it('has expected public methods', () => {
17
+ createComponent();
17
18
  expect(wrapper.vm.register).toBeInstanceOf(Function);
18
19
  expect(wrapper.vm.unregister).toBeInstanceOf(Function);
19
20
  expect(wrapper.vm.getValue).toBeInstanceOf(Function);
@@ -24,6 +25,7 @@ describe('Filtered search suggestion list component', () => {
24
25
  describe('navigation', () => {
25
26
  const stubs = [{ value: 'stub1' }, { value: 'stub2' }, { value: 'stub3' }];
26
27
  beforeEach(() => {
28
+ createComponent();
27
29
  stubs.forEach((s) => {
28
30
  wrapper.vm.register(s);
29
31
  });
@@ -71,6 +73,58 @@ describe('Filtered search suggestion list component', () => {
71
73
  expect(wrapper.vm.getValue()).toBe('stub2');
72
74
  });
73
75
  });
76
+
77
+ describe('navigation with termsAsTokens', () => {
78
+ const stubs = [{ value: 'stub1' }, { value: 'stub2' }, { value: 'stub3' }];
79
+ beforeEach(() => {
80
+ createComponent({ termsAsTokens: true });
81
+ stubs.forEach((s) => {
82
+ wrapper.vm.register(s);
83
+ });
84
+ });
85
+
86
+ it('does not select item by default', () => {
87
+ expect(wrapper.vm.getValue()).toBe(null);
88
+ });
89
+
90
+ it('selects first item on nextItem call', async () => {
91
+ wrapper.vm.nextItem();
92
+ await nextTick();
93
+ expect(wrapper.vm.getValue()).toBe(stubs[0].value);
94
+ });
95
+
96
+ it('wraps to last item on prevItem call', async () => {
97
+ wrapper.vm.nextItem();
98
+ wrapper.vm.prevItem();
99
+ await nextTick();
100
+ expect(wrapper.vm.getValue()).toBe(stubs[2].value);
101
+ });
102
+
103
+ it('wraps to first item on nextItem call', async () => {
104
+ stubs.forEach(() => wrapper.vm.nextItem());
105
+ wrapper.vm.nextItem();
106
+ await nextTick();
107
+ expect(wrapper.vm.getValue()).toBe(stubs[0].value);
108
+ });
109
+
110
+ it('remove selection if suggestion is unregistered', async () => {
111
+ wrapper.vm.nextItem();
112
+ await nextTick();
113
+ wrapper.vm.unregister(stubs[0]);
114
+ await nextTick();
115
+ expect(wrapper.vm.getValue()).toBe(null);
116
+ });
117
+
118
+ it('selects correct suggestion when item (un)registration is late', async () => {
119
+ // Initially stub2 is at index 1.
120
+ await wrapper.setProps({ initialValue: 'stub2' });
121
+ // Remove item at index 0, so stub2 moves to index 0
122
+ wrapper.vm.unregister(stubs[0]);
123
+ await nextTick();
124
+ // stub2 should still be selected
125
+ expect(wrapper.vm.getValue()).toBe('stub2');
126
+ });
127
+ });
74
128
  });
75
129
 
76
130
  describe('integration tests', () => {
@@ -87,6 +141,17 @@ describe('Filtered search suggestion list component', () => {
87
141
  `,
88
142
  };
89
143
 
144
+ const createComponent = ({ termsAsTokens = false } = {}) => {
145
+ wrapper = mount(FilteredSearchSuggestionList, {
146
+ provide: { suggestionsListClass: () => 'custom-class', termsAsTokens: () => termsAsTokens },
147
+ slots: {
148
+ default: list,
149
+ },
150
+ });
151
+ };
152
+
153
+ const findActiveSuggestion = () => wrapper.find('.gl-filtered-search-suggestion-active');
154
+
90
155
  beforeAll(() => {
91
156
  if (!HTMLElement.prototype.scrollIntoView) {
92
157
  HTMLElement.prototype.scrollIntoView = jest.fn();
@@ -99,92 +164,174 @@ describe('Filtered search suggestion list component', () => {
99
164
  }
100
165
  });
101
166
 
102
- beforeEach(() => {
103
- wrapper = mount(FilteredSearchSuggestionList, {
104
- provide: { suggestionsListClass: () => 'custom-class' },
105
- slots: {
106
- default: list,
107
- },
167
+ describe('with termsAsTokens = false', () => {
168
+ beforeEach(() => {
169
+ createComponent();
108
170
  });
109
- });
110
171
 
111
- it('selects first suggestion', async () => {
112
- wrapper.vm.nextItem();
113
- await nextTick();
114
- expect(wrapper.vm.getValue()).toBe('One');
115
- });
172
+ it('selects first suggestion', async () => {
173
+ wrapper.vm.nextItem();
174
+ await nextTick();
175
+ expect(wrapper.vm.getValue()).toBe('One');
176
+ });
116
177
 
117
- it('selects second suggestion', async () => {
118
- wrapper.vm.nextItem();
119
- wrapper.vm.nextItem();
120
- await nextTick();
121
- expect(wrapper.vm.getValue()).toBe('Two');
122
- });
178
+ it('selects second suggestion', async () => {
179
+ wrapper.vm.nextItem();
180
+ wrapper.vm.nextItem();
181
+ await nextTick();
182
+ expect(wrapper.vm.getValue()).toBe('Two');
183
+ });
123
184
 
124
- it('deselects first suggestion after list end', async () => {
125
- wrapper.vm.nextItem();
126
- wrapper.vm.nextItem();
127
- wrapper.vm.nextItem();
128
- wrapper.vm.nextItem();
129
- await nextTick();
130
- expect(wrapper.vm.getValue()).toBe(null);
131
- });
185
+ it('deselects first suggestion after list end', async () => {
186
+ wrapper.vm.nextItem();
187
+ wrapper.vm.nextItem();
188
+ wrapper.vm.nextItem();
189
+ wrapper.vm.nextItem();
190
+ await nextTick();
191
+ expect(wrapper.vm.getValue()).toBe(null);
192
+ });
132
193
 
133
- it('deselects first suggestion after list start', async () => {
134
- wrapper.vm.nextItem();
135
- wrapper.vm.prevItem();
136
- await nextTick();
137
- expect(wrapper.vm.getValue()).toBe(null);
138
- });
194
+ it('deselects first suggestion after list start', async () => {
195
+ wrapper.vm.nextItem();
196
+ wrapper.vm.prevItem();
197
+ await nextTick();
198
+ expect(wrapper.vm.getValue()).toBe(null);
199
+ });
139
200
 
140
- it('selects last suggestion in circle when selecting previous item', async () => {
141
- wrapper.vm.nextItem();
142
- wrapper.vm.prevItem();
143
- wrapper.vm.prevItem();
144
- await nextTick();
145
- expect(wrapper.vm.getValue()).toBe(false);
146
- });
201
+ it('selects last suggestion in circle when selecting previous item', async () => {
202
+ wrapper.vm.nextItem();
203
+ wrapper.vm.prevItem();
204
+ wrapper.vm.prevItem();
205
+ await nextTick();
206
+ expect(wrapper.vm.getValue()).toBe(false);
207
+ });
147
208
 
148
- it('selects first suggestion in circle when selecting next item', async () => {
149
- wrapper.vm.nextItem();
150
- wrapper.vm.nextItem();
151
- wrapper.vm.nextItem();
152
- wrapper.vm.nextItem();
153
- wrapper.vm.nextItem();
154
- await nextTick();
155
- expect(wrapper.vm.getValue()).toBe('One');
156
- });
209
+ it('selects first suggestion in circle when selecting next item', async () => {
210
+ wrapper.vm.nextItem();
211
+ wrapper.vm.nextItem();
212
+ wrapper.vm.nextItem();
213
+ wrapper.vm.nextItem();
214
+ wrapper.vm.nextItem();
215
+ await nextTick();
216
+ expect(wrapper.vm.getValue()).toBe('One');
217
+ });
157
218
 
158
- it('highlights suggestion if initial-value is provided', async () => {
159
- await wrapper.setProps({ initialValue: 'Two' });
160
- expect(wrapper.find('.gl-filtered-search-suggestion-active').text()).toBe('Two');
161
- });
219
+ it('highlights suggestion if initial-value is provided', async () => {
220
+ await wrapper.setProps({ initialValue: 'Two' });
221
+ expect(findActiveSuggestion().text()).toBe('Two');
222
+ });
162
223
 
163
- it('highlights suggestion if initial-value is provided, regardless of case sensitivity', async () => {
164
- await wrapper.setProps({ initialValue: 'two' });
165
- expect(wrapper.find('.gl-filtered-search-suggestion-active').text()).toBe('Two');
166
- });
224
+ it('highlights suggestion if initial-value is provided, regardless of case sensitivity', async () => {
225
+ await wrapper.setProps({ initialValue: 'two' });
226
+ expect(findActiveSuggestion().text()).toBe('Two');
227
+ });
167
228
 
168
- it('highlights suggestion if initial-value is provided, regardless of falsiness', async () => {
169
- await wrapper.setProps({ initialValue: false });
170
- expect(wrapper.find('.gl-filtered-search-suggestion-active').text()).toBe('Three');
171
- });
229
+ it('highlights suggestion if initial-value is provided, regardless of falsiness', async () => {
230
+ await wrapper.setProps({ initialValue: false });
231
+ expect(findActiveSuggestion().text()).toBe('Three');
232
+ });
172
233
 
173
- it('highlights first suggestion if initial-value is provided, deselected then selected', async () => {
174
- await wrapper.setProps({ initialValue: 'One' });
175
- wrapper.vm.prevItem();
176
- wrapper.vm.nextItem();
177
- await nextTick();
178
- expect(wrapper.find('.gl-filtered-search-suggestion-active').text()).toBe('One');
179
- });
234
+ it('highlights first suggestion if initial-value is provided, deselected then selected', async () => {
235
+ await wrapper.setProps({ initialValue: 'One' });
236
+ wrapper.vm.prevItem();
237
+ wrapper.vm.nextItem();
238
+ await nextTick();
239
+ expect(findActiveSuggestion().text()).toBe('One');
240
+ });
241
+
242
+ it('does not highlight anything if initial-value matches nothing', async () => {
243
+ await wrapper.setProps({ initialValue: 'missing' });
244
+ expect(findActiveSuggestion().exists()).toBe(false);
245
+ });
180
246
 
181
- it('does not highlight anything if initial-value matches nothing', async () => {
182
- await wrapper.setProps({ initialValue: 'missing' });
183
- expect(wrapper.find('.gl-filtered-search-suggestion-active').exists()).toBe(false);
247
+ it('applies the injected suggestion-list-class to the dropdown', () => {
248
+ expect(wrapper.classes()).toContain('custom-class');
249
+ });
184
250
  });
185
251
 
186
- it('applies the injected suggestion-list-class to the dropdown', () => {
187
- expect(wrapper.classes()).toContain('custom-class');
252
+ describe('with termsAsTokens = true', () => {
253
+ beforeEach(() => {
254
+ createComponent({ termsAsTokens: true });
255
+ });
256
+
257
+ it('selects first suggestion', async () => {
258
+ wrapper.vm.nextItem();
259
+ await nextTick();
260
+ expect(wrapper.vm.getValue()).toBe('One');
261
+ });
262
+
263
+ it('selects second suggestion', async () => {
264
+ wrapper.vm.nextItem();
265
+ wrapper.vm.nextItem();
266
+ await nextTick();
267
+ expect(wrapper.vm.getValue()).toBe('Two');
268
+ });
269
+
270
+ it('wraps to first suggestion after list end', async () => {
271
+ wrapper.vm.nextItem();
272
+ wrapper.vm.nextItem();
273
+ wrapper.vm.nextItem();
274
+ wrapper.vm.nextItem();
275
+ await nextTick();
276
+ expect(wrapper.vm.getValue()).toBe('One');
277
+ });
278
+
279
+ it('wraps to last suggestion before list start', async () => {
280
+ wrapper.vm.nextItem();
281
+ wrapper.vm.prevItem();
282
+ await nextTick();
283
+ expect(wrapper.vm.getValue()).toBe(false);
284
+ });
285
+
286
+ it('selects second-to-last suggestion in circle when selecting previous item', async () => {
287
+ wrapper.vm.nextItem();
288
+ wrapper.vm.prevItem();
289
+ wrapper.vm.prevItem();
290
+ await nextTick();
291
+ expect(wrapper.vm.getValue()).toBe('Two');
292
+ });
293
+
294
+ it('selects second suggestion in circle when selecting next item', async () => {
295
+ wrapper.vm.nextItem();
296
+ wrapper.vm.nextItem();
297
+ wrapper.vm.nextItem();
298
+ wrapper.vm.nextItem();
299
+ wrapper.vm.nextItem();
300
+ await nextTick();
301
+ expect(wrapper.vm.getValue()).toBe('Two');
302
+ });
303
+
304
+ it('highlights suggestion if initial-value is provided', async () => {
305
+ await wrapper.setProps({ initialValue: 'Two' });
306
+ expect(findActiveSuggestion().text()).toBe('Two');
307
+ });
308
+
309
+ it('highlights suggestion if initial-value is provided, regardless of case sensitivity', async () => {
310
+ await wrapper.setProps({ initialValue: 'two' });
311
+ expect(findActiveSuggestion().text()).toBe('Two');
312
+ });
313
+
314
+ it('highlights suggestion if initial-value is provided, regardless of falsiness', async () => {
315
+ await wrapper.setProps({ initialValue: false });
316
+ expect(findActiveSuggestion().text()).toBe('Three');
317
+ });
318
+
319
+ it('highlights first suggestion if initial-value is provided, deselected then selected', async () => {
320
+ await wrapper.setProps({ initialValue: 'One' });
321
+ wrapper.vm.prevItem();
322
+ wrapper.vm.nextItem();
323
+ await nextTick();
324
+ expect(findActiveSuggestion().text()).toBe('One');
325
+ });
326
+
327
+ it('does not highlight anything if initial-value matches nothing', async () => {
328
+ await wrapper.setProps({ initialValue: 'missing' });
329
+ expect(findActiveSuggestion().exists()).toBe(false);
330
+ });
331
+
332
+ it('applies the injected suggestion-list-class to the dropdown', () => {
333
+ expect(wrapper.classes()).toContain('custom-class');
334
+ });
188
335
  });
189
336
  });
190
337
  });
@@ -6,7 +6,7 @@ const NO_ACTIVE_ITEM = -2;
6
6
 
7
7
  export default {
8
8
  name: 'GlFilteredSearchSuggestionList',
9
- inject: ['suggestionsListClass'],
9
+ inject: ['suggestionsListClass', 'termsAsTokens'],
10
10
  provide() {
11
11
  return {
12
12
  filteredSearchSuggestionListInstance: this,
@@ -40,7 +40,7 @@ export default {
40
40
  return this.registeredItems[this.initialActiveIdx];
41
41
  },
42
42
  activeItem() {
43
- if (this.activeIdx === NO_ACTIVE_ITEM) return null;
43
+ if (!this.termsAsTokens() && this.activeIdx === NO_ACTIVE_ITEM) return null;
44
44
  if (this.activeIdx === DEFER_TO_INITIAL_VALUE) return this.initialActiveItem;
45
45
  return this.registeredItems[this.activeIdx];
46
46
  },
@@ -76,15 +76,24 @@ export default {
76
76
  }
77
77
  },
78
78
  nextItem() {
79
- this.stepItem(1, this.registeredItems.length - 1);
79
+ if (this.termsAsTokens()) {
80
+ this.stepItem(1);
81
+ } else {
82
+ this.stepItem(1, this.registeredItems.length - 1);
83
+ }
80
84
  },
81
85
  prevItem() {
82
- this.stepItem(-1, 0);
86
+ if (this.termsAsTokens()) {
87
+ this.stepItem(-1);
88
+ } else {
89
+ this.stepItem(-1, 0);
90
+ }
83
91
  },
84
92
  stepItem(direction, endIdx) {
85
93
  if (
86
- this.activeIdx === endIdx ||
87
- (this.activeIdx === DEFER_TO_INITIAL_VALUE && this.initialActiveIdx === endIdx)
94
+ !this.termsAsTokens() &&
95
+ (this.activeIdx === endIdx ||
96
+ (this.activeIdx === DEFER_TO_INITIAL_VALUE && this.initialActiveIdx === endIdx))
88
97
  ) {
89
98
  // The user wants to move past the end of the list, so ensure nothing is selected.
90
99
  this.activeIdx = NO_ACTIVE_ITEM;
@@ -1,7 +1,8 @@
1
1
  import { nextTick } from 'vue';
2
2
  import { shallowMount } from '@vue/test-utils';
3
+ import GlToken from '../token/token.vue';
3
4
  import FilteredSearchTerm from './filtered_search_term.vue';
4
- import { INTENT_ACTIVATE_PREVIOUS } from './filtered_search_utils';
5
+ import { INTENT_ACTIVATE_PREVIOUS, TERM_TOKEN_TYPE } from './filtered_search_utils';
5
6
 
6
7
  const availableTokens = [
7
8
  { type: 'foo', title: 'test1-foo', token: 'stub', icon: 'eye' },
@@ -9,6 +10,8 @@ const availableTokens = [
9
10
  { type: 'baz', title: 'test1-baz', token: 'stub', icon: 'eye' },
10
11
  ];
11
12
 
13
+ const pointerClass = 'gl-cursor-pointer';
14
+
12
15
  describe('Filtered search term', () => {
13
16
  let wrapper;
14
17
 
@@ -21,13 +24,23 @@ describe('Filtered search term', () => {
21
24
 
22
25
  const segmentStub = {
23
26
  name: 'gl-filtered-search-token-segment-stub',
24
- template: '<div><slot name="view"></slot><slot name="suggestions"></slot></div>',
25
- props: ['searchInputAttributes', 'isLastToken', 'currentValue', 'viewOnly', 'options'],
27
+ template: '<div><slot v-if="!active" name="view"></slot><slot name="suggestions"></slot></div>',
28
+ props: [
29
+ 'searchInputAttributes',
30
+ 'isLastToken',
31
+ 'currentValue',
32
+ 'viewOnly',
33
+ 'options',
34
+ 'active',
35
+ ],
26
36
  };
27
37
 
28
- const createComponent = (props) => {
38
+ const createComponent = ({ termsAsTokens = false, ...props } = {}) => {
29
39
  wrapper = shallowMount(FilteredSearchTerm, {
30
40
  propsData: { ...defaultProps, ...props },
41
+ provide: {
42
+ termsAsTokens: () => termsAsTokens,
43
+ },
31
44
  stubs: {
32
45
  'gl-filtered-search-token-segment': segmentStub,
33
46
  },
@@ -36,14 +49,24 @@ describe('Filtered search term', () => {
36
49
 
37
50
  const findSearchInput = () => wrapper.find('input');
38
51
  const findTokenSegmentComponent = () => wrapper.findComponent(segmentStub);
52
+ const findToken = () => wrapper.findComponent(GlToken);
39
53
 
40
54
  it('renders value in inactive mode', () => {
41
55
  createComponent({ value: { data: 'test-value' } });
42
56
  expect(wrapper.html()).toMatchSnapshot();
43
57
  });
44
58
 
45
- it('renders input with value in active mode', () => {
59
+ it('renders token in inactive mode with termsAsTokens', () => {
60
+ createComponent({ value: { data: 'test value' }, termsAsTokens: true });
61
+ expect(findToken().exists()).toBe(true);
62
+ expect(findToken().props().viewOnly).toBe(false);
63
+ expect(findToken().classes()).toContain(pointerClass);
64
+ expect(findToken().text()).toBe('test value');
65
+ });
66
+
67
+ it('renders nothing with value in active mode (delegates to segment)', () => {
46
68
  createComponent({ value: { data: 'test-value' }, active: true });
69
+ expect(wrapper.text()).not.toContain('test-value');
47
70
  expect(wrapper.html()).toMatchSnapshot();
48
71
  });
49
72
 
@@ -60,20 +83,45 @@ describe('Filtered search term', () => {
60
83
  expect(findTokenSegmentComponent().props('options')).toHaveLength(2);
61
84
  });
62
85
 
86
+ it('suggests text search when termsAsTokens=true', async () => {
87
+ createComponent({
88
+ availableTokens,
89
+ active: true,
90
+ value: { data: 'foo' },
91
+ termsAsTokens: true,
92
+ });
93
+
94
+ await nextTick();
95
+
96
+ expect(findTokenSegmentComponent().props('options')).toEqual([
97
+ expect.objectContaining({
98
+ value: 'foo',
99
+ title: 'test1-foo',
100
+ }),
101
+ expect.objectContaining({
102
+ value: TERM_TOKEN_TYPE,
103
+ title: 'Search for this text',
104
+ }),
105
+ ]);
106
+ });
107
+
63
108
  it.each`
64
- originalEvent | emittedEvent | payload
65
- ${'activate'} | ${'activate'} | ${undefined}
66
- ${'deactivate'} | ${'deactivate'} | ${undefined}
67
- ${'split'} | ${'split'} | ${undefined}
68
- ${'submit'} | ${'submit'} | ${undefined}
69
- ${'complete'} | ${'replace'} | ${{ type: undefined }}
70
- ${'backspace'} | ${'destroy'} | ${{ intent: INTENT_ACTIVATE_PREVIOUS }}
109
+ originalEvent | originalPayload | emittedEvent | payload
110
+ ${'activate'} | ${undefined} | ${'activate'} | ${undefined}
111
+ ${'deactivate'} | ${undefined} | ${'deactivate'} | ${undefined}
112
+ ${'split'} | ${'foo'} | ${'split'} | ${'foo'}
113
+ ${'submit'} | ${undefined} | ${'submit'} | ${undefined}
114
+ ${'previous'} | ${undefined} | ${'previous'} | ${undefined}
115
+ ${'next'} | ${undefined} | ${'next'} | ${undefined}
116
+ ${'complete'} | ${'foo'} | ${'replace'} | ${{ type: 'foo' }}
117
+ ${'complete'} | ${TERM_TOKEN_TYPE} | ${'complete'} | ${undefined}
118
+ ${'backspace'} | ${undefined} | ${'destroy'} | ${{ intent: INTENT_ACTIVATE_PREVIOUS }}
71
119
  `(
72
120
  'emits $emittedEvent when token segment emits $originalEvent',
73
- async ({ originalEvent, emittedEvent, payload }) => {
121
+ async ({ originalEvent, originalPayload, emittedEvent, payload }) => {
74
122
  createComponent({ active: true, value: { data: 'something' } });
75
123
 
76
- findTokenSegmentComponent().vm.$emit(originalEvent);
124
+ findTokenSegmentComponent().vm.$emit(originalEvent, originalPayload);
77
125
 
78
126
  await nextTick();
79
127
 
@@ -117,6 +165,17 @@ describe('Filtered search term', () => {
117
165
  expect(findTokenSegmentComponent().props('viewOnly')).toBe(false);
118
166
  });
119
167
 
168
+ it('sets viewOnly prop and removes pointer class on token when termsAsTokens=true', () => {
169
+ createComponent({
170
+ value: { data: 'foo' },
171
+ viewOnly: true,
172
+ termsAsTokens: true,
173
+ });
174
+
175
+ expect(findToken().props().viewOnly).toBe(true);
176
+ expect(findToken().classes()).not.toContain(pointerClass);
177
+ });
178
+
120
179
  it('adds `searchInputAttributes` prop to search term input', () => {
121
180
  createComponent({
122
181
  placeholder: 'placeholder-stub',
@@ -1,12 +1,23 @@
1
1
  <script>
2
+ import GlToken from '../token/token.vue';
3
+ import { stopEvent } from '../../../utils/utils';
2
4
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment.vue';
3
- import { INTENT_ACTIVATE_PREVIOUS, match, tokenToOption } from './filtered_search_utils';
5
+ import {
6
+ INTENT_ACTIVATE_PREVIOUS,
7
+ TERM_TOKEN_TYPE,
8
+ TOKEN_CLOSE_SELECTOR,
9
+ match,
10
+ tokenToOption,
11
+ termTokenDefinition,
12
+ } from './filtered_search_utils';
4
13
 
5
14
  export default {
6
15
  name: 'GlFilteredSearchTerm',
7
16
  components: {
8
17
  GlFilteredSearchTokenSegment,
18
+ GlToken,
9
19
  },
20
+ inject: ['termsAsTokens'],
10
21
  inheritAttrs: false,
11
22
  props: {
12
23
  /**
@@ -67,6 +78,14 @@ export default {
67
78
  default: 'end',
68
79
  validator: (value) => ['start', 'end'].includes(value),
69
80
  },
81
+ /**
82
+ * The title of the text search option. Ignored unless termsAsTokens is enabled.
83
+ */
84
+ searchTextOptionLabel: {
85
+ type: String,
86
+ required: false,
87
+ default: termTokenDefinition.title,
88
+ },
70
89
  viewOnly: {
71
90
  type: Boolean,
72
91
  required: false,
@@ -74,10 +93,22 @@ export default {
74
93
  },
75
94
  },
76
95
  computed: {
96
+ showInput() {
97
+ return this.termsAsTokens() || Boolean(this.placeholder);
98
+ },
99
+ showToken() {
100
+ return this.termsAsTokens() && Boolean(this.value.data);
101
+ },
77
102
  suggestedTokens() {
78
- return this.availableTokens
79
- .filter((token) => match(token.title, this.value.data))
80
- .map(tokenToOption);
103
+ const tokens = this.availableTokens.filter((token) => match(token.title, this.value.data));
104
+ if (this.termsAsTokens() && this.value.data) {
105
+ tokens.push({
106
+ ...termTokenDefinition,
107
+ title: this.searchTextOptionLabel,
108
+ });
109
+ }
110
+
111
+ return tokens.map(tokenToOption);
81
112
  },
82
113
  internalValue: {
83
114
  get() {
@@ -93,6 +124,9 @@ export default {
93
124
  this.$emit('input', { data });
94
125
  },
95
126
  },
127
+ eventListeners() {
128
+ return this.viewOnly ? {} : { mousedown: this.destroyByClose };
129
+ },
96
130
  },
97
131
  methods: {
98
132
  onBackspace() {
@@ -105,6 +139,21 @@ export default {
105
139
  */
106
140
  this.$emit('destroy', { intent: INTENT_ACTIVATE_PREVIOUS });
107
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', { type });
155
+ }
156
+ },
108
157
  },
109
158
  };
110
159
  </script>
@@ -134,6 +183,7 @@ export default {
134
183
 
135
184
  <!--
136
185
  Emitted when Space is pressed in-between term text.
186
+ Not emitted when termsAsTokens is true.
137
187
  @event split
138
188
  @property {array} newTokens Token configurations
139
189
  -->
@@ -152,7 +202,7 @@ export default {
152
202
  :options="suggestedTokens"
153
203
  @activate="$emit('activate')"
154
204
  @deactivate="$emit('deactivate')"
155
- @complete="$emit('replace', { type: $event })"
205
+ @complete="onComplete"
156
206
  @backspace="onBackspace"
157
207
  @submit="$emit('submit')"
158
208
  @split="$emit('split', $event)"
@@ -160,8 +210,16 @@ export default {
160
210
  @next="$emit('next')"
161
211
  >
162
212
  <template #view>
213
+ <gl-token
214
+ v-if="showToken"
215
+ :class="{ 'gl-cursor-pointer': !viewOnly }"
216
+ :view-only="viewOnly"
217
+ v-on="eventListeners"
218
+ >{{ value.data }}</gl-token
219
+ >
220
+
163
221
  <input
164
- v-if="placeholder"
222
+ v-else-if="showInput"
165
223
  v-bind="searchInputAttributes"
166
224
  class="gl-filtered-search-term-input"
167
225
  :class="{ 'gl-bg-gray-10': viewOnly }"
@@ -44,6 +44,7 @@ describe('Filtered search token', () => {
44
44
  provide: {
45
45
  portalName: 'fake target',
46
46
  alignSuggestions: function fakeAlignSuggestions() {},
47
+ termsAsTokens: () => false,
47
48
  },
48
49
  stubs: {
49
50
  Portal: {