@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
@@ -3,12 +3,11 @@ import cloneDeep from 'lodash/cloneDeep';
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 { createTerm, tokenToOption } from './filtered_search_utils';
6
+ import { createTerm, tokenToOption, TOKEN_CLOSE_SELECTOR } from './filtered_search_utils';
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
 
13
12
  const DEFAULT_OPERATORS = [
14
13
  { value: '=', description: 'is', default: true },
@@ -392,6 +391,7 @@ export default {
392
391
 
393
392
  <!--
394
393
  Emitted when Space is pressed in-between term text.
394
+ Not emitted when termsAsTokens is true.
395
395
  @event split
396
396
  @property {array} newTokens Token configurations
397
397
  -->
@@ -1,5 +1,6 @@
1
1
  import { shallowMount } from '@vue/test-utils';
2
2
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment.vue';
3
+ import { TERM_TOKEN_TYPE } from './filtered_search_utils';
3
4
 
4
5
  const OPTIONS = [
5
6
  { value: '=', title: 'is' },
@@ -26,7 +27,7 @@ describe('Filtered search token segment', () => {
26
27
  let alignSuggestionsMock;
27
28
  let suggestionsMock;
28
29
 
29
- const createComponent = (props) => {
30
+ const createComponent = ({ termsAsTokens = false, ...props } = {}) => {
30
31
  alignSuggestionsMock = jest.fn();
31
32
  suggestionsMock = {
32
33
  methods: {
@@ -42,6 +43,7 @@ describe('Filtered search token segment', () => {
42
43
  provide: {
43
44
  portalName: 'fakePortal',
44
45
  alignSuggestions: alignSuggestionsMock,
46
+ termsAsTokens: () => termsAsTokens,
45
47
  },
46
48
  stubs: {
47
49
  Portal: { template: '<div><slot></slot></div>' },
@@ -50,7 +52,7 @@ describe('Filtered search token segment', () => {
50
52
  });
51
53
  };
52
54
 
53
- const createWrappedComponent = ({ value, ...otherProps } = {}) => {
55
+ const createWrappedComponent = ({ value, termsAsTokens = false, ...otherProps } = {}) => {
54
56
  // We need to create fake parent due to https://github.com/vuejs/vue-test-utils/issues/1140
55
57
  const fakeParent = {
56
58
  inheritAttrs: false,
@@ -68,6 +70,9 @@ describe('Filtered search token segment', () => {
68
70
 
69
71
  wrapper = shallowMount(fakeParent, {
70
72
  propsData: { ...otherProps, cursorPosition: 'end' },
73
+ provide: {
74
+ termsAsTokens: () => termsAsTokens,
75
+ },
71
76
  stubs: { GlFilteredSearchTokenSegment },
72
77
  });
73
78
  };
@@ -184,6 +189,22 @@ describe('Filtered search token segment', () => {
184
189
  );
185
190
  });
186
191
 
192
+ it('leaves value as-is if options are provided and isTerm=true', async () => {
193
+ const originalValue = '!=';
194
+ createWrappedComponent({
195
+ value: originalValue,
196
+ title: 'Test',
197
+ options: OPTIONS,
198
+ isTerm: true,
199
+ active: true,
200
+ });
201
+
202
+ await wrapper.setData({ value: 'invalid' });
203
+ await wrapper.setProps({ active: false });
204
+
205
+ expect(wrapper.findComponent(GlFilteredSearchTokenSegment).emitted().input).toBe(undefined);
206
+ });
207
+
187
208
  describe('applySuggestion', () => {
188
209
  it('emits original token when no spaces are present', () => {
189
210
  createComponent({ value: '' });
@@ -209,6 +230,29 @@ describe('Filtered search token segment', () => {
209
230
  expect(wrapper.emitted('complete')[0][0]).toBe(formattedToken);
210
231
  });
211
232
 
233
+ it('emits token as-is when spaces are present and termsAsTokens=true', () => {
234
+ createComponent({ value: '', termsAsTokens: true });
235
+
236
+ const token = 'token with spaces';
237
+
238
+ wrapper.vm.applySuggestion(token);
239
+
240
+ expect(wrapper.emitted('input')[0][0]).toBe(token);
241
+ expect(wrapper.emitted('select')[0][0]).toBe(token);
242
+ expect(wrapper.emitted('complete')[0][0]).toBe(token);
243
+ });
244
+
245
+ it('emits input value when term token suggestion is chosen and termsAsTokens=true', () => {
246
+ const value = `some text with 'spaces and' "quotes"`;
247
+ createComponent({ value, termsAsTokens: true });
248
+
249
+ wrapper.vm.applySuggestion(TERM_TOKEN_TYPE);
250
+
251
+ expect(wrapper.emitted('input')[0][0]).toBe(value);
252
+ expect(wrapper.emitted('select')[0][0]).toBe(TERM_TOKEN_TYPE);
253
+ expect(wrapper.emitted('complete')[0][0]).toBe(TERM_TOKEN_TYPE);
254
+ });
255
+
212
256
  it('selects suggestion on press Enter', () => {
213
257
  createComponent({ active: true, options: OPTIONS, value: false });
214
258
  wrapper.find('input').trigger('keydown', { key: 'ArrowDown' });
@@ -4,7 +4,7 @@ import { Portal } from 'portal-vue';
4
4
  import { COMMA, LEFT_MOUSE_BUTTON } from '../../../utils/constants';
5
5
  import GlFilteredSearchSuggestion from './filtered_search_suggestion.vue';
6
6
  import GlFilteredSearchSuggestionList from './filtered_search_suggestion_list.vue';
7
- import { splitOnQuotes, wrapTokenInQuotes, match } from './filtered_search_utils';
7
+ import { splitOnQuotes, wrapTokenInQuotes, match, TERM_TOKEN_TYPE } from './filtered_search_utils';
8
8
 
9
9
  // We need some helpers to ensure @vue/compat compatibility
10
10
  // @vue/compat will render comment nodes for v-if and comments in HTML
@@ -51,7 +51,7 @@ export default {
51
51
  GlFilteredSearchSuggestionList,
52
52
  GlFilteredSearchSuggestion,
53
53
  },
54
- inject: ['portalName', 'alignSuggestions'],
54
+ inject: ['portalName', 'alignSuggestions', 'termsAsTokens'],
55
55
  inheritAttrs: false,
56
56
  props: {
57
57
  /**
@@ -140,6 +140,13 @@ export default {
140
140
  },
141
141
 
142
142
  computed: {
143
+ hasTermSuggestion() {
144
+ if (!this.termsAsTokens()) return false;
145
+ if (!this.options) return false;
146
+
147
+ return this.options.some(({ value }) => value === TERM_TOKEN_TYPE);
148
+ },
149
+
143
150
  matchingOption() {
144
151
  return this.options?.find((o) => o.value === this.value);
145
152
  },
@@ -182,7 +189,10 @@ export default {
182
189
  const option =
183
190
  this.getMatchingOptionForInputValue(this.inputValue) ||
184
191
  this.getMatchingOptionForInputValue(this.inputValue, { loose: true });
185
- return option?.value;
192
+
193
+ if (option) return option.value;
194
+ if (this.hasTermSuggestion) return TERM_TOKEN_TYPE;
195
+ return null;
186
196
  }
187
197
 
188
198
  const defaultOption = this.options.find((op) => op.default);
@@ -216,6 +226,8 @@ export default {
216
226
  },
217
227
 
218
228
  inputValue(newValue) {
229
+ if (this.termsAsTokens()) return;
230
+
219
231
  const hasUnclosedQuote = newValue.split('"').length % 2 === 0;
220
232
  if (newValue.indexOf(' ') === -1 || hasUnclosedQuote) {
221
233
  return;
@@ -282,7 +294,9 @@ export default {
282
294
  },
283
295
 
284
296
  applySuggestion(suggestedValue) {
285
- const formattedSuggestedValue = wrapTokenInQuotes(suggestedValue);
297
+ const formattedSuggestedValue = this.termsAsTokens()
298
+ ? suggestedValue
299
+ : wrapTokenInQuotes(suggestedValue);
286
300
 
287
301
  /**
288
302
  * Emitted when autocomplete entry is selected.
@@ -292,7 +306,10 @@ export default {
292
306
  this.$emit('select', formattedSuggestedValue);
293
307
 
294
308
  if (!this.multiSelect) {
295
- this.$emit('input', formattedSuggestedValue);
309
+ this.$emit(
310
+ 'input',
311
+ formattedSuggestedValue === TERM_TOKEN_TYPE ? this.inputValue : formattedSuggestedValue
312
+ );
296
313
  this.$emit('complete', formattedSuggestedValue);
297
314
  }
298
315
  },
@@ -7,6 +7,8 @@ export const TERM_TOKEN_TYPE = 'filtered-search-term';
7
7
 
8
8
  export const INTENT_ACTIVATE_PREVIOUS = 'intent-activate-previous';
9
9
 
10
+ export const TOKEN_CLOSE_SELECTOR = '.gl-token-close';
11
+
10
12
  export function isEmptyTerm(token) {
11
13
  return token.type === TERM_TOKEN_TYPE && token.value.data.trim() === '';
12
14
  }
@@ -135,21 +137,26 @@ export function createTerm(data = '') {
135
137
  };
136
138
  }
137
139
 
138
- export function denormalizeTokens(inputTokens) {
140
+ export function denormalizeTokens(inputTokens, termsAsTokens = false) {
139
141
  assertValidTokens(inputTokens);
140
142
 
141
143
  const tokens = Array.isArray(inputTokens) ? inputTokens : [inputTokens];
142
144
 
143
- const result = [];
144
- tokens.forEach((t) => {
145
+ return tokens.reduce((result, t) => {
145
146
  if (typeof t === 'string') {
146
- const stringTokens = t.split(' ').filter(Boolean);
147
- stringTokens.forEach((strToken) => result.push(createTerm(strToken)));
147
+ if (termsAsTokens) {
148
+ const trimmedText = t.trim();
149
+ if (trimmedText) result.push(createTerm(trimmedText));
150
+ } else {
151
+ const stringTokens = t.split(' ').filter(Boolean);
152
+ stringTokens.forEach((strToken) => result.push(createTerm(strToken)));
153
+ }
148
154
  } else {
149
155
  result.push(ensureTokenId(t));
150
156
  }
151
- });
152
- return result;
157
+
158
+ return result;
159
+ }, []);
153
160
  }
154
161
 
155
162
  /**
@@ -165,6 +172,12 @@ export function match(text, query) {
165
172
  return text.toLowerCase().includes(query.toLowerCase());
166
173
  }
167
174
 
175
+ export const termTokenDefinition = {
176
+ type: TERM_TOKEN_TYPE,
177
+ icon: 'title',
178
+ title: 'Search for this text',
179
+ };
180
+
168
181
  export function splitOnQuotes(str) {
169
182
  if (first(str) === "'" && last(str) === "'") {
170
183
  return [str];