@gitlab/ui 60.2.0 → 61.0.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 (29) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/components/base/filtered_search/filtered_search.js +1 -4
  3. package/dist/components/base/filtered_search/filtered_search_suggestion.js +1 -1
  4. package/dist/components/base/filtered_search/filtered_search_suggestion_list.js +30 -16
  5. package/dist/components/base/filtered_search/filtered_search_term.js +4 -6
  6. package/dist/components/base/filtered_search/filtered_search_token.js +16 -17
  7. package/dist/components/base/filtered_search/filtered_search_token_segment.js +23 -14
  8. package/dist/components/base/filtered_search/filtered_search_utils.js +78 -1
  9. package/dist/utils/number_utils.js +22 -1
  10. package/package.json +1 -1
  11. package/src/components/base/filtered_search/__snapshots__/filtered_search_term.spec.js.snap +2 -0
  12. package/src/components/base/filtered_search/filtered_search.md +5 -3
  13. package/src/components/base/filtered_search/filtered_search.stories.js +8 -5
  14. package/src/components/base/filtered_search/filtered_search.vue +1 -11
  15. package/src/components/base/filtered_search/filtered_search_suggestion.md +8 -2
  16. package/src/components/base/filtered_search/filtered_search_suggestion.vue +1 -0
  17. package/src/components/base/filtered_search/filtered_search_suggestion_list.spec.js +61 -64
  18. package/src/components/base/filtered_search/filtered_search_suggestion_list.vue +39 -20
  19. package/src/components/base/filtered_search/filtered_search_term.spec.js +11 -28
  20. package/src/components/base/filtered_search/filtered_search_term.vue +6 -17
  21. package/src/components/base/filtered_search/filtered_search_token.spec.js +3 -22
  22. package/src/components/base/filtered_search/filtered_search_token.vue +8 -16
  23. package/src/components/base/filtered_search/filtered_search_token_segment.spec.js +18 -1
  24. package/src/components/base/filtered_search/filtered_search_token_segment.stories.js +9 -0
  25. package/src/components/base/filtered_search/filtered_search_token_segment.vue +35 -12
  26. package/src/components/base/filtered_search/filtered_search_utils.js +69 -0
  27. package/src/components/base/filtered_search/filtered_search_utils.spec.js +32 -1
  28. package/src/utils/number_utils.js +21 -0
  29. package/src/utils/number_utils.spec.js +42 -0
@@ -1,7 +1,10 @@
1
1
  import { shallowMount } from '@vue/test-utils';
2
2
  import GlFilteredSearchTokenSegment from './filtered_search_token_segment.vue';
3
3
 
4
- const OPTIONS = [{ value: '=' }, { value: '!=' }];
4
+ const OPTIONS = [
5
+ { value: '=', title: 'is' },
6
+ { value: '!=', title: 'is not' },
7
+ ];
5
8
 
6
9
  describe('Filtered search token segment', () => {
7
10
  let wrapper;
@@ -330,5 +333,19 @@ describe('Filtered search token segment', () => {
330
333
  createWrappedComponent({ value: 'test', active: true, viewOnly: false });
331
334
  expect(wrapper.find('input').attributes('readonly')).toBeUndefined();
332
335
  });
336
+
337
+ it.each`
338
+ context | isTerm | eventPayloads
339
+ ${'does'} | ${false} | ${[['!=']]}
340
+ ${'does not'} | ${true} | ${undefined}
341
+ `(
342
+ '$context revert to fallback value on deactivation when no-fallback is $isTerm',
343
+ async ({ isTerm, eventPayloads }) => {
344
+ createComponent({ value: '!=', active: true, options: OPTIONS, isTerm });
345
+
346
+ await wrapper.setProps({ value: 'foo', active: false });
347
+ expect(wrapper.emitted('input')).toEqual(eventPayloads);
348
+ }
349
+ );
333
350
  });
334
351
  });
@@ -10,6 +10,15 @@ Vue.use(PortalVue);
10
10
  const staticOptions = [
11
11
  { icon: 'eye-slash', value: true, title: 'Yes' },
12
12
  { icon: 'eye', value: false, title: 'No' },
13
+ {
14
+ icon: 'pencil',
15
+ value: 'custom',
16
+ title: 'Custom',
17
+ component: {
18
+ props: ['option'],
19
+ template: '<span>{{ option.title }} <b class="gl-text-red-500">component</b></span>',
20
+ },
21
+ },
13
22
  ];
14
23
 
