diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/filtered_search_bar')
7 files changed, 350 insertions, 42 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 7b3d1d0afd6..3d8afd162cb 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 @@ -1,8 +1,11 @@ +/* eslint-disable @gitlab/require-i18n-strings */ import { __ } from '~/locale'; -export const ANY_AUTHOR = 'Any'; +const DEFAULT_LABEL_NO_LABEL = { value: 'No label', text: __('No label') }; +export const DEFAULT_LABEL_NONE = { value: 'None', text: __('None') }; +export const DEFAULT_LABEL_ANY = { value: 'Any', text: __('Any') }; -export const NO_LABEL = 'No label'; +export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL]; export const DEBOUNCE_DELAY = 200; @@ -11,13 +14,11 @@ export const SortDirection = { ascending: 'ascending', }; -export const defaultMilestones = [ - // eslint-disable-next-line @gitlab/require-i18n-strings - { value: 'None', text: __('None') }, - // eslint-disable-next-line @gitlab/require-i18n-strings - { value: 'Any', text: __('Any') }, - // eslint-disable-next-line @gitlab/require-i18n-strings +export const DEFAULT_MILESTONES = [ + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, { value: 'Upcoming', text: __('Upcoming') }, - // eslint-disable-next-line @gitlab/require-i18n-strings { value: 'Started', text: __('Started') }, ]; + +/* eslint-enable @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 ee293d37b66..25478ad6f4f 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 @@ -3,8 +3,8 @@ import { GlFilteredSearch, GlButtonGroup, GlButton, - GlNewDropdown as GlDropdown, - GlNewDropdownItem as GlDropdownItem, + GlDropdown, + GlDropdownItem, GlTooltipDirective, } from '@gitlab/ui'; @@ -15,7 +15,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { stripQuotes } from './filtered_search_utils'; +import { stripQuotes, uniqueTokens } from './filtered_search_utils'; import { SortDirection } from './constants'; export default { @@ -120,10 +120,31 @@ export default { ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, + /** + * This prop fixes a behaviour affecting GlFilteredSearch + * where selecting duplicate token values leads to history + * dropdown also showing that selection. + */ filteredRecentSearches() { - return this.recentSearchesStorageKey - ? this.recentSearches.filter(item => typeof item !== 'string') - : undefined; + if (this.recentSearchesStorageKey) { + const knownItems = []; + return this.recentSearches.reduce((historyItems, item) => { + // Only include non-string history items (discard items from legacy search) + if (typeof item !== 'string') { + const sanitizedItem = uniqueTokens(item); + const itemString = JSON.stringify(sanitizedItem); + // Only include items which aren't already part of history + if (!knownItems.includes(itemString)) { + historyItems.push(sanitizedItem); + // We're storing string for comparision as doing direct object compare + // won't work due to object reference not being the same. + knownItems.push(itemString); + } + } + return historyItems; + }, []); + } + return undefined; }, }, watch: { @@ -245,12 +266,14 @@ export default { this.recentSearchesService.save(resultantSearches); this.recentSearches = []; }, - handleFilterSubmit(filters) { + handleFilterSubmit() { + const filterTokens = uniqueTokens(this.filterValue); + this.filterValue = filterTokens; if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { - if (filters.length) { - const resultantSearches = this.recentSearchesStore.addRecentSearch(filters); + if (filterTokens.length) { + const resultantSearches = this.recentSearchesStore.addRecentSearch(filterTokens); this.recentSearchesService.save(resultantSearches); this.recentSearches = resultantSearches; } @@ -260,7 +283,7 @@ export default { }); } this.blurSearchInput(); - this.$emit('onFilter', this.removeQuotesEnclosure(filters)); + this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, }, }; 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 85f7f746b49..e7d7b7d9f1b 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 @@ -1,4 +1,164 @@ -// eslint-disable-next-line import/prefer-default-export -export const stripQuotes = value => { - return value.includes(' ') ? value.slice(1, -1) : value; +import { isEmpty } from 'lodash'; +import { queryToObject } from '~/lib/utils/url_utility'; + +/** + * Strips enclosing quotations from a string if it has one. + * + * @param {String} value String to strip quotes from + * + * @returns {String} String without any enclosure + */ +export const stripQuotes = value => value.replace(/^('|")(.*)('|")$/, '$2'); + +/** + * This method removes duplicate tokens from tokens array. + * + * @param {Array} tokens Array of tokens as defined by `GlFilteredSearch` + * + * @returns {Array} Unique array of tokens + */ +export const uniqueTokens = tokens => { + const knownTokens = []; + return tokens.reduce((uniques, token) => { + 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); + knownTokens.push(tokenString); + } + } else { + uniques.push(token); + } + return uniques; + }, []); }; + +/** + * Creates a token from a type and a filter. Example returned object + * { type: 'myType', value: { data: 'myData', operator: '= '} } + * @param {String} type the name of the filter + * @param {Object} + * @param {Object.value} filter value to be returned as token data + * @param {Object.operator} filter operator to be retuned as token operator + * @return {Object} + * @return {Object.type} token type + * @return {Object.value} token value + */ +function createToken(type, filter) { + return { type, value: { data: filter.value, operator: filter.operator } }; +} + +/** + * This function takes a filter object and translates it into a token array + * @param {Object} filters + * @param {Object.myFilterName} a single filter value or an array of filters + * @return {Array} tokens an array of tokens created from filter values + */ +export function prepareTokens(filters = {}) { + return Object.keys(filters).reduce((memo, key) => { + const value = filters[key]; + if (!value) { + return memo; + } + if (Array.isArray(value)) { + return [...memo, ...value.map(filterValue => createToken(key, filterValue))]; + } + + return [...memo, createToken(key, value)]; + }, []); +} + +export function processFilters(filters) { + return filters.reduce((acc, token) => { + const { type, value } = token; + const { operator } = value; + const tokenValue = value.data; + + if (!acc[type]) { + acc[type] = []; + } + + acc[type].push({ value: tokenValue, operator }); + return acc; + }, {}); +} + +/** + * This function takes a filter object and maps it into a query object. Example filter: + * { myFilterName: { value: 'foo', operator: '=' } } + * gets translated into: + * { myFilterName: 'foo', 'not[myFilterName]': null } + * @param {Object} filters + * @param {Object.myFilterName} a single filter value or an array of filters + * @return {Object} query object with both filter name and not-name with values + */ +export function filterToQueryObject(filters = {}) { + return Object.keys(filters).reduce((memo, key) => { + const filter = filters[key]; + + 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); + } else { + selected = filter?.operator === '=' ? filter.value : null; + unselected = filter?.operator === '!=' ? filter.value : null; + } + + if (isEmpty(selected)) { + selected = null; + } + if (isEmpty(unselected)) { + unselected = null; + } + + return { ...memo, [key]: selected, [`not[${key}]`]: unselected }; + }, {}); +} + +/** + * Extracts filter name from url name, e.g. `not[my_filter]` => `my_filter` + * 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.operator} `=` or `!=` + */ +function extractNameAndOperator(filterName) { + // eslint-disable-next-line @gitlab/require-i18n-strings + if (filterName.startsWith('not[') && filterName.endsWith(']')) { + return { filterName: filterName.slice(4, -1), operator: '!=' }; + } + + return { filterName, operator: '=' }; +} + +/** + * 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` + * @return {Object} filter object with filter names and their values + */ +export function urlQueryToFilter(query = '') { + const filters = queryToObject(query, { gatherArrays: true }); + return Object.keys(filters).reduce((memo, key) => { + const value = filters[key]; + if (!value) { + return memo; + } + const { filterName, operator } = extractNameAndOperator(key); + let previousValues = []; + if (Array.isArray(memo[filterName])) { + previousValues = memo[filterName]; + } + if (Array.isArray(value)) { + const newAdditions = value.filter(Boolean).map(item => ({ value: item, operator })); + return { ...memo, [filterName]: [...previousValues, ...newAdditions] }; + } + + return { ...memo, [filterName]: { value, operator } }; + }, {}); +} 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 969e914ef0c..ee0e00b0f5d 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 @@ -3,7 +3,7 @@ import { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDeprecatedDropdownDivider, + GlDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; @@ -11,15 +11,14 @@ import { debounce } from 'lodash'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; -import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; export default { - anyAuthor: ANY_AUTHOR, components: { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDeprecatedDropdownDivider, + GlDropdownDivider, GlLoadingIcon, }, props: { @@ -35,6 +34,7 @@ export default { data() { return { authors: this.config.initialAuthors || [], + defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], loading: true, }; }, @@ -99,10 +99,14 @@ export default { <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> </template> <template #suggestions> - <gl-filtered-search-suggestion :value="$options.anyAuthor"> - {{ __('Any') }} + <gl-filtered-search-suggestion + v-for="author in defaultAuthors" + :key="author.value" + :value="author.value" + > + {{ author.text }} </gl-filtered-search-suggestion> - <gl-deprecated-dropdown-divider /> + <gl-dropdown-divider v-if="defaultAuthors.length" /> <gl-loading-icon v-if="loading" /> <template v-else> <gl-filtered-search-suggestion diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue new file mode 100644 index 00000000000..c18bdfc5c20 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -0,0 +1,115 @@ +<script> +import { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +import { DEBOUNCE_DELAY } from '../constants'; + +export default { + components: { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + branches: this.config.initialBranches || [], + defaultBranches: this.config.defaultBranches || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeBranch() { + return this.branches.find(branch => branch.name.toLowerCase() === this.currentValue); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.branches.length) { + this.fetchBranchBySearchTerm(this.value.data); + } + }, + }, + }, + methods: { + fetchBranchBySearchTerm(searchTerm) { + this.loading = true; + this.config + .fetchBranches(searchTerm) + .then(({ data }) => { + this.branches = data; + }) + .catch(() => createFlash({ message: __('There was a problem fetching branches.') })) + .finally(() => { + this.loading = false; + }); + }, + searchBranches: debounce(function debouncedSearch({ data }) { + this.fetchBranchBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchBranches" + > + <template #view-token="{ inputValue }"> + <gl-token variant="search-value">{{ + activeBranch ? activeBranch.name : inputValue + }}</gl-token> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="branch in defaultBranches" + :key="branch.value" + :value="branch.value" + > + {{ branch.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultBranches.length" /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="branch in branches" + :key="branch.id" + :value="branch.name" + > + <div class="gl-display-flex"> + <span class="gl-display-inline-block gl-mr-3 gl-p-3"></span> + <div>{{ branch.name }}</div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> 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 726a1c49993..7a9c5c277eb 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 @@ -3,7 +3,7 @@ import { GlToken, GlFilteredSearchToken, GlFilteredSearchSuggestion, - GlNewDropdownDivider as GlDropdownDivider, + GlDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; @@ -14,10 +14,9 @@ import { __ } from '~/locale'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { stripQuotes } from '../filtered_search_utils'; -import { NO_LABEL, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABELS, DEBOUNCE_DELAY } from '../constants'; export default { - noLabel: NO_LABEL, components: { GlToken, GlFilteredSearchToken, @@ -38,6 +37,7 @@ export default { data() { return { labels: this.config.initialLabels || [], + defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, loading: true, }; }, @@ -105,10 +105,14 @@ export default { > </template> <template #suggestions> - <gl-filtered-search-suggestion :value="$options.noLabel">{{ - __('No label') - }}</gl-filtered-search-suggestion> - <gl-dropdown-divider /> + <gl-filtered-search-suggestion + v-for="label in defaultLabels" + :key="label.value" + :value="label.value" + > + {{ label.text }} + </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="label.title"> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index cf1ac4e718b..89952623d0d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, - GlNewDropdownDivider as GlDropdownDivider, + GlDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; @@ -11,10 +11,9 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import { stripQuotes } from '../filtered_search_utils'; -import { defaultMilestones, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants'; export default { - defaultMilestones, components: { GlFilteredSearchToken, GlFilteredSearchSuggestion, @@ -34,6 +33,7 @@ export default { data() { return { milestones: this.config.initialMilestones || [], + defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES, loading: true, }; }, @@ -89,12 +89,13 @@ export default { </template> <template #suggestions> <gl-filtered-search-suggestion - v-for="milestone in $options.defaultMilestones" + v-for="milestone in defaultMilestones" :key="milestone.value" :value="milestone.value" - >{{ milestone.text }}</gl-filtered-search-suggestion > - <gl-dropdown-divider /> + {{ milestone.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultMilestones.length" /> <gl-loading-icon v-if="loading" /> <template v-else> <gl-filtered-search-suggestion |