@gitlab/ui 38.2.0 → 38.2.1

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,3 +1,10 @@
1
+ ## [38.2.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v38.2.0...v38.2.1) (2022-04-01)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **GlFilteredSearch:** Generate stable token keys ([88dccde](https://gitlab.com/gitlab-org/gitlab-ui/commit/88dccde9f4df1352a7e06e831de8baced4a8d18f))
7
+
1
8
  # [38.2.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v38.1.0...v38.2.0) (2022-03-31)
2
9
 
3
10
 
@@ -5,22 +5,12 @@ import { GlTooltipDirective } from '../../../directives/tooltip';
5
5
  import GlIcon from '../icon/icon';
6
6
  import GlSearchBoxByClick from '../search_box_by_click/search_box_by_click';
7
7
  import GlFilteredSearchTerm from './filtered_search_term';
8
- import { TERM_TOKEN_TYPE, needDenormalization, denormalizeTokens, isEmptyTerm, INTENT_ACTIVATE_PREVIOUS, normalizeTokens } from './filtered_search_utils';
8
+ import { createTerm, needDenormalization, denormalizeTokens, isEmptyTerm, INTENT_ACTIVATE_PREVIOUS, ensureTokenId, normalizeTokens } from './filtered_search_utils';
9
9
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
10
10
 
11
11
  Vue.use(PortalVue);
12
12
  let portalUuid = 0;
13
13
 
14
- function createTerm() {
15
- let data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
16
- return {
17
- type: TERM_TOKEN_TYPE,
18
- value: {
19
- data
20
- }
21
- };
22
- }
23
-
24
14
  function initialState() {
25
15
  return [createTerm()];
26
16
  }
@@ -172,6 +162,14 @@ var script = {
172
162
  watch: {
173
163
  tokens: {
174
164
  handler() {
165
+ if (process.env.NODE_ENV !== 'production') {
166
+ const invalidToken = this.tokens.find(token => !token.id);
167
+
168
+ if (invalidToken) {
169
+ throw new Error(`Token does not have an id:\n${JSON.stringify(invalidToken)}`);
170
+ }
171
+ }
172
+
175
173
  if (this.tokens.length === 0 || !this.isLastTokenEmpty()) {
176
174
  this.tokens.push(createTerm());
177
175
  }
@@ -278,12 +276,12 @@ var script = {
278
276
  },
279
277
 
280
278
  replaceToken(idx, token) {
281
- this.$set(this.tokens, idx, { ...token,
279
+ this.$set(this.tokens, idx, ensureTokenId({ ...token,
282
280
  value: {
283
281
  data: '',
284
282
  ...token.value
285
283
  }
286
- });
284
+ }));
287
285
  this.activeTokenIdx = idx;
288
286
  },
289
287
 
@@ -295,12 +293,7 @@ var script = {
295
293
  return;
296
294
  }
297
295
 
298
- const newTokens = newStrings.map(data => ({
299
- type: TERM_TOKEN_TYPE,
300
- value: {
301
- data
302
- }
303
- }));
296
+ const newTokens = newStrings.map(data => createTerm(data));
304
297
  this.tokens.splice(idx + 1, 0, ...newTokens);
305
298
  this.activeTokenIdx = idx + newStrings.length;
306
299
  },
@@ -333,7 +326,7 @@ var script = {
333
326
  const __vue_script__ = script;
334
327
 
335
328
  /* template */
336
- 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,"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"},[_vm._l((_vm.tokens),function(token,idx){return [_c(_vm.getTokenComponent(token.type),{key:((token.type) + "-" + idx),ref:"tokens",refInFor:true,tag:"component",staticClass:"gl-filtered-search-item",class:{
329
+ 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,"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"},[_vm._l((_vm.tokens),function(token,idx){return [_c(_vm.getTokenComponent(token.type),{key:token.id,ref:"tokens",refInFor:true,tag:"component",staticClass:"gl-filtered-search-item",class:{
337
330
  'gl-filtered-search-last-item': _vm.isLastToken(idx),
338
331
  },attrs:{"config":_vm.getTokenEntry(token.type),"active":_vm.activeTokenIdx === idx,"available-tokens":_vm.currentAvailableTokens,"current-value":_vm.tokens,"index":idx,"placeholder":_vm.termPlaceholder,"show-friendly-text":_vm.showFriendlyText,"search-input-attributes":_vm.searchInputAttributes,"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)}},model:{value:(token.value),callback:function ($$v) {_vm.$set(token, "value", $$v);},expression:"token.value"}})]})],2),_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))};
339
332
  var __vue_staticRenderFns__ = [];
@@ -59,7 +59,7 @@ var script = {
59
59
  const __vue_script__ = script;
60
60
 
61
61
  /* template */
62
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-dropdown-item',_vm._b({ref:"item",staticClass:"gl-filtered-search-suggestion",class:{ 'gl-filtered-search-suggestion-active': _vm.isActive },attrs:{"href":"#"},nativeOn:{"mousedown":function($event){$event.preventDefault();return _vm.emitValue($event)}}},'gl-dropdown-item',_vm.$attrs,false),[_vm._t("default")],2)};
62
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-dropdown-item',_vm._b({ref:"item",staticClass:"gl-filtered-search-suggestion",class:{ 'gl-filtered-search-suggestion-active': _vm.isActive },attrs:{"data-testid":"filtered-search-suggestion","href":"#"},nativeOn:{"mousedown":function($event){$event.preventDefault();return _vm.emitValue($event)}}},'gl-dropdown-item',_vm.$attrs,false),[_vm._t("default")],2)};
63
63
  var __vue_staticRenderFns__ = [];
64
64
 
65
65
  /* style */
@@ -2,7 +2,7 @@ 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 { TERM_TOKEN_TYPE } from './filtered_search_utils';
5
+ import { createTerm } 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';
@@ -203,12 +203,7 @@ var script = {
203
203
  * Emitted when this token is converted to another type
204
204
  * @property {object} token Replacement token configuration
205
205
  */
206
- this.$emit('replace', {
207
- type: TERM_TOKEN_TYPE,
208
- value: {
209
- data: this.config.title
210
- }
211
- });
206
+ this.$emit('replace', createTerm(this.config.title));
212
207
  }
213
208
  },
214
209
 
@@ -298,6 +293,7 @@ var script = {
298
293
  destroyByClose(event) {
299
294
  if (event.target.closest(TOKEN_CLOSE_SELECTOR)) {
300
295
  event.preventDefault();
296
+ event.stopPropagation();
301
297
  this.$emit('destroy');
302
298
  }
303
299
  }
@@ -38,7 +38,45 @@ function needDenormalization(tokens) {
38
38
  }
39
39
 
40
40
  assertValidTokens(tokens);
41
- return tokens.some(t => typeof t === 'string');
41
+ return tokens.some(t => typeof t === 'string' || !t.id);
42
+ }
43
+ let tokenIdCounter = 0;
44
+
45
+ const getTokenId = () => {
46
+ const tokenId = `token-${tokenIdCounter}`;
47
+ tokenIdCounter += 1;
48
+ return tokenId;
49
+ };
50
+ /**
51
+ * Ensure the given token has an `id` property, which `GlFilteredSearch` relies
52
+ * on as a unique key for the token.
53
+ *
54
+ * If the given token does not have an `id`, it returns a shallow copy of the
55
+ * token with an `id`. Otherwise, it returns the given token.
56
+ *
57
+ * @param {object} token The token to check.
58
+ * @returns {object} A token with an `id`.
59
+ */
60
+
61
+
62
+ function ensureTokenId(token) {
63
+ if (!token.id) {
64
+ return { ...token,
65
+ id: getTokenId()
66
+ };
67
+ }
68
+
69
+ return token;
70
+ }
71
+ function createTerm() {
72
+ let data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
73
+ return {
74
+ id: getTokenId(),
75
+ type: TERM_TOKEN_TYPE,
76
+ value: {
77
+ data
78
+ }
79
+ };
42
80
  }
43
81
  function denormalizeTokens(inputTokens) {
44
82
  assertValidTokens(inputTokens);
@@ -47,14 +85,9 @@ function denormalizeTokens(inputTokens) {
47
85
  tokens.forEach(t => {
48
86
  if (typeof t === 'string') {
49
87
  const stringTokens = t.split(' ').filter(Boolean);
50
- stringTokens.forEach(strToken => result.push({
51
- type: TERM_TOKEN_TYPE,
52
- value: {
53
- data: strToken
54
- }
55
- }));
88
+ stringTokens.forEach(strToken => result.push(createTerm(strToken)));
56
89
  } else {
57
- result.push(t);
90
+ result.push(ensureTokenId(t));
58
91
  }
59
92
  });
60
93
  return result;
@@ -135,4 +168,4 @@ function wrapTokenInQuotes(token) {
135
168
  return `"${token}"`;
136
169
  }
137
170
 
138
- export { INTENT_ACTIVATE_PREVIOUS, TERM_TOKEN_TYPE, denormalizeTokens, isEmptyTerm, needDenormalization, normalizeTokens, splitOnQuotes, wrapTokenInQuotes };
171
+ export { INTENT_ACTIVATE_PREVIOUS, TERM_TOKEN_TYPE, createTerm, denormalizeTokens, ensureTokenId, isEmptyTerm, needDenormalization, normalizeTokens, splitOnQuotes, wrapTokenInQuotes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "38.2.0",
3
+ "version": "38.2.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -1,4 +1,5 @@
1
1
  import Vue, { nextTick } from 'vue';
2
+ import { omit } from 'lodash';
2
3
  import { shallowMount, mount } from '@vue/test-utils';
3
4
  import GlFilteredSearch from './filtered_search.vue';
4
5
  import GlFilteredSearchSuggestion from './filtered_search_suggestion.vue';
@@ -17,6 +18,8 @@ const FakeToken = {
17
18
 
18
19
  Vue.directive('GlTooltip', () => {});
19
20
 
21
+ const stripId = (token) => (typeof token === 'object' ? omit(token, 'id') : token);
22
+
20
23
  let wrapper;
21
24
  describe('Filtered search', () => {
22
25
  const defaultProps = {
@@ -41,7 +44,7 @@ describe('Filtered search', () => {
41
44
  describe('value manipulation', () => {
42
45
  it('creates term when empty', () => {
43
46
  createComponent();
44
- expect(wrapper.emitted().input[0][0]).toStrictEqual([
47
+ expect(wrapper.emitted().input[0][0].map(stripId)).toStrictEqual([
45
48
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
46
49
  ]);
47
50
  });
@@ -56,7 +59,7 @@ describe('Filtered search', () => {
56
59
  value: [{ type: 'faketoken', value: { data: '' } }],
57
60
  });
58
61
 
59
- expect(wrapper.emitted().input[0][0].pop()).toStrictEqual({
62
+ expect(stripId(wrapper.emitted().input[0][0].pop())).toStrictEqual({
60
63
  type: TERM_TOKEN_TYPE,
61
64
  value: { data: '' },
62
65
  });
@@ -172,7 +175,7 @@ describe('Filtered search', () => {
172
175
 
173
176
  await nextTick();
174
177
 
175
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
178
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
176
179
  { type: 'faketoken', value: { data: '' } },
177
180
  { type: TERM_TOKEN_TYPE, value: { data: 'one' } },
178
181
  { type: TERM_TOKEN_TYPE, value: { data: 'three' } },
@@ -190,7 +193,7 @@ describe('Filtered search', () => {
190
193
 
191
194
  await nextTick();
192
195
 
193
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
196
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
194
197
  { type: TERM_TOKEN_TYPE, value: { data: 'one' } },
195
198
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
196
199
  ]);
@@ -305,7 +308,7 @@ describe('Filtered search', () => {
305
308
 
306
309
  await nextTick();
307
310
 
308
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
311
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
309
312
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
310
313
  ]);
311
314
  });
@@ -318,7 +321,7 @@ describe('Filtered search', () => {
318
321
 
319
322
  await nextTick();
320
323
 
321
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
324
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
322
325
  { type: 'faketoken', value: { data: 'test' } },
323
326
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
324
327
  ]);
@@ -339,7 +342,7 @@ describe('Filtered search', () => {
339
342
 
340
343
  await nextTick();
341
344
 
342
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
345
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
343
346
  { type: 'faketoken', value: { data: 'test' } },
344
347
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
345
348
  ]);
@@ -352,7 +355,7 @@ describe('Filtered search', () => {
352
355
 
353
356
  await nextTick();
354
357
 
355
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
358
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
356
359
  { type: TERM_TOKEN_TYPE, value: { data: 'one' } },
357
360
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
358
361
  ]);
@@ -368,7 +371,7 @@ describe('Filtered search', () => {
368
371
  await nextTick();
369
372
 
370
373
  expect(wrapper.findAllComponents(GlFilteredSearchTerm).at(2).props('active')).toBe(true);
371
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
374
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
372
375
  { type: TERM_TOKEN_TYPE, value: { data: 'one' } },
373
376
  { type: TERM_TOKEN_TYPE, value: { data: 'two' } },
374
377
  { type: TERM_TOKEN_TYPE, value: { data: '' } },
@@ -385,7 +388,7 @@ describe('Filtered search', () => {
385
388
 
386
389
  await nextTick();
387
390
 
388
- expect(wrapper.emitted().input.pop()[0]).toStrictEqual([
391
+ expect(wrapper.emitted().input.pop()[0].map(stripId)).toStrictEqual([
389
392
  { type: TERM_TOKEN_TYPE, value: { data: 'one' } },
390
393
  { type: TERM_TOKEN_TYPE, value: { data: 'foo' } },
391
394
  { type: TERM_TOKEN_TYPE, value: { data: 'bar' } },
@@ -415,7 +418,7 @@ describe('Filtered search', () => {
415
418
  });
416
419
  wrapper.findComponent(GlFilteredSearchTerm).vm.$emit('submit');
417
420
  expect(wrapper.emitted().submit).toBeDefined();
418
- expect(wrapper.emitted().submit[0][0]).toStrictEqual([
421
+ expect(wrapper.emitted().submit[0][0].map(stripId)).toStrictEqual([
419
422
  'one two',
420
423
  { type: 'faketoken', value: { data: 'smth' } },
421
424
  'four five',
@@ -475,7 +478,7 @@ describe('Filtered search', () => {
475
478
  });
476
479
  await nextTick();
477
480
 
478
- expect(wrapper.findComponent(GlFilteredSearchTerm).props('currentValue')).toEqual([
481
+ expect(wrapper.findComponent(GlFilteredSearchTerm).props('currentValue').map(stripId)).toEqual([
479
482
  { type: 'filtered-search-term', value: { data: 'one' } },
480
483
  { type: 'filtered-search-term', value: { data: '' } },
481
484
  ]);
@@ -705,4 +708,26 @@ describe('Filtered search integration tests', () => {
705
708
 
706
709
  expect(wrapper.findAllComponents(GlFilteredSearchTerm)).toHaveLength(1);
707
710
  });
711
+
712
+ // Regression test for https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1761
713
+ it('does not incorrectly activate next token of the same type after token destruction', async () => {
714
+ mountComponent({
715
+ value: [
716
+ { type: 'static', value: { data: 'first', operator: '=' } },
717
+ { type: 'static', value: { data: 'second', operator: '=' } },
718
+ { type: 'unique', value: { data: 'something' } },
719
+ ],
720
+ });
721
+ await nextTick();
722
+
723
+ expect(
724
+ wrapper.findAllComponents(GlFilteredSearchToken).wrappers.map((cmp) => cmp.props('active'))
725
+ ).toEqual([false, false, false]);
726
+
727
+ await wrapper.find('.gl-token-close').trigger('mousedown');
728
+
729
+ expect(
730
+ wrapper.findAllComponents(GlFilteredSearchToken).wrappers.map((cmp) => cmp.props('active'))
731
+ ).toEqual([false, false]);
732
+ });
708
733
  });
@@ -6,6 +6,7 @@ import {
6
6
  GlFilteredSearchTerm,
7
7
  GlFilteredSearchTokenSegment,
8
8
  GlLoadingIcon,
9
+ GlIcon,
9
10
  GlToken,
10
11
  GlAvatar,
11
12
  } from '../../../index';
@@ -56,14 +57,14 @@ const UserToken = {
56
57
  setStoryTimeout(() => {
57
58
  this.loadingView = false;
58
59
  this.activeUser = fakeUsers.find((u) => u.username === this.value.data);
59
- }, 1000);
60
+ }, 500);
60
61
  },
61
62
  loadSuggestions() {
62
63
  this.loadingSuggestions = true;
63
64
  setStoryTimeout(() => {
64
65
  this.loadingSuggestions = false;
65
66
  this.users = fakeUsers;
66
- }, 2000);
67
+ }, 500);
67
68
  },
68
69
  },
69
70
  watch: {
@@ -132,7 +133,7 @@ const MilestoneToken = {
132
133
  setStoryTimeout(() => {
133
134
  this.loadingSuggestions = false;
134
135
  this.milestones = fakeMilestones;
135
- }, 2000);
136
+ }, 500);
136
137
  },
137
138
  },
138
139
  watch: {
@@ -214,7 +215,7 @@ const LabelToken = {
214
215
  setStoryTimeout(() => {
215
216
  this.loadingSuggestions = false;
216
217
  this.labels = fakeLabels;
217
- }, 2000);
218
+ }, 500);
218
219
  },
219
220
  },
220
221
  watch: {
@@ -322,7 +323,7 @@ export const WithHistoryItems = () => ({
322
323
  type: 'demotoken',
323
324
  title: 'Unique',
324
325
  icon: 'document',
325
- token: 'gl-filtered-search-token',
326
+ token: GlFilteredSearchToken,
326
327
  operators: [{ value: '=', description: 'is', default: 'true' }],
327
328
  options: [
328
329
  { icon: 'heart', title: 'heart', value: 1 },
@@ -406,7 +407,13 @@ export const WithFriendlyText = () => ({
406
407
  export const WithMultiSelect = () => {
407
408
  const MultiUserToken = {
408
409
  props: ['value', 'active', 'config'],
409
- components: { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon, GlAvatar },
410
+ components: {
411
+ GlFilteredSearchToken,
412
+ GlFilteredSearchSuggestion,
413
+ GlLoadingIcon,
414
+ GlIcon,
415
+ GlAvatar,
416
+ },
410
417
  inheritAttrs: false,
411
418
  data() {
412
419
  return {
@@ -8,8 +8,9 @@ import GlSearchBoxByClick from '../search_box_by_click/search_box_by_click.vue';
8
8
  import GlFilteredSearchTerm from './filtered_search_term.vue';
9
9
  import {
10
10
  isEmptyTerm,
11
- TERM_TOKEN_TYPE,
12
11
  INTENT_ACTIVATE_PREVIOUS,
12
+ createTerm,
13
+ ensureTokenId,
13
14
  normalizeTokens,
14
15
  denormalizeTokens,
15
16
  needDenormalization,
@@ -19,13 +20,6 @@ Vue.use(PortalVue);
19
20
 
20
21
  let portalUuid = 0;
21
22
 
22
- function createTerm(data = '') {
23
- return {
24
- type: TERM_TOKEN_TYPE,
25
- value: { data },
26
- };
27
- }
28
-
29
23
  function initialState() {
30
24
  return [createTerm()];
31
25
  }
@@ -160,6 +154,13 @@ export default {
160
154
  watch: {
161
155
  tokens: {
162
156
  handler() {
157
+ if (process.env.NODE_ENV !== 'production') {
158
+ const invalidToken = this.tokens.find((token) => !token.id);
159
+ if (invalidToken) {
160
+ throw new Error(`Token does not have an id:\n${JSON.stringify(invalidToken)}`);
161
+ }
162
+ }
163
+
163
164
  if (this.tokens.length === 0 || !this.isLastTokenEmpty()) {
164
165
  this.tokens.push(createTerm());
165
166
  }
@@ -255,7 +256,7 @@ export default {
255
256
  },
256
257
 
257
258
  replaceToken(idx, token) {
258
- this.$set(this.tokens, idx, { ...token, value: { data: '', ...token.value } });
259
+ this.$set(this.tokens, idx, ensureTokenId({ ...token, value: { data: '', ...token.value } }));
259
260
  this.activeTokenIdx = idx;
260
261
  },
261
262
 
@@ -269,10 +270,7 @@ export default {
269
270
  return;
270
271
  }
271
272
 
272
- const newTokens = newStrings.map((data) => ({
273
- type: TERM_TOKEN_TYPE,
274
- value: { data },
275
- }));
273
+ const newTokens = newStrings.map((data) => createTerm(data));
276
274
 
277
275
  this.tokens.splice(idx + 1, 0, ...newTokens);
278
276
 
@@ -340,7 +338,7 @@ export default {
340
338
  <component
341
339
  :is="getTokenComponent(token.type)"
342
340
  ref="tokens"
343
- :key="`${token.type}-${idx}`"
341
+ :key="token.id"
344
342
  v-model="token.value"
345
343
  :config="getTokenEntry(token.type)"
346
344
  :active="activeTokenIdx === idx"
@@ -51,6 +51,7 @@ export default {
51
51
  <gl-dropdown-item
52
52
  ref="item"
53
53
  class="gl-filtered-search-suggestion"
54
+ data-testid="filtered-search-suggestion"
54
55
  :class="{ 'gl-filtered-search-suggestion-active': isActive }"
55
56
  v-bind="$attrs"
56
57
  href="#"
@@ -225,8 +225,16 @@ describe('Filtered search token', () => {
225
225
  mountComponent({ value: { operator: '=', data: 'something' } });
226
226
  const closeWrapper = wrapper.find('.gl-token-close');
227
227
  closeWrapper.element.closest = () => closeWrapper.element;
228
- closeWrapper.trigger('mousedown');
229
228
 
229
+ const preventDefaultSpy = jest.fn();
230
+ const stopPropagationSpy = jest.fn();
231
+ closeWrapper.trigger('mousedown', {
232
+ preventDefault: preventDefaultSpy,
233
+ stopPropagation: stopPropagationSpy,
234
+ });
235
+
236
+ expect(preventDefaultSpy).toHaveBeenCalled();
237
+ expect(stopPropagationSpy).toHaveBeenCalled();
230
238
  expect(wrapper.emitted().destroy).toHaveLength(1);
231
239
  });
232
240
 
@@ -3,7 +3,7 @@ import { cloneDeep } from 'lodash';
3
3
  import { COMMA } from '../../../utils/constants';
4
4
  import GlToken from '../token/token.vue';
5
5
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment.vue';
6
- import { TERM_TOKEN_TYPE } from './filtered_search_utils';
6
+ import { createTerm } from './filtered_search_utils';
7
7
 
8
8
  const SEGMENT_TITLE = 'TYPE';
9
9
  const SEGMENT_OPERATOR = 'OPERATOR';
@@ -183,7 +183,7 @@ export default {
183
183
  * Emitted when this token is converted to another type
184
184
  * @property {object} token Replacement token configuration
185
185
  */
186
- this.$emit('replace', { type: TERM_TOKEN_TYPE, value: { data: this.config.title } });
186
+ this.$emit('replace', createTerm(this.config.title));
187
187
  }
188
188
  },
189
189
 
@@ -254,6 +254,7 @@ export default {
254
254
  destroyByClose(event) {
255
255
  if (event.target.closest(TOKEN_CLOSE_SELECTOR)) {
256
256
  event.preventDefault();
257
+ event.stopPropagation();
257
258
  this.$emit('destroy');
258
259
  }
259
260
  },
@@ -39,7 +39,42 @@ export function needDenormalization(tokens) {
39
39
 
40
40
  assertValidTokens(tokens);
41
41
 
42
- return tokens.some((t) => typeof t === 'string');
42
+ return tokens.some((t) => typeof t === 'string' || !t.id);
43
+ }
44
+
45
+ let tokenIdCounter = 0;
46
+ const getTokenId = () => {
47
+ const tokenId = `token-${tokenIdCounter}`;
48
+ tokenIdCounter += 1;
49
+ return tokenId;
50
+ };
51
+ /**
52
+ * Ensure the given token has an `id` property, which `GlFilteredSearch` relies
53
+ * on as a unique key for the token.
54
+ *
55
+ * If the given token does not have an `id`, it returns a shallow copy of the
56
+ * token with an `id`. Otherwise, it returns the given token.
57
+ *
58
+ * @param {object} token The token to check.
59
+ * @returns {object} A token with an `id`.
60
+ */
61
+ export function ensureTokenId(token) {
62
+ if (!token.id) {
63
+ return {
64
+ ...token,
65
+ id: getTokenId(),
66
+ };
67
+ }
68
+
69
+ return token;
70
+ }
71
+
72
+ export function createTerm(data = '') {
73
+ return {
74
+ id: getTokenId(),
75
+ type: TERM_TOKEN_TYPE,
76
+ value: { data },
77
+ };
43
78
  }
44
79
 
45
80
  export function denormalizeTokens(inputTokens) {
@@ -51,11 +86,9 @@ export function denormalizeTokens(inputTokens) {
51
86
  tokens.forEach((t) => {
52
87
  if (typeof t === 'string') {
53
88
  const stringTokens = t.split(' ').filter(Boolean);
54
- stringTokens.forEach((strToken) =>
55
- result.push({ type: TERM_TOKEN_TYPE, value: { data: strToken } })
56
- );
89
+ stringTokens.forEach((strToken) => result.push(createTerm(strToken)));
57
90
  } else {
58
- result.push(t);
91
+ result.push(ensureTokenId(t));
59
92
  }
60
93
  });
61
94
  return result;
@@ -1,28 +1,77 @@
1
1
  import { GlSkeletonLoader } from '../../../index';
2
+ import { makeContainer } from '../../../utils/story_decorators/container';
2
3
  import readme from './skeleton_loader.md';
3
4
 
4
- const Template = (args) => ({
5
+ const defaultValue = (prop) => GlSkeletonLoader.props[prop].default;
6
+
7
+ const generateProps = ({
8
+ width = defaultValue('width'),
9
+ height = defaultValue('height'),
10
+ preserveAspectRatio = defaultValue('preserveAspectRatio'),
11
+ lines = defaultValue('lines'),
12
+ equalWidthLines = defaultValue('equalWidthLines'),
13
+ } = {}) => ({
14
+ width,
15
+ height,
16
+ preserveAspectRatio,
17
+ lines,
18
+ equalWidthLines,
19
+ });
20
+
21
+ const template = (slotContent = '') => `
22
+ <gl-skeleton-loader
23
+ :width="width"
24
+ :height="height"
25
+ :preserveAspectRatio="preserveAspectRatio"
26
+ :lines="lines"
27
+ :equalWidthLines="equalWidthLines"
28
+ >${slotContent}</gl-skeleton-loader>
29
+ `;
30
+
31
+ export const Default = (args) => ({
5
32
  components: { GlSkeletonLoader },
6
33
  props: Object.keys(args),
7
- template: `
8
- <div style="background: #fff; border: 1px solid #dfdfdf; box-shadow: 0 1px 2px rgba(0,0,0,0.1); width: 359px; height: 135px; padding: 1rem; border-radius: 0.25rem;">
9
- <div style="width: 327px; height: 102px;">
10
- <gl-skeleton-loader :width="327" :height="102">
11
- <rect width="276" height="16" rx="4" />
12
- <rect y="18" width="237" height="16" rx="4" />
13
- <rect y="42" width="118" height="16" rx="8" />
14
- <rect x="122" y="42" width="130" height="16" rx="8" />
15
- <rect y="62" width="106" height="16" rx="8" />
16
- <rect x="110" y="62" width="56" height="16" rx="8" />
17
- <rect x="256" y="42" width="71" height="16" rx="8" />
18
- <rect y="86" width="38" height="16" rx="4" />
19
- </gl-skeleton-loader>
20
- </div>
21
- </div>`,
34
+ template: template(),
35
+ });
36
+ Object.assign(Default, {
37
+ args: generateProps(),
38
+ parameters: {
39
+ controls: {
40
+ // When not providing custom shapes in the default slot, these are the
41
+ // only props you're likely to want to modify.
42
+ include: ['width', 'lines', 'equalWidthLines'],
43
+ },
44
+ },
22
45
  });
23
46
 
24
- export const Default = Template.bind({});
25
- Default.args = {};
47
+ export const WithCustomShapes = (args) => ({
48
+ components: { GlSkeletonLoader },
49
+ props: Object.keys(args),
50
+ template: template(`
51
+ <rect width="276" height="16" rx="4" />
52
+ <rect y="18" width="237" height="16" rx="4" />
53
+ <rect y="42" width="118" height="16" rx="8" />
54
+ <rect x="122" y="42" width="130" height="16" rx="8" />
55
+ <rect y="62" width="106" height="16" rx="8" />
56
+ <rect x="110" y="62" width="56" height="16" rx="8" />
57
+ <rect x="256" y="42" width="71" height="16" rx="8" />
58
+ <rect y="86" width="38" height="16" rx="4" />
59
+ `),
60
+ });
61
+ Object.assign(WithCustomShapes, {
62
+ args: generateProps({
63
+ width: 327,
64
+ height: 102,
65
+ }),
66
+ parameters: {
67
+ controls: {
68
+ // With custom shapes, other props become useful, although they're a bit
69
+ // counterintuitive.
70
+ include: ['width', 'height', 'preserveAspectRatio'],
71
+ },
72
+ },
73
+ decorators: [makeContainer({ width: '250px' })],
74
+ });
26
75
 
27
76
  export default {
28
77
  title: 'base/skeleton-loader',
@@ -34,8 +83,5 @@ export default {
34
83
  component: readme,
35
84
  },
36
85
  },
37
- controls: {
38
- disable: true,
39
- },
40
86
  },
41
87
  };