15
24
  const generateProps = ({ active = true } = {}) => ({
@@ -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 } from './filtered_search_utils';
7
+ import { splitOnQuotes, wrapTokenInQuotes, match } 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
@@ -62,6 +62,11 @@ export default {
62
62
  required: false,
63
63
  default: false,
64
64
  },
65
+ isTerm: {
66
+ type: Boolean,
67
+ required: false,
68
+ default: false,
69
+ },
65
70
  label: {
66
71
  type: String,
67
72
  required: false,
@@ -80,7 +85,7 @@ export default {
80
85
  optionTextField: {
81
86
  type: String,
82
87
  required: false,
83
- default: 'value',
88
+ default: 'title',
84
89
  },
85
90
  customInputKeydownHandler: {
86
91
  type: Function,
@@ -145,18 +150,22 @@ export default {
145
150
 
146
151
  inputValue: {
147
152
  get() {
153
+ if (this.isTerm) {
154
+ return this.nonMultipleValue;
155
+ }
156
+
148
157
  return this.matchingOption
149
158
  ? this.matchingOption[this.optionTextField]
150
159
  : this.nonMultipleValue;
151
160
  },
152
161
 
153
- set(v) {
162
+ set(inputValue) {
154
163
  /**
155
164
  * Emitted when this token segment's value changes.
156
165
  *
157
166
  * @type {object} option The current option.
158
167
  */
159
- this.$emit('input', this.getMatchingOptionForInputValue(v)?.value ?? v);
168
+ this.$emit('input', inputValue);
160
169
  },
161
170
  },
162
171
 
@@ -176,8 +185,13 @@ export default {
176
185
  return option?.value;
177
186
  }
178
187
 
179
- const defaultSuggestion = this.options.find((op) => op.default);
180
- return (defaultSuggestion ?? this.options[0])?.value;
188
+ const defaultOption = this.options.find((op) => op.default);
189
+
190
+ if (defaultOption) {
191
+ return defaultOption.value;
192
+ }
193
+
194
+ return this.isTerm ? undefined : this.options[0]?.value;
181
195
  },
182
196
  containerAttributes() {
183
197
  return (
@@ -210,7 +224,7 @@ export default {
210
224
  const [firstWord, ...otherWords] = splitOnQuotes(newValue).filter(
211
225
  (w, idx, arr) => Boolean(w) || idx === arr.length - 1
212
226
  );
213
- this.$emit('input', this.getMatchingOptionForInputValue(firstWord)?.value ?? firstWord);
227
+ this.$emit('input', firstWord);
214
228
 
215
229
  if (otherWords.length) {
216
230
  /**
@@ -233,9 +247,11 @@ export default {
233
247
  }
234
248
  },
235
249
 
236
- getMatchingOptionForInputValue(v, { loose } = { loose: false }) {
237
- return this.options?.find((o) =>
238
- loose ? o[this.optionTextField].startsWith(v) : [this.optionTextField] === v
250
+ getMatchingOptionForInputValue(inputValue, { loose } = { loose: false }) {
251
+ return this.options?.find((option) =>
252
+ loose
253
+ ? match(option[this.optionTextField], inputValue)
254
+ : option[this.optionTextField] === inputValue
239
255
  );
240
256
  },
241
257
 
@@ -256,7 +272,7 @@ export default {
256
272
  },
257
273
 
258
274
  deactivate() {
259
- if (!this.options) {
275
+ if (!this.options || this.isTerm) {
260
276
  return;
261
277
  }
262
278
 
@@ -410,7 +426,14 @@ export default {
410
426
  :value="option.value"
411
427
  :icon-name="option.icon"
412
428
  >
413
- <slot name="option" v-bind="{ option }"> {{ option[optionTextField] }} </slot>
429
+ <slot name="option" v-bind="{ option }">
430
+ <template v-if="option.component">
431
+ <component :is="option.component" :option="option" />
432
+ </template>
433
+ <template v-else>
434
+ {{ option[optionTextField] }}
435
+ </template>
436
+ </slot>
414
437
  </gl-filtered-search-suggestion>
415
438
  </template>
416
439
 
@@ -1,6 +1,7 @@
1
1
  import first from 'lodash/first';
2
2
  import last from 'lodash/last';
3
3
  import isString from 'lodash/isString';
4
+ import { modulo } from '../../../utils/number_utils';
4
5
 
5
6
  export const TERM_TOKEN_TYPE = 'filtered-search-term';
6
7
 
@@ -44,6 +45,61 @@ export function needDenormalization(tokens) {
44
45
  return tokens.some((t) => typeof t === 'string' || !t.id);
45
46
  }
46
47
 
48
+ /**
49
+ * Given an initial index, step size and array length, returns an index that is
50
+ * within the array bounds (unless step is 0; see † below).
51
+ *
52
+ * The step can be any positive or negative integer, including zero.
53
+ *
54
+ * An out-of-bounds index is considered 'uninitialised', and is handled
55
+ * specially. For instance, the 'next' index of 'uninitialised' is the first
56
+ * index:
57
+ *
58
+ * stepIndexAndWrap(-1, 1, 5) === 0
59
+ *
60
+ * The 'previous' index of 'uninitialised' is the last index:
61
+ *
62
+ * stepIndexAndWrap(-1, -1, 5) === 4
63
+ *
64
+ * †: If step is 0, the index is returned as-is, which may be out-of-bounds.
65
+ *
66
+ * @param {number} index The initial index.
67
+ * @param {number} step The amount to step by (positive or negative).
68
+ * @param {number} length The length of the array.
69
+ * @returns {number}
70
+ */
71
+ export function stepIndexAndWrap(index, step, length) {
72
+ if (step === 0) return index;
73
+
74
+ let start;
75
+ const indexInRange = index >= 0 && index < length;
76
+
77
+ if (indexInRange) {
78
+ // Step from the valid index.
79
+ start = index;
80
+ } else if (step > 0) {
81
+ // Step forwards from the beginning of the array.
82
+ start = -1;
83
+ } else {
84
+ // Step backwards from the end of the array.
85
+ start = length;
86
+ }
87
+
88
+ return modulo(start + step, length);
89
+ }
90
+
91
+ /**
92
+ * Transforms a given token definition to an option definition.
93
+ *
94
+ * @param {Object} token A token definition (see GlFilteredSearch's
95
+ * availableTokens prop).
96
+ * @returns {Object} A option definition (see GlFilteredSearchTokenSegment's
97
+ * options prop).
98
+ */
99
+ export function tokenToOption({ icon, title, type, optionComponent }) {
100
+ return { icon, title, value: type, component: optionComponent };
101
+ }
102
+
47
103
  let tokenIdCounter = 0;
48
104
  const getTokenId = () => {
49
105
  const tokenId = `token-${tokenIdCounter}`;
@@ -96,6 +152,19 @@ export function denormalizeTokens(inputTokens) {
96
152
  return result;
97
153
  }
98
154
 
155
+ /**
156
+ * Returns `true` if `text` contains `query` (case insensitive).
157
+ *
158
+ * This is used in `filter` and `find` array methods for token segment options.
159
+ *
160
+ * @param {string} text The string to look within.
161
+ * @param {string} query The string to find inside the text.
162
+ * @returns {boolean}
163
+ */
164
+ export function match(text, query) {
165
+ return text.toLowerCase().includes(query.toLowerCase());
166
+ }
167
+
99
168
  export function splitOnQuotes(str) {
100
169
  if (first(str) === "'" && last(str) === "'") {
101
170
  return [str];
@@ -1,4 +1,4 @@
1
- import { splitOnQuotes, wrapTokenInQuotes } from './filtered_search_utils';
1
+ import { splitOnQuotes, wrapTokenInQuotes, stepIndexAndWrap } from './filtered_search_utils';
2
2
 
3
3
  describe('FilteredSearchUtils', () => {
4
4
  describe('splitOnQuotes', () => {
@@ -48,4 +48,35 @@ describe('FilteredSearchUtils', () => {
48
48
  expect(wrapTokenInQuotes(token)).toEqual(`"${token}"`);
49
49
  });
50
50
  });
51
+
52
+ describe('stepIndexAndWrap', () => {
53
+ it.each`
54
+ index | step | length | result
55
+ ${0} | ${0} | ${5} | ${0}
56
+ ${0} | ${1} | ${5} | ${1}
57
+ ${0} | ${-1} | ${5} | ${4}
58
+ ${0} | ${6} | ${5} | ${1}
59
+ ${0} | ${-6} | ${5} | ${4}
60
+ ${1} | ${0} | ${5} | ${1}
61
+ ${1} | ${1} | ${5} | ${2}
62
+ ${1} | ${-2} | ${5} | ${4}
63
+ ${1} | ${-5} | ${5} | ${1}
64
+ ${-1} | ${0} | ${5} | ${-1}
65
+ ${6} | ${0} | ${5} | ${6}
66
+ ${6} | ${1} | ${5} | ${0}
67
+ ${6} | ${-1} | ${5} | ${4}
68
+ ${-1} | ${1} | ${5} | ${0}
69
+ ${-1} | ${-1} | ${5} | ${4}
70
+ ${-1} | ${-5} | ${5} | ${0}
71
+ ${-1} | ${5} | ${5} | ${4}
72
+ ${4} | ${1} | ${5} | ${0}
73
+ ${4} | ${2} | ${5} | ${1}
74
+ ${NaN} | ${1} | ${1} | ${0}
75
+ ${0} | ${NaN} | ${1} | ${NaN}
76
+ ${0} | ${1} | ${NaN} | ${NaN}
77
+ ${0} | ${1} | ${0} | ${NaN}
78
+ `('stepIndex($index, $step, $length) === $result', ({ index, step, length, result }) => {
79
+ expect(stepIndexAndWrap(index, step, length, result)).toBe(result);
80
+ });
81
+ });
51
82
  });
@@ -17,6 +17,27 @@ export const sum = (...numbers) => numbers.reduce(addition);
17
17
  */
18
18
  export const average = (...numbers) => sum(...numbers) / numbers.length;
19
19
 
20
+ /**
21
+ * Returns the modulo of n for a divisor.
22
+ *
23
+ * Maps the integer n into the range [0, divisor) when the divisor is positive,
24
+ * and (divisor, 0] when the divisor is negative.
25
+ *
26
+ * This is useful when indexing into an array, to ensure you always stay within
27
+ * the array bounds.
28
+ *
29
+ * See https://2ality.com/2019/08/remainder-vs-modulo.html.
30
+ *
31
+ * @param {number} n The number to mod.
32
+ * @param {number} divisor The divisor (e.g., the length of an array).
33
+ * @returns {number}
34
+ */
35
+ export function modulo(n, divisor) {
36
+ const result = ((n % divisor) + divisor) % divisor;
37
+ // Never return -0.
38
+ return result === 0 ? 0 : result;
39
+ }
40
+
20
41
  /**
21
42
  * Convert number to engineering format, using SI suffix
22
43
  * @param {Number|String} value - Number or Number-convertible String
@@ -44,4 +44,46 @@ describe('number utils', () => {
44
44
  expect(numberUtils.engineeringNotation(...input)).toBe(output);
45
45
  });
46
46
  });
47
+
48
+ describe('modulo', () => {
49
+ it.each`
50
+ n | divisor | result
51
+ ${-7} | ${3} | ${2}
52
+ ${-6} | ${3} | ${0}
53
+ ${-5} | ${3} | ${1}
54
+ ${-4} | ${3} | ${2}
55
+ ${-3} | ${3} | ${0}
56
+ ${-2} | ${3} | ${1}
57
+ ${-1} | ${3} | ${2}
58
+ ${0} | ${3} | ${0}
59
+ ${1} | ${3} | ${1}
60
+ ${2} | ${3} | ${2}
61
+ ${3} | ${3} | ${0}
62
+ ${4} | ${3} | ${1}
63
+ ${5} | ${3} | ${2}
64
+ ${6} | ${3} | ${0}
65
+ ${7} | ${3} | ${1}
66
+ ${-7} | ${-3} | ${-1}
67
+ ${-6} | ${-3} | ${0}
68
+ ${-5} | ${-3} | ${-2}
69
+ ${-4} | ${-3} | ${-1}
70
+ ${-3} | ${-3} | ${0}
71
+ ${-2} | ${-3} | ${-2}
72
+ ${-1} | ${-3} | ${-1}
73
+ ${0} | ${-3} | ${0}
74
+ ${1} | ${-3} | ${-2}
75
+ ${2} | ${-3} | ${-1}
76
+ ${3} | ${-3} | ${0}
77
+ ${4} | ${-3} | ${-2}
78
+ ${5} | ${-3} | ${-1}
79
+ ${6} | ${-3} | ${0}
80
+ ${7} | ${-3} | ${-2}
81
+ ${NaN} | ${1} | ${NaN}
82
+ ${1} | ${NaN} | ${NaN}
83
+ ${1} | ${0} | ${NaN}
84
+ ${Infinity} | ${1} | ${NaN}
85
+ `('modulo($n, $divisor) === $result', ({ n, divisor, result }) => {
86
+ expect(numberUtils.modulo(n, divisor)).toBe(result);
87
+ });
88
+ });
47
89
  });