@gitlab/ui 43.17.0 → 43.18.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 (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/components/base/filtered_search/filtered_search.js +18 -5
  3. package/dist/components/base/filtered_search/filtered_search_term.js +6 -1
  4. package/dist/components/base/filtered_search/filtered_search_token.js +28 -8
  5. package/dist/components/base/filtered_search/filtered_search_token_segment.js +10 -4
  6. package/dist/index.css +1 -1
  7. package/dist/index.css.map +1 -1
  8. package/dist/utils/constants.js +2 -1
  9. package/package.json +12 -12
  10. package/src/components/base/button/button.spec.js +1 -1
  11. package/src/components/base/datepicker/datepicker.spec.js +1 -1
  12. package/src/components/base/dropdown/dropdown.spec.js +1 -1
  13. package/src/components/base/filtered_search/filtered_search.spec.js +53 -2
  14. package/src/components/base/filtered_search/filtered_search.stories.js +13 -2
  15. package/src/components/base/filtered_search/filtered_search.vue +24 -6
  16. package/src/components/base/filtered_search/filtered_search_term.spec.js +23 -3
  17. package/src/components/base/filtered_search/filtered_search_term.vue +8 -0
  18. package/src/components/base/filtered_search/filtered_search_token.scss +10 -8
  19. package/src/components/base/filtered_search/filtered_search_token.spec.js +74 -21
  20. package/src/components/base/filtered_search/filtered_search_token.vue +25 -4
  21. package/src/components/base/filtered_search/filtered_search_token_segment.spec.js +18 -0
  22. package/src/components/base/filtered_search/filtered_search_token_segment.vue +10 -3
  23. package/src/components/base/form/form_checkbox/form_checkbox.scss +1 -1
  24. package/src/components/base/form/form_checkbox/form_checkbox.stories.js +20 -8
  25. package/src/components/base/form/form_radio/form_radio.stories.js +30 -17
  26. package/src/components/base/link/link.spec.js +1 -1
  27. package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js +1 -1
  28. package/src/components/base/new_dropdowns/listbox/listbox.spec.js +4 -4
  29. package/src/components/base/new_dropdowns/listbox/listbox_item.spec.js +2 -2
  30. package/src/components/charts/area/area.spec.js +3 -3
  31. package/src/components/charts/line/line.spec.js +3 -3
  32. package/src/directives/outside/outside.spec.js +1 -1
  33. package/src/utils/constants.js +2 -0
@@ -9,6 +9,7 @@ function appendDefaultOption(options) {
9
9
  }
10
10
 
11
11
  const COMMA = ',';
12
+ const LEFT_MOUSE_BUTTON = 0;
12
13
  const glThemes = ['indigo', 'blue', 'light-blue', 'green', 'red', 'light-red'];
13
14
  const variantOptions = {
14
15
  primary: 'primary',
@@ -244,4 +245,4 @@ const loadingIconSizes = {
244
245
  'xl (64x64)': 'xl'
245
246
  };
246
247
 
247
- export { COMMA, alertVariantIconMap, alertVariantOptions, alignOptions, avatarShapeOptions, avatarSizeOptions, avatarsInlineSizeOptions, badgeForButtonOptions, badgeSizeOptions, badgeVariantOptions, bannerVariants, buttonCategoryOptions, buttonSizeOptions, buttonSizeOptionsMap, buttonVariantOptions, colorThemes, columnOptions, defaultDateFormat, drawerVariants, dropdownVariantOptions, focusableTags, formInputSizes, formStateOptions, glThemes, iconSizeOptions, keyboard, labelColorOptions, labelSizeOptions, loadingIconSizes, maxZIndex, modalButtonDefaults, modalSizeOptions, popoverPlacements, resizeDebounceTime, tabsButtonDefaults, targetOptions, toggleLabelPosition, tokenVariants, tooltipActionEvents, tooltipDelay, tooltipPlacements, triggerVariantOptions, truncateOptions, variantCssColorMap, variantOptions, variantOptionsWithNoDefault, viewModeOptions };
248
+ export { COMMA, LEFT_MOUSE_BUTTON, alertVariantIconMap, alertVariantOptions, alignOptions, avatarShapeOptions, avatarSizeOptions, avatarsInlineSizeOptions, badgeForButtonOptions, badgeSizeOptions, badgeVariantOptions, bannerVariants, buttonCategoryOptions, buttonSizeOptions, buttonSizeOptionsMap, buttonVariantOptions, colorThemes, columnOptions, defaultDateFormat, drawerVariants, dropdownVariantOptions, focusableTags, formInputSizes, formStateOptions, glThemes, iconSizeOptions, keyboard, labelColorOptions, labelSizeOptions, loadingIconSizes, maxZIndex, modalButtonDefaults, modalSizeOptions, popoverPlacements, resizeDebounceTime, tabsButtonDefaults, targetOptions, toggleLabelPosition, tokenVariants, tooltipActionEvents, tooltipDelay, tooltipPlacements, triggerVariantOptions, truncateOptions, variantCssColorMap, variantOptions, variantOptionsWithNoDefault, viewModeOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "43.17.0",
3
+ "version": "43.18.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -77,22 +77,22 @@
77
77
  },
78
78
  "devDependencies": {
79
79
  "@arkweid/lefthook": "0.7.7",
80
- "@babel/core": "^7.18.13",
81
- "@babel/preset-env": "^7.18.10",
80
+ "@babel/core": "^7.19.1",
81
+ "@babel/preset-env": "^7.19.1",
82
82
  "@gitlab/eslint-plugin": "17.0.0",
83
83
  "@gitlab/stylelint-config": "4.1.0",
84
84
  "@gitlab/svgs": "3.3.0",
85
85
  "@rollup/plugin-commonjs": "^11.1.0",
86
86
  "@rollup/plugin-node-resolve": "^7.1.3",
87
87
  "@rollup/plugin-replace": "^2.3.2",
88
- "@storybook/addon-a11y": "6.5.10",
89
- "@storybook/addon-docs": "6.5.10",
90
- "@storybook/addon-essentials": "6.5.10",
91
- "@storybook/addon-storyshots": "6.5.10",
92
- "@storybook/addon-storyshots-puppeteer": "6.5.10",
93
- "@storybook/addon-viewport": "6.5.10",
94
- "@storybook/theming": "6.5.10",
95
- "@storybook/vue": "6.5.10",
88
+ "@storybook/addon-a11y": "6.5.12",
89
+ "@storybook/addon-docs": "6.5.12",
90
+ "@storybook/addon-essentials": "6.5.12",
91
+ "@storybook/addon-storyshots": "6.5.12",
92
+ "@storybook/addon-storyshots-puppeteer": "6.5.12",
93
+ "@storybook/addon-viewport": "6.5.12",
94
+ "@storybook/theming": "6.5.12",
95
+ "@storybook/vue": "6.5.12",
96
96
  "@vue/test-utils": "1.3.0",
97
97
  "@vue/vue2-jest": "29.0.0",
98
98
  "autoprefixer": "^9.7.6",
@@ -102,7 +102,7 @@
102
102
  "babel-plugin-require-context-hook": "^1.0.0",
103
103
  "babel-preset-vue": "^2.0.2",
104
104
  "bootstrap": "4.5.3",
105
- "cypress": "^10.7.0",
105
+ "cypress": "^10.8.0",
106
106
  "emoji-regex": "^10.0.0",
107
107
  "eslint": "8.23.0",
108
108
  "eslint-import-resolver-jest": "3.0.2",
@@ -174,7 +174,7 @@ describe('button component', () => {
174
174
 
175
175
  // GlSafeLinkDirective is actually responsible to handle the unsafe URLs
176
176
  // and GlButton uses this directive to make all the links secure by default
177
- it('should set href to blank ', () => {
177
+ it('should set href to blank', () => {
178
178
  buildWrapper({
179
179
  propsData: {
180
180
  href: unsafeUrl,
@@ -35,7 +35,7 @@ describe('datepicker component', () => {
35
35
  jest.setSystemTime(currentDate.getTime());
36
36
  });
37
37
 
38
- it("does not set default date when 'value' and 'defaultDate' props aren't set ", () => {
38
+ it("does not set default date when 'value' and 'defaultDate' props aren't set", () => {
39
39
  mountWithOptions();
40
40
 
41
41
  expect(Pikaday).toHaveBeenCalled();
@@ -311,7 +311,7 @@ describe('new dropdown', () => {
311
311
  });
312
312
  });
313
313
 
314
- describe('with showClearAll=true ', () => {
314
+ describe('with showClearAll=true', () => {
315
315
  beforeEach(() => {
316
316
  buildWrapper({ showClearAll: true });
317
317
  });
@@ -18,9 +18,11 @@ const FakeToken = {
18
18
 
19
19
  Vue.directive('GlTooltip', () => {});
20
20
 
21
+ let wrapper;
22
+
23
+ const findFilteredSearchInput = () => wrapper.find('[data-testid="filtered-search-input"]');
21
24
  const stripId = (token) => (typeof token === 'object' ? omit(token, 'id') : token);
22
25
 
23
- let wrapper;
24
26
  describe('Filtered search', () => {
25
27
  const defaultProps = {
26
28
  availableTokens: [{ type: 'faketoken', token: FakeToken }],
@@ -41,6 +43,10 @@ describe('Filtered search', () => {
41
43
  });
42
44
  };
43
45
 
46
+ afterEach(() => {
47
+ wrapper = null;
48
+ });
49
+
44
50
  describe('value manipulation', () => {
45
51
  it('creates term when empty', () => {
46
52
  createComponent();
@@ -284,7 +290,7 @@ describe('Filtered search', () => {
284
290
 
285
291
  await nextTick();
286
292
 
287
- wrapper.findAllComponents(GlFilteredSearchTerm).wrappers.forEach((searchTermWrapper) => {
293
+ wrapper.findAll(`.gl-filtered-search-item`).wrappers.forEach((searchTermWrapper) => {
288
294
  expect(searchTermWrapper.props('active')).toBe(false);
289
295
  });
290
296
  });
@@ -483,6 +489,51 @@ describe('Filtered search', () => {
483
489
  { type: 'filtered-search-term', value: { data: '' } },
484
490
  ]);
485
491
  });
492
+
493
+ it('the search input is enabled by default', () => {
494
+ createComponent();
495
+
496
+ expect(findFilteredSearchInput().attributes('disabled')).toBe(undefined);
497
+ });
498
+
499
+ describe('view-only state', () => {
500
+ const createViewOnlyComponent = (viewOnly) =>
501
+ createComponent({
502
+ value: ['one', { type: 'faketoken', value: '' }],
503
+ viewOnly,
504
+ });
505
+
506
+ it.each([true, false])(
507
+ 'passes the value of viewOnly to the search term when view-only is %s',
508
+ (viewOnly) => {
509
+ createViewOnlyComponent(viewOnly);
510
+
511
+ expect(wrapper.findComponent(GlFilteredSearchTerm).props('viewOnly')).toBe(viewOnly);
512
+ }
513
+ );
514
+
515
+ describe('when view-only is true', () => {
516
+ beforeEach(() => {
517
+ createViewOnlyComponent(true);
518
+ });
519
+
520
+ it('disables the search input', () => {
521
+ expect(findFilteredSearchInput().attributes('disabled')).toBe('disabled');
522
+ });
523
+
524
+ it('prevents tokens from activating', async () => {
525
+ await wrapper.findComponent(FakeToken).vm.$emit('activate');
526
+
527
+ wrapper.findAll(`.gl-filtered-search-item`).wrappers.forEach((searchTermWrapper) => {
528
+ expect(searchTermWrapper.props('active')).toBe(false);
529
+ });
530
+ });
531
+
532
+ it('does not apply the last token class', async () => {
533
+ expect(wrapper.find('.gl-filtered-search-last-item').exists()).toBe(false);
534
+ });
535
+ });
536
+ });
486
537
  });
487
538
 
488
539
  describe('Filtered search integration tests', () => {
@@ -186,7 +186,7 @@ const LabelToken = {
186
186
  GlToken,
187
187
  GlDropdownDivider,
188
188
  },
189
- props: ['value', 'active'],
189
+ props: ['value', 'active', 'viewOnly'],
190
190
  inheritAttrs: false,
191
191
  data() {
192
192
  return {
@@ -251,7 +251,7 @@ const LabelToken = {
251
251
  v-on="$listeners"
252
252
  >
253
253
  <template #view-token="{ inputValue, cssClasses, listeners }">
254
- <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners">
254
+ <gl-token variant="search-value" :view-only="viewOnly" :class="cssClasses" :style="containerStyle" v-on="listeners">
255
255
  {{ activeLabel ? activeLabel.title : inputValue }}
256
256
  </gl-token>
257
257
  </template>
@@ -323,6 +323,17 @@ export const Default = () => ({
323
323
  template: `<gl-filtered-search :available-tokens="tokens" :value="value" />`,
324
324
  });
325
325
 
326
+ export const ViewOnly = () => ({
327
+ data() {
328
+ return {
329
+ tokens,
330
+ value: [{ type: 'author', value: { data: 'epsilon', operator: '=' } }, 'raw text'],
331
+ };
332
+ },
333
+ components,
334
+ template: `<gl-filtered-search view-only :available-tokens="tokens" :value="value" />`,
335
+ });
336
+
326
337
  export const WithHistoryItems = () => ({
327
338
  components,
328
339
  data() {
@@ -113,6 +113,11 @@ export default {
113
113
  required: false,
114
114
  default: () => ({}),
115
115
  },
116
+ viewOnly: {
117
+ type: Boolean,
118
+ required: false,
119
+ default: false,
120
+ },
116
121
  },
117
122
  data() {
118
123
  return {
@@ -162,7 +167,7 @@ export default {
162
167
  }
163
168
  }
164
169
 
165
- if (this.tokens.length === 0 || !this.isLastTokenEmpty()) {
170
+ if ((this.tokens.length === 0 || !this.isLastTokenEmpty()) && !this.viewOnly) {
166
171
  this.tokens.push(createTerm());
167
172
  }
168
173
 
@@ -191,6 +196,10 @@ export default {
191
196
  this.tokens = needDenormalization(newValue) ? denormalizeTokens(newValue) : newValue;
192
197
  },
193
198
 
199
+ isActiveToken(idx) {
200
+ return this.activeTokenIdx === idx;
201
+ },
202
+
194
203
  isLastToken(idx) {
195
204
  return !this.activeTokenIdx && idx === this.lastTokenIdx;
196
205
  },
@@ -207,8 +216,14 @@ export default {
207
216
  return this.getTokenEntry(type)?.token || GlFilteredSearchTerm;
208
217
  },
209
218
 
219
+ getLastTokenClassList(idx) {
220
+ return this.isLastToken(idx) && !this.viewOnly ? 'gl-filtered-search-last-item' : '';
221
+ },
222
+
210
223
  activate(idx) {
211
- this.activeTokenIdx = idx;
224
+ if (!this.viewOnly) {
225
+ this.activeTokenIdx = idx;
226
+ }
212
227
  },
213
228
 
214
229
  activatePreviousToken() {
@@ -336,6 +351,7 @@ export default {
336
351
  :history-items="historyItems"
337
352
  :clearable="hasValue"
338
353
  :search-button-attributes="searchButtonAttributes"
354
+ :disabled="viewOnly"
339
355
  data-testid="filtered-search-input"
340
356
  @submit="submit"
341
357
  @input="applyNewValue"
@@ -348,7 +364,10 @@ export default {
348
364
  <slot name="history-item" v-bind="slotScope"></slot>
349
365
  </template>
350
366
  <template #input>
351
- <div class="gl-filtered-search-scrollable">
367
+ <div
368
+ class="gl-filtered-search-scrollable"
369
+ :class="{ 'gl-bg-gray-10! gl-inset-border-1-gray-100!': viewOnly }"
370
+ >
352
371
  <template v-for="(token, idx) in tokens">
353
372
  <component
354
373
  :is="getTokenComponent(token.type)"
@@ -364,11 +383,10 @@ export default {
364
383
  :placeholder="termPlaceholder"
365
384
  :show-friendly-text="showFriendlyText"
366
385
  :search-input-attributes="searchInputAttributes"
386
+ :view-only="viewOnly"
367
387
  :is-last-token="isLastToken(idx)"
368
388
  class="gl-filtered-search-item"
369
- :class="{
370
- 'gl-filtered-search-last-item': isLastToken(idx),
371
- }"
389
+ :class="{ 'gl-filtered-search-last-item': isLastToken(idx) && !viewOnly }"
372
390
  @activate="activate(idx)"
373
391
  @deactivate="deactivate(token)"
374
392
  @destroy="destroyToken(idx, $event)"
@@ -23,7 +23,7 @@ describe('Filtered search term', () => {
23
23
  const segmentStub = {
24
24
  name: 'gl-filtered-search-token-segment-stub',
25
25
  template: '<div><slot name="view"></slot><slot name="suggestions"></slot></div>',
26
- props: ['searchInputAttributes', 'isLastToken', 'currentValue'],
26
+ props: ['searchInputAttributes', 'isLastToken', 'currentValue', 'viewOnly'],
27
27
  };
28
28
 
29
29
  const createComponent = (props) => {
@@ -35,6 +35,7 @@ describe('Filtered search term', () => {
35
35
  });
36
36
  };
37
37
 
38
+ const findSearchInput = () => wrapper.find('input');
38
39
  const findTokenSegmentComponent = () => wrapper.findComponent(segmentStub);
39
40
 
40
41
  it('renders value in inactive mode', () => {
@@ -85,8 +86,9 @@ describe('Filtered search term', () => {
85
86
  }
86
87
  );
87
88
 
88
- it('passes `searchInputAttributes`, `isLastToken`, and `currentValue` props to `GlFilteredSearchTokenSegment`', () => {
89
+ it('passes `searchInputAttributes`, `isLastToken`, `currentValue` & `viewOnly` props to `GlFilteredSearchTokenSegment`', () => {
89
90
  const isLastToken = true;
91
+ const viewOnly = true;
90
92
  const currentValue = [
91
93
  { type: 'filtered-search-term', value: { data: 'something' } },
92
94
  { type: 'filtered-search-term', value: { data: '' } },
@@ -97,23 +99,41 @@ describe('Filtered search term', () => {
97
99
  searchInputAttributes,
98
100
  isLastToken,
99
101
  currentValue,
102
+ viewOnly,
100
103
  });
101
104
 
102
105
  expect(findTokenSegmentComponent().props()).toEqual({
103
106
  searchInputAttributes,
104
107
  isLastToken,
105
108
  currentValue,
109
+ viewOnly,
106
110
  });
107
111
  });
108
112
 
113
+ it('by default sets `viewOnly` to false on `GlFilteredSearchTokenSegment`', () => {
114
+ createComponent();
115
+
116
+ expect(findTokenSegmentComponent().props('viewOnly')).toBe(false);
117
+ });
118
+
109
119
  it('adds `searchInputAttributes` prop to search term input', () => {
110
120
  createComponent({
111
121
  placeholder: 'placeholder-stub',
112
122
  searchInputAttributes,
113
123
  });
114
124
 
115
- expect(wrapper.find('input').attributes('data-qa-selector')).toBe(
125
+ expect(findSearchInput().attributes('data-qa-selector')).toBe(
116
126
  searchInputAttributes['data-qa-selector']
117
127
  );
118
128
  });
129
+
130
+ describe.each([true, false])('when `viewOnly` is %s', (viewOnly) => {
131
+ beforeEach(() => {
132
+ createComponent({ viewOnly, searchInputAttributes, placeholder: 'placeholder-stub' });
133
+ });
134
+
135
+ it(`${viewOnly ? 'adds' : 'does not add'} \`gl-bg-gray-10\` class to search term input`, () => {
136
+ expect(findSearchInput().classes('gl-bg-gray-10')).toBe(viewOnly);
137
+ });
138
+ });
119
139
  });
@@ -69,6 +69,11 @@ export default {
69
69
  default: 'end',
70
70
  validator: (value) => ['start', 'end'].includes(value),
71
71
  },
72
+ viewOnly: {
73
+ type: Boolean,
74
+ required: false,
75
+ default: false,
76
+ },
72
77
  },
73
78
  computed: {
74
79
  suggestedTokens() {
@@ -145,6 +150,7 @@ export default {
145
150
  :search-input-attributes="searchInputAttributes"
146
151
  :is-last-token="isLastToken"
147
152
  :current-value="currentValue"
153
+ :view-only="viewOnly"
148
154
  @activate="$emit('activate')"
149
155
  @deactivate="$emit('deactivate')"
150
156
  @complete="$emit('replace', { type: $event })"
@@ -170,8 +176,10 @@ export default {
170
176
  v-if="placeholder"
171
177
  v-bind="searchInputAttributes"
172
178
  class="gl-filtered-search-term-input"
179
+ :class="{ 'gl-bg-gray-10': viewOnly }"
173
180
  :placeholder="placeholder"
174
181
  :aria-label="placeholder"
182
+ :readonly="viewOnly"
175
183
  data-testid="filtered-search-term-input"
176
184
  />
177
185
 
@@ -8,14 +8,16 @@
8
8
  @include gl-white-space-nowrap;
9
9
  @include gl-cursor-pointer;
10
10
 
11
- &:hover {
12
- .gl-filtered-search-token-type {
13
- @include gl-bg-gray-100;
14
- }
15
-
16
- .gl-filtered-search-token-data,
17
- .gl-filtered-search-token-operator {
18
- @include gl-bg-gray-200;
11
+ &.gl-filtered-search-token-hover {
12
+ &:hover {
13
+ .gl-filtered-search-token-type {
14
+ @include gl-bg-gray-100;
15
+ }
16
+
17
+ .gl-filtered-search-token-data,
18
+ .gl-filtered-search-token-operator {
19
+ @include gl-bg-gray-200;
20
+ }
19
21
  }
20
22
  }
21
23
  }
@@ -39,6 +39,27 @@ describe('Filtered search token', () => {
39
39
  });
40
40
  };
41
41
 
42
+ const mountComponent = (props) => {
43
+ wrapper = mount(GlFilteredSearchToken, {
44
+ provide: {
45
+ portalName: 'fake target',
46
+ alignSuggestions: function fakeAlignSuggestions() {},
47
+ },
48
+ stubs: {
49
+ Portal: {
50
+ template: '<div><slot></slot></div>',
51
+ },
52
+ GlFilteredSearchSuggestionList: {
53
+ template: '<div></div>',
54
+ methods: {
55
+ getValue: () => '=',
56
+ },
57
+ },
58
+ },
59
+ propsData: { ...defaultProps, ...props },
60
+ });
61
+ };
62
+
42
63
  describe('when activated', () => {
43
64
  it('emits activate when operator segment is clicked', () => {
44
65
  createComponent();
@@ -201,27 +222,6 @@ describe('Filtered search token', () => {
201
222
  }
202
223
  });
203
224
 
204
- const mountComponent = (props) => {
205
- wrapper = mount(GlFilteredSearchToken, {
206
- provide: {
207
- portalName: 'fake target',
208
- alignSuggestions: function fakeAlignSuggestions() {},
209
- },
210
- stubs: {
211
- Portal: {
212
- template: '<div><slot></slot></div>',
213
- },
214
- GlFilteredSearchSuggestionList: {
215
- template: '<div></div>',
216
- methods: {
217
- getValue: () => '=',
218
- },
219
- },
220
- },
221
- propsData: { ...defaultProps, ...props },
222
- });
223
- };
224
-
225
225
  it('emits close event when data token is closed', () => {
226
226
  mountComponent({ value: { operator: '=', data: 'something' } });
227
227
  const closeWrapper = wrapper.find('.gl-token-close');
@@ -302,4 +302,57 @@ describe('Filtered search token', () => {
302
302
  expect(findDataSegment().props('value')).toEqual('gamma');
303
303
  });
304
304
  });
305
+
306
+ describe('view-only state', () => {
307
+ it('prevents segments from activating when view-only is true', async () => {
308
+ createComponent({
309
+ active: true,
310
+ value: { operator: '=' },
311
+ viewOnly: true,
312
+ });
313
+
314
+ await findTitleSegment().vm.$emit('activate');
315
+
316
+ expect(findTitleSegment().props().active).toBe(false);
317
+ });
318
+
319
+ it('does not add a mousedown listener to the token-data when view-only is true', async () => {
320
+ mountComponent({ value: { operator: '=', data: 'something' }, viewOnly: true });
321
+
322
+ const tokenData = wrapper.find('.gl-filtered-search-token-data');
323
+ tokenData.element.closest = jest.fn(() => tokenData.element);
324
+
325
+ await tokenData.trigger('mousedown');
326
+
327
+ expect(tokenData.element.closest).not.toHaveBeenCalled();
328
+ });
329
+
330
+ describe.each`
331
+ viewOnly | hoverClassExists | cursorClassExists | propValue
332
+ ${true} | ${false} | ${true} | ${true}
333
+ ${false} | ${true} | ${false} | ${false}
334
+ `(
335
+ 'when view-only is $viewOnly',
336
+ ({ viewOnly, hoverClassExists, cursorClassExists, propValue }) => {
337
+ beforeEach(() => {
338
+ createComponent({
339
+ active: true,
340
+ value: { operator: '=' },
341
+ viewOnly,
342
+ });
343
+ });
344
+
345
+ it(`${viewOnly ? 'applies' : 'does not apply'} the view-only style classes`, () => {
346
+ expect(wrapper.find('.gl-filtered-search-token-hover').exists()).toBe(hoverClassExists);
347
+ expect(wrapper.find('.gl-cursor-default').exists()).toBe(cursorClassExists);
348
+ });
349
+
350
+ it(`sets the view-only prop to ${viewOnly} on the title, data and operator segments`, () => {
351
+ expect(findTitleSegment().props('viewOnly')).toBe(propValue);
352
+ expect(findDataSegment().props('viewOnly')).toBe(propValue);
353
+ expect(findOperatorSegment().props('viewOnly')).toBe(propValue);
354
+ });
355
+ }
356
+ );
357
+ });
305
358
  });
@@ -71,6 +71,11 @@ export default {
71
71
  default: 'end',
72
72
  validator: (value) => ['start', 'end'].includes(value),
73
73
  },
74
+ viewOnly: {
75
+ type: Boolean,
76
+ required: false,
77
+ default: false,
78
+ },
74
79
  },
75
80
  data() {
76
81
  return {
@@ -100,6 +105,10 @@ export default {
100
105
  const operator = this.operators.find((op) => op.value === this.tokenValue.operator);
101
106
  return this.showFriendlyText ? operator?.description : operator?.value;
102
107
  },
108
+
109
+ eventListeners() {
110
+ return this.viewOnly ? {} : { mousedown: this.destroyByClose };
111
+ },
103
112
  },
104
113
  segments: {
105
114
  SEGMENT_TITLE,
@@ -165,6 +174,8 @@ export default {
165
174
 
166
175
  methods: {
167
176
  activateSegment(segment) {
177
+ if (this.viewOnly) return;
178
+
168
179
  this.activeSegment = segment;
169
180
 
170
181
  if (!this.active) {
@@ -178,6 +189,9 @@ export default {
178
189
  },
179
190
 
180
191
  getAdditionalSegmentClasses(segment) {
192
+ if (this.viewOnly) {
193
+ return 'gl-cursor-text';
194
+ }
181
195
  return { 'gl-cursor-pointer': !this.isSegmentActive(segment) };
182
196
  },
183
197
 
@@ -293,7 +307,11 @@ export default {
293
307
  <template>
294
308
  <div
295
309
  class="gl-filtered-search-token"
296
- :class="{ 'gl-filtered-search-token-active': active }"
310
+ :class="{
311
+ 'gl-filtered-search-token-active': active,
312
+ 'gl-filtered-search-token-hover': !viewOnly,
313
+ 'gl-cursor-default': viewOnly,
314
+ }"
297
315
  data-testid="filtered-search-token"
298
316
  >
299
317
  <!--
@@ -307,6 +325,7 @@ export default {
307
325
  :active="isSegmentActive($options.segments.SEGMENT_TITLE)"
308
326
  :cursor-position="intendedCursorPosition"
309
327
  :options="availableTokensWithSelf"
328
+ :view-only="viewOnly"
310
329
  @activate="activateSegment($options.segments.SEGMENT_TITLE)"
311
330
  @deactivate="$emit('deactivate')"
312
331
  @complete="replaceToken"
@@ -333,7 +352,7 @@ export default {
333
352
  :cursor-position="intendedCursorPosition"
334
353
  :options="operators"
335
354
  :custom-input-keydown-handler="handleOperatorKeydown"
336
- view-only
355
+ :view-only="viewOnly"
337
356
  @activate="activateSegment($options.segments.SEGMENT_OPERATOR)"
338
357
  @backspace="replaceWithTermIfEmpty"
339
358
  @complete="activateSegment($options.segments.SEGMENT_DATA)"
@@ -382,6 +401,7 @@ export default {
382
401
  :cursor-position="intendedCursorPosition"
383
402
  :multi-select="config.multiSelect"
384
403
  :options="config.options"
404
+ :view-only="viewOnly"
385
405
  option-text-field="title"
386
406
  @activate="activateDataSegment"
387
407
  @backspace="activateSegment($options.segments.SEGMENT_OPERATOR)"
@@ -406,7 +426,7 @@ export default {
406
426
  name="view-token"
407
427
  v-bind="{
408
428
  inputValue,
409
- listeners: { mousedown: destroyByClose },
429
+ listeners: eventListeners,
410
430
  cssClasses: {
411
431
  'gl-filtered-search-token-data': true,
412
432
  ...getAdditionalSegmentClasses($options.segments.SEGMENT_DATA),
@@ -417,7 +437,8 @@ export default {
417
437
  class="gl-filtered-search-token-data"
418
438
  variant="search-value"
419
439
  :class="getAdditionalSegmentClasses($options.segments.SEGMENT_DATA)"
420
- @mousedown="destroyByClose"
440
+ :view-only="viewOnly"
441
+ v-on="eventListeners"
421
442
  >
422
443
  <span class="gl-filtered-search-token-data-content">
423
444
  <!--
@@ -77,6 +77,14 @@ describe('Filtered search token segment', () => {
77
77
  expect(wrapper.emitted().activate).toHaveLength(1);
78
78
  });
79
79
 
80
+ it('does not emit activate when view-only is true', () => {
81
+ createComponent({ viewOnly: true, value: '' });
82
+
83
+ wrapper.trigger('mousedown.left');
84
+
85
+ expect(wrapper.emitted().activate).toBeUndefined();
86
+ });
87
+
80
88
  it('ignores mousedown if active', () => {
81
89
  createComponent({ value: '', active: true });
82
90
 
@@ -312,5 +320,15 @@ describe('Filtered search token segment', () => {
312
320
  searchInputAttributes['data-qa-selector']
313
321
  );
314
322
  });
323
+
324
+ describe.each([true, false])('and viewOnly is %s', (viewOnly) => {
325
+ const readonly = viewOnly ? 'readonly' : undefined;
326
+
327
+ it(`sets the input \`readonly\` atttribute to ${readonly}`, () => {
328
+ createWrappedComponent({ value: 'test', active: true, viewOnly });
329
+
330
+ expect(wrapper.find('input').attributes('readonly')).toBe(readonly);
331
+ });
332
+ });
315
333
  });
316
334
  });