@gitlab/ui 61.2.0 → 62.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.
- package/CHANGELOG.md +25 -0
- package/dist/components/base/daterange_picker/daterange_picker.js +1 -1
- package/dist/components/base/filtered_search/common_story_options.js +2 -1
- package/dist/components/base/filtered_search/filtered_search.js +28 -4
- package/dist/components/base/filtered_search/filtered_search_suggestion_list.js +13 -5
- package/dist/components/base/filtered_search/filtered_search_term.js +51 -4
- package/dist/components/base/filtered_search/filtered_search_token.js +1 -2
- package/dist/components/base/filtered_search/filtered_search_token_segment.js +18 -5
- package/dist/components/base/filtered_search/filtered_search_utils.js +18 -7
- package/dist/components/base/token_selector/token_container.js +1 -1
- package/dist/index.css.map +1 -1
- package/dist/utility_classes.css +1 -1
- package/dist/utility_classes.css.map +1 -1
- package/package.json +1 -1
- package/src/components/base/daterange_picker/daterange_picker.vue +1 -1
- package/src/components/base/filtered_search/__snapshots__/filtered_search_term.spec.js.snap +2 -5
- package/src/components/base/filtered_search/common_story_options.js +1 -0
- package/src/components/base/filtered_search/filtered_search.md +10 -1
- package/src/components/base/filtered_search/filtered_search.spec.js +14 -2
- package/src/components/base/filtered_search/filtered_search.stories.js +17 -0
- package/src/components/base/filtered_search/filtered_search.vue +29 -1
- package/src/components/base/filtered_search/filtered_search_suggestion_list.spec.js +222 -75
- package/src/components/base/filtered_search/filtered_search_suggestion_list.vue +15 -6
- package/src/components/base/filtered_search/filtered_search_term.spec.js +73 -14
- package/src/components/base/filtered_search/filtered_search_term.vue +64 -6
- package/src/components/base/filtered_search/filtered_search_token.spec.js +1 -0
- package/src/components/base/filtered_search/filtered_search_token.vue +2 -2
- package/src/components/base/filtered_search/filtered_search_token_segment.spec.js +46 -2
- package/src/components/base/filtered_search/filtered_search_token_segment.vue +22 -5
- package/src/components/base/filtered_search/filtered_search_utils.js +20 -7
- package/src/components/base/token_selector/token_container.vue +1 -1
- package/src/scss/utilities.scss +0 -72
- package/src/scss/utility-mixins/flex.scss +0 -41
|
@@ -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: [
|
|
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
|
|
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'} | ${
|
|
68
|
-
${'submit'} | ${'submit'} | ${undefined}
|
|
69
|
-
${'
|
|
70
|
-
${'
|
|
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 {
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
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="
|
|
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="
|
|
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 }"
|
|
@@ -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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
144
|
-
tokens.forEach((t) => {
|
|
145
|
+
return tokens.reduce((result, t) => {
|
|
145
146
|
if (typeof t === 'string') {
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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];
|
|
@@ -128,7 +128,7 @@ export default {
|
|
|
128
128
|
</script>
|
|
129
129
|
|
|
130
130
|
<template>
|
|
131
|
-
<div class="gl-display-flex gl-flex-
|
|
131
|
+
<div class="gl-display-flex gl-flex-nowrap gl-align-items-flex-start gl-w-full">
|
|
132
132
|
<div
|
|
133
133
|
ref="tokenContainer"
|
|
134
134
|
class="gl-display-flex gl-flex-wrap gl-align-items-center gl-my-n1 gl-mx-n1 gl-w-full"
|
package/src/scss/utilities.scss
CHANGED
|
@@ -3387,78 +3387,6 @@
|
|
|
3387
3387
|
}
|
|
3388
3388
|
}
|
|
3389
3389
|
|
|
3390
|
-
.gl-flex-wrap-wrap {
|
|
3391
|
-
flex-wrap: wrap;
|
|
3392
|
-
}
|
|
3393
|
-
|
|
3394
|
-
.gl-flex-wrap-wrap\! {
|
|
3395
|
-
flex-wrap: wrap !important;
|
|
3396
|
-
}
|
|
3397
|
-
|
|
3398
|
-
.gl-lg-flex-wrap-wrap {
|
|
3399
|
-
@include gl-media-breakpoint-up(lg) {
|
|
3400
|
-
flex-wrap: wrap;
|
|
3401
|
-
}
|
|
3402
|
-
}
|
|
3403
|
-
|
|
3404
|
-
.gl-lg-flex-wrap-wrap\! {
|
|
3405
|
-
@include gl-media-breakpoint-up(lg) {
|
|
3406
|
-
flex-wrap: wrap !important;
|
|
3407
|
-
}
|
|
3408
|
-
}
|
|
3409
|
-
|
|
3410
|
-
.gl-xl-flex-wrap-wrap {
|
|
3411
|
-
@include gl-media-breakpoint-up(xl) {
|
|
3412
|
-
flex-wrap: wrap;
|
|
3413
|
-
}
|
|
3414
|
-
}
|
|
3415
|
-
|
|
3416
|
-
.gl-xl-flex-wrap-wrap\! {
|
|
3417
|
-
@include gl-media-breakpoint-up(xl) {
|
|
3418
|
-
flex-wrap: wrap !important;
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
|
-
.gl-sm-flex-wrap-wrap {
|
|
3423
|
-
@include gl-media-breakpoint-up(sm) {
|
|
3424
|
-
flex-wrap: wrap;
|
|
3425
|
-
}
|
|
3426
|
-
}
|
|
3427
|
-
|
|
3428
|
-
.gl-sm-flex-wrap-wrap\! {
|
|
3429
|
-
@include gl-media-breakpoint-up(sm) {
|
|
3430
|
-
flex-wrap: wrap !important;
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
|
-
.gl-flex-wrap-wrap-reverse {
|
|
3435
|
-
flex-wrap: wrap-reverse;
|
|
3436
|
-
}
|
|
3437
|
-
|
|
3438
|
-
.gl-flex-wrap-wrap-reverse\! {
|
|
3439
|
-
flex-wrap: wrap-reverse !important;
|
|
3440
|
-
}
|
|
3441
|
-
|
|
3442
|
-
.gl-flex-wrap-nowrap {
|
|
3443
|
-
flex-wrap: nowrap;
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
.gl-flex-wrap-nowrap\! {
|
|
3447
|
-
flex-wrap: nowrap !important;
|
|
3448
|
-
}
|
|
3449
|
-
|
|
3450
|
-
.gl-sm-flex-wrap-nowrap {
|
|
3451
|
-
@include gl-media-breakpoint-up(sm) {
|
|
3452
|
-
flex-wrap: nowrap;
|
|
3453
|
-
}
|
|
3454
|
-
}
|
|
3455
|
-
|
|
3456
|
-
.gl-sm-flex-wrap-nowrap\! {
|
|
3457
|
-
@include gl-media-breakpoint-up(sm) {
|
|
3458
|
-
flex-wrap: nowrap !important;
|
|
3459
|
-
}
|
|
3460
|
-
}
|
|
3461
|
-
|
|
3462
3390
|
.gl-flex-direction-column {
|
|
3463
3391
|
flex-direction: column;
|
|
3464
3392
|
}
|
|
@@ -101,47 +101,6 @@
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* `gl-*flex-wrap-wrap` is deprecated; use `gl-*flex-wrap` instead.
|
|
106
|
-
* TODO: delete `gl-*flex-wrap-wrap` utilities classes, see
|
|
107
|
-
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2204
|
|
108
|
-
*/
|
|
109
|
-
@mixin gl-flex-wrap-wrap {
|
|
110
|
-
flex-wrap: wrap;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
@mixin gl-lg-flex-wrap-wrap {
|
|
114
|
-
@include gl-media-breakpoint-up(lg) {
|
|
115
|
-
@include gl-flex-wrap-wrap;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
@mixin gl-xl-flex-wrap-wrap {
|
|
120
|
-
@include gl-media-breakpoint-up(xl) {
|
|
121
|
-
@include gl-flex-wrap-wrap;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
@mixin gl-sm-flex-wrap-wrap {
|
|
126
|
-
@include gl-media-breakpoint-up(sm) {
|
|
127
|
-
@include gl-flex-wrap-wrap;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
@mixin gl-flex-wrap-wrap-reverse {
|
|
132
|
-
flex-wrap: wrap-reverse;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
@mixin gl-flex-wrap-nowrap {
|
|
136
|
-
flex-wrap: nowrap;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
@mixin gl-sm-flex-wrap-nowrap {
|
|
140
|
-
@include gl-media-breakpoint-up(sm) {
|
|
141
|
-
@include gl-flex-wrap-nowrap;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
104
|
@mixin gl-flex-direction-column {
|
|
146
105
|
flex-direction: column;
|
|
147
106
|
}
|