diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/filtered_search_bar')
9 files changed, 258 insertions, 183 deletions
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 2cb1b6a195f..9775a9119c6 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -21,7 +21,7 @@ export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, ]); -export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings +export const DEFAULT_LABELS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 3e7feb91b27..5ab287150f2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -12,7 +12,7 @@ import { import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; @@ -211,7 +211,9 @@ export default { .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - createFlash(__('An error occurred while parsing recent searches')); + createFlash({ + message: __('An error occurred while parsing recent searches'), + }); // Gracefully fail to empty array return []; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index e5c8d29e09b..37436de907f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; import { queryToObject } from '~/lib/utils/url_utility'; -import { MAX_RECENT_TOKENS_SIZE } from './constants'; +import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants'; /** * Strips enclosing quotations from a string if it has one. @@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); export const uniqueTokens = (tokens) => { const knownTokens = []; return tokens.reduce((uniques, token) => { - if (typeof token === 'object' && token.type !== 'filtered-search-term') { + if (typeof token === 'object' && token.type !== FILTERED_SEARCH_TERM) { const tokenString = `${token.type}${token.value.operator}${token.value.data}`; if (!knownTokens.includes(tokenString)) { uniques.push(token); @@ -86,21 +86,37 @@ export function processFilters(filters) { }, {}); } +function filteredSearchQueryParam(filter) { + return filter + .map(({ value }) => value) + .join(' ') + .trim(); +} + /** * This function takes a filter object and maps it into a query object. Example filter: - * { myFilterName: { value: 'foo', operator: '=' } } + * { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] } * gets translated into: - * { myFilterName: 'foo', 'not[myFilterName]': null } + * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' } * @param {Object} filters - * @param {Object.myFilterName} a single filter value or an array of filters + * @param {Object} filters.myFilterName a single filter value or an array of filters + * @param {Object} options + * @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested * @return {Object} query object with both filter name and not-name with values */ -export function filterToQueryObject(filters = {}) { +export function filterToQueryObject(filters = {}, options = {}) { + const { filteredSearchTermKey } = options; + return Object.keys(filters).reduce((memo, key) => { const filter = filters[key]; + if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) { + return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) }; + } + let selected; let unselected; + if (Array.isArray(filter)) { selected = filter.filter((item) => item.operator === '=').map((item) => item.value); unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value); @@ -125,7 +141,7 @@ export function filterToQueryObject(filters = {}) { * and returns the operator with it depending on the filter name * @param {String} filterName from url * @return {Object} - * @return {Object.filterName} extracted filtern ame + * @return {Object.filterName} extracted filter name * @return {Object.operator} `=` or `!=` */ function extractNameAndOperator(filterName) { @@ -138,21 +154,52 @@ function extractNameAndOperator(filterName) { } /** + * Gathers search term as values + * @param {String|Array} value + * @returns {Array} List of search terms split by word + */ +function filteredSearchTermValue(value) { + const values = Array.isArray(value) ? value : [value]; + return values + .filter((term) => term) + .join(' ') + .split(' ') + .map((term) => ({ value: term })); +} + +/** * This function takes a URL query string and maps it into a filter object. Example query string: * '?myFilterName=foo' * gets translated into: * { myFilterName: { value: 'foo', operator: '=' } } - * @param {String} query URL quert string, e.g. from `window.location.search` + * @param {String} query URL query string, e.g. from `window.location.search` + * @param {Object} options + * @param {Object} options + * @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested + * @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped + * @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested * @return {Object} filter object with filter names and their values */ -export function urlQueryToFilter(query = '') { - const filters = queryToObject(query, { gatherArrays: true }); +export function urlQueryToFilter(query = '', options = {}) { + const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options; + + const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode }); return Object.keys(filters).reduce((memo, key) => { const value = filters[key]; if (!value) { return memo; } + if (key === filteredSearchTermKey) { + return { + ...memo, + [FILTERED_SEARCH_TERM]: filteredSearchTermValue(value), + }; + } + const { filterName, operator } = extractNameAndOperator(key); + if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) { + return memo; + } let previousValues = []; if (Array.isArray(memo[filterName])) { previousValues = memo[filterName]; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js index 4dfc61f1fff..f4317ba90a2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -24,7 +24,9 @@ export function fetchBranches({ commit, state }, search = '') { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_BRANCHES_ERROR, status); - createFlash(__('Failed to load branches. Please try again.')); + createFlash({ + message: __('Failed to load branches. Please try again.'), + }); }); } @@ -41,7 +43,9 @@ export const fetchMilestones = ({ commit, state }, search_title = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_MILESTONES_ERROR, status); - createFlash(__('Failed to load milestones. Please try again.')); + createFlash({ + message: __('Failed to load milestones. Please try again.'), + }); }); }; @@ -57,7 +61,9 @@ export const fetchLabels = ({ commit, state }, search = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_LABELS_ERROR, status); - createFlash(__('Failed to load labels. Please try again.')); + createFlash({ + message: __('Failed to load labels. Please try again.'), + }); }); }; @@ -80,7 +86,9 @@ function fetchUser(options = {}) { .catch(({ response }) => { const { status } = response; commit(`RECEIVE_${action}_ERROR`, status); - createFlash(errorMessage); + createFlash({ + message: errorMessage, + }); }); } diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index aeb698a3adb..2e7b3e149b2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -1,25 +1,18 @@ <script> -import { - GlFilteredSearchToken, - GlAvatar, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABEL_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { config: { @@ -30,37 +23,28 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { authors: this.config.initialAuthors || [], defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], - loading: true, + preloadedAuthors: this.config.preloadedAuthors || [], + loading: false, }; }, - computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeAuthor() { - return this.authors.find((author) => author.username.toLowerCase() === this.currentValue); - }, - activeAuthorAvatar() { - return this.avatarUrl(this.activeAuthor); + methods: { + getActiveAuthor(authors, currentValue) { + return authors.find((author) => author.username.toLowerCase() === currentValue); }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.authors.length) { - this.fetchAuthorBySearchTerm(this.value.data); - } - }, + getAvatarUrl(author) { + return author.avatarUrl || author.avatar_url; }, - }, - methods: { fetchAuthorBySearchTerm(searchTerm) { + this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) : this.config.fetchAuthors(searchTerm); @@ -72,63 +56,56 @@ export default { // return response differently. this.authors = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching users.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching users.'), + }), + ) .finally(() => { this.loading = false; }); }, - avatarUrl(author) { - return author.avatarUrl || author.avatar_url; - }, - searchAuthors: debounce(function debouncedSearch({ data }) { - this.fetchAuthorBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token - :config="config" - v-bind="{ ...$props, ...$attrs }" - v-on="$listeners" - @input="searchAuthors" + <base-token + :token-config="config" + :token-value="value" + :token-active="active" + :tokens-list-loading="loading" + :token-values="authors" + :fn-active-token-value="getActiveAuthor" + :default-token-values="defaultAuthors" + :preloaded-token-values="preloadedAuthors" + :recent-token-values-storage-key="config.recentTokenValuesStorageKey" + @fetch-token-values="fetchAuthorBySearchTerm" > - <template #view="{ inputValue }"> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> <gl-avatar - v-if="activeAuthor" + v-if="activeTokenValue" :size="16" - :src="activeAuthorAvatar" + :src="getAvatarUrl(activeTokenValue)" shape="circle" class="gl-mr-2" /> - <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> + <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> </template> - <template #suggestions> + <template #token-values-list="{ tokenValues }"> <gl-filtered-search-suggestion - v-for="author in defaultAuthors" - :key="author.value" - :value="author.value" + v-for="author in tokenValues" + :key="author.username" + :value="author.username" > - {{ author.text }} - </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultAuthors.length" /> - <gl-loading-icon v-if="loading" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="author in authors" - :key="author.username" - :value="author.username" - > - <div class="d-flex"> - <gl-avatar :size="32" :src="avatarUrl(author)" /> - <div> - <div>{{ author.name }}</div> - <div>@{{ author.username }}</div> - </div> + <div class="gl-display-flex"> + <gl-avatar :size="32" :src="getAvatarUrl(author)" /> + <div> + <div>{{ author.name }}</div> + <div>@{{ author.username }}</div> </div> - </gl-filtered-search-suggestion> - </template> + </div> + </gl-filtered-search-suggestion> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 6ebc5431012..fb6b9e4bc0d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -48,6 +48,11 @@ export default { required: false, default: () => [], }, + preloadedTokenValues: { + type: Array, + required: false, + default: () => [], + }, recentTokenValuesStorageKey: { type: String, required: false, @@ -78,7 +83,10 @@ export default { return Boolean(this.recentTokenValuesStorageKey); }, recentTokenIds() { - return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name); + return this.recentTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); + }, + preloadedTokenIds() { + return this.preloadedTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); }, currentTokenValue() { if (this.fnCurrentTokenValue) { @@ -98,7 +106,9 @@ export default { return this.searchKey ? this.tokenValues : this.tokenValues.filter( - (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]), + (tokenValue) => + !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) && + !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), ); }, }, @@ -120,7 +130,15 @@ export default { }, DEBOUNCE_DELAY); }, handleTokenValueSelected(activeTokenValue) { - if (this.isRecentTokenValuesEnabled) { + // Make sure that; + // 1. Recently used values feature is enabled + // 2. User has actually selected a value + // 3. Selected value is not part of preloaded list. + if ( + this.isRecentTokenValuesEnabled && + activeTokenValue && + !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) + ) { setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue); } }, @@ -158,6 +176,11 @@ export default { <slot name="token-values-list" :token-values="recentTokenValues"></slot> <gl-dropdown-divider /> </template> + <slot + v-if="preloadedTokenValues.length" + name="token-values-list" + :token-values="preloadedTokenValues" + ></slot> <gl-loading-icon v-if="tokensListLoading" /> <template v-else> <slot name="token-values-list" :token-values="availableTokenValues"></slot> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index f2f4787d80b..9ba7f3d1a1d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -7,7 +7,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; @@ -65,7 +65,11 @@ export default { .then((res) => { this.emojis = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching emojis.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching emojis.'), + }), + ) .finally(() => { this.loading = false; }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index 1450807b11d..d21fa9a344a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -11,6 +11,7 @@ import { __ } from '~/locale'; import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; export default { + separator: '::&', components: { GlDropdownDivider, GlFilteredSearchToken, @@ -34,17 +35,35 @@ export default { }; }, computed: { + idProperty() { + return this.config.idProperty || 'iid'; + }, currentValue() { - return Number(this.value.data); + const epicIid = Number(this.value.data); + if (epicIid) { + return epicIid; + } + return this.value.data; }, defaultEpics() { return this.config.defaultEpics || DEFAULT_NONE_ANY; }, - idProperty() { - return this.config.idProperty || 'id'; - }, activeEpic() { - return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); + if (this.currentValue && this.epics.length) { + // Check if current value is an epic ID. + if (typeof this.currentValue === 'number') { + return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); + } + + // Current value is a string. + const [groupPath, idProperty] = this.currentValue?.split('::&'); + return this.epics.find( + (epic) => + epic.group_full_path === groupPath && + epic[this.idProperty] === parseInt(idProperty, 10), + ); + } + return null; }, }, watch: { @@ -58,10 +77,10 @@ export default { }, }, methods: { - fetchEpicsBySearchTerm(searchTerm = '') { + fetchEpicsBySearchTerm({ epicPath = '', search = '' }) { this.loading = true; this.config - .fetchEpics(searchTerm) + .fetchEpics({ epicPath, search }) .then((response) => { this.epics = Array.isArray(response) ? response : response.data; }) @@ -71,11 +90,21 @@ export default { }); }, searchEpics: debounce(function debouncedSearch({ data }) { - this.fetchEpicsBySearchTerm(data); + let epicPath = this.activeEpic?.web_url; + + // When user visits the page with token value already included in filters + // We don't have any information about selected token except for its + // group path and iid joined by separator, so we need to manually + // compose epic path from it. + if (data.includes(this.$options.separator)) { + const [groupPath, epicIid] = data.split(this.$options.separator); + epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; + } + this.fetchEpicsBySearchTerm({ epicPath, search: data }); }, DEBOUNCE_DELAY), getEpicDisplayText(epic) { - return `${epic.title}::&${epic[this.idProperty]}`; + return `${epic.title}${this.$options.separator}${epic.iid}`; }, }, }; @@ -104,8 +133,8 @@ export default { <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" - :key="epic[idProperty]" - :value="String(epic[idProperty])" + :key="epic.id" + :value="`${epic.group_full_path}::&${epic[idProperty]}`" > {{ epic.title }} </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 76b005772ec..20b8cbfe933 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -1,27 +1,20 @@ <script> -import { - GlToken, - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_LABELS, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABELS } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; +import BaseToken from './base_token.vue'; + export default { components: { + BaseToken, GlToken, - GlFilteredSearchToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { config: { @@ -32,43 +25,24 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { labels: this.config.initialLabels || [], defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, - loading: true, + loading: false, }; }, - computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeLabel() { - return this.labels.find( - (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue), + methods: { + getActiveLabel(labels, currentValue) { + return labels.find( + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue), ); }, - containerStyle() { - if (this.activeLabel) { - const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel); - - return { backgroundColor: color, color: textColor }; - } - return {}; - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.labels.length) { - this.fetchLabelBySearchTerm(this.value.data); - } - }, - }, - }, - methods: { /** * There's an inconsistency between private and public API * for labels where label name is included in a different @@ -84,6 +58,16 @@ export default { getLabelName(label) { return label.name || label.title; }, + getContainerStyle(activeLabel) { + if (activeLabel) { + const { color: backgroundColor, textColor: color } = convertObjectPropsToCamelCase( + activeLabel, + ); + + return { backgroundColor, color }; + } + return {}; + }, fetchLabelBySearchTerm(searchTerm) { this.loading = true; this.config @@ -94,55 +78,56 @@ export default { // return response differently. this.labels = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching labels.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching labels.'), + }), + ) .finally(() => { this.loading = false; }); }, - searchLabels: debounce(function debouncedSearch({ data }) { - if (!this.loading) this.fetchLabelBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token - :config="config" - v-bind="{ ...$props, ...$attrs }" - v-on="$listeners" - @input="searchLabels" + <base-token + :token-config="config" + :token-value="value" + :token-active="active" + :tokens-list-loading="loading" + :token-values="labels" + :fn-active-token-value="getActiveLabel" + :default-token-values="defaultLabels" + :recent-token-values-storage-key="config.recentTokenValuesStorageKey" + @fetch-token-values="fetchLabelBySearchTerm" > - <template #view-token="{ inputValue, cssClasses, listeners }"> - <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners" - >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token + <template + #view-token="{ viewTokenProps: { inputValue, cssClasses, listeners, activeTokenValue } }" + > + <gl-token + variant="search-value" + :class="cssClasses" + :style="getContainerStyle(activeTokenValue)" + v-on="listeners" + >~{{ activeTokenValue ? getLabelName(activeTokenValue) : inputValue }}</gl-token > </template> - <template #suggestions> + <template #token-values-list="{ tokenValues }"> <gl-filtered-search-suggestion - v-for="label in defaultLabels" - :key="label.value" - :value="label.value" + v-for="label in tokenValues" + :key="label.id" + :value="getLabelName(label)" > - {{ label.text }} + <div class="gl-display-flex gl-align-items-center"> + <span + :style="{ backgroundColor: label.color }" + class="gl-display-inline-block mr-2 p-2" + ></span> + <div>{{ getLabelName(label) }}</div> + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultLabels.length" /> - <gl-loading-icon v-if="loading" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="label in labels" - :key="label.id" - :value="getLabelName(label)" - > - <div class="gl-display-flex gl-align-items-center"> - <span - :style="{ backgroundColor: label.color }" - class="gl-display-inline-block mr-2 p-2" - ></span> - <div>{{ getLabelName(label) }}</div> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> |