diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/filtered_search_bar')
8 files changed, 460 insertions, 71 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 3d8afd162cb..2cb1b6a195f 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,24 +1,46 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { __ } from '~/locale'; -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 DEBOUNCE_DELAY = 200; +export const MAX_RECENT_TOKENS_SIZE = 3; -export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL]; +export const FILTER_NONE = 'None'; +export const FILTER_ANY = 'Any'; +export const FILTER_CURRENT = 'Current'; -export const DEBOUNCE_DELAY = 200; +export const OPERATOR_IS = '='; +export const OPERATOR_IS_TEXT = __('is'); +export const OPERATOR_IS_NOT = '!='; + +export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; + +export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) }; +export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) }; +export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + +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_MILESTONES = DEFAULT_NONE_ANY.concat([ + { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings + { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings +]); export const SortDirection = { descending: 'descending', ascending: 'ascending', }; -export const DEFAULT_MILESTONES = [ - DEFAULT_LABEL_NONE, - DEFAULT_LABEL_ANY, - { value: 'Upcoming', text: __('Upcoming') }, - { value: 'Started', text: __('Started') }, -]; +export const FILTERED_SEARCH_TERM = 'filtered-search-term'; -/* eslint-enable @gitlab/require-i18n-strings */ +export const TOKEN_TITLE_AUTHOR = __('Author'); +export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); +export const TOKEN_TITLE_MILESTONE = __('Milestone'); +export const TOKEN_TITLE_LABEL = __('Label'); +export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); +export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); +export const TOKEN_TITLE_ITERATION = __('Iteration'); +export const TOKEN_TITLE_EPIC = __('Epic'); +export const TOKEN_TITLE_WEIGHT = __('Weight'); 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 107ced550c1..3e7feb91b27 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 @@ -93,9 +93,9 @@ export default { sortBy.sortDirection.descending === this.initialSortBy, ) .pop(); - selectedSortDirection = this.initialSortBy.endsWith('_desc') - ? SortDirection.descending - : SortDirection.ascending; + selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find( + (key) => selectedSortOption.sortDirection[key] === this.initialSortBy, + ); } return { @@ -324,7 +324,9 @@ export default { class="gl-align-self-center" :checked="checkboxChecked" @input="$emit('checked-input', $event)" - /> + > + <span class="gl-sr-only">{{ __('Select all') }}</span> + </gl-form-checkbox> <gl-filtered-search ref="filteredSearchInput" v-model="filterValue" 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 a15cf220ee5..e5c8d29e09b 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,6 +1,9 @@ -import { isEmpty } from 'lodash'; +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'; + /** * Strips enclosing quotations from a string if it has one. * @@ -162,3 +165,38 @@ export function urlQueryToFilter(query = '') { return { ...memo, [filterName]: { value, operator } }; }, {}); } + +/** + * Returns array of token values from localStorage + * based on provided recentTokenValuesStorageKey + * + * @param {String} recentTokenValuesStorageKey + * @returns + */ +export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) { + let recentlyUsedTokenValues = []; + if (AccessorUtilities.isLocalStorageAccessSafe()) { + recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || []; + } + return recentlyUsedTokenValues; +} + +/** + * Sets provided token value to recently used array + * within localStorage for provided recentTokenValuesStorageKey + * + * @param {String} recentTokenValuesStorageKey + * @param {Object} tokenValue + */ +export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) { + const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey); + + recentlyUsedTokenValues.splice(0, 0, { ...tokenValue }); + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem( + recentTokenValuesStorageKey, + JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)), + ); + } +} 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 new file mode 100644 index 00000000000..6ebc5431012 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -0,0 +1,167 @@ +<script> +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, + GlLoadingIcon, +} from '@gitlab/ui'; + +import { DEBOUNCE_DELAY } from '../constants'; +import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, + GlLoadingIcon, + }, + props: { + tokenConfig: { + type: Object, + required: true, + }, + tokenValue: { + type: Object, + required: true, + }, + tokenActive: { + type: Boolean, + required: true, + }, + tokensListLoading: { + type: Boolean, + required: true, + }, + tokenValues: { + type: Array, + required: true, + }, + fnActiveTokenValue: { + type: Function, + required: true, + }, + defaultTokenValues: { + type: Array, + required: false, + default: () => [], + }, + recentTokenValuesStorageKey: { + type: String, + required: false, + default: '', + }, + valueIdentifier: { + type: String, + required: false, + default: 'id', + }, + fnCurrentTokenValue: { + type: Function, + required: false, + default: null, + }, + }, + data() { + return { + searchKey: '', + recentTokenValues: this.recentTokenValuesStorageKey + ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey) + : [], + loading: false, + }; + }, + computed: { + isRecentTokenValuesEnabled() { + return Boolean(this.recentTokenValuesStorageKey); + }, + recentTokenIds() { + return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name); + }, + currentTokenValue() { + if (this.fnCurrentTokenValue) { + return this.fnCurrentTokenValue(this.tokenValue.data); + } + return this.tokenValue.data.toLowerCase(); + }, + activeTokenValue() { + return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue); + }, + /** + * Return all the tokenValues when searchKey is present + * otherwise return only the tokenValues which aren't + * present in "Recently used" + */ + availableTokenValues() { + return this.searchKey + ? this.tokenValues + : this.tokenValues.filter( + (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]), + ); + }, + }, + watch: { + tokenActive: { + immediate: true, + handler(newValue) { + if (!newValue && !this.tokenValues.length) { + this.$emit('fetch-token-values', this.tokenValue.data); + } + }, + }, + }, + methods: { + handleInput({ data }) { + this.searchKey = data; + setTimeout(() => { + if (!this.tokensListLoading) this.$emit('fetch-token-values', data); + }, DEBOUNCE_DELAY); + }, + handleTokenValueSelected(activeTokenValue) { + if (this.isRecentTokenValuesEnabled) { + setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue); + } + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="tokenConfig" + v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }" + v-on="this.$parent.$listeners" + @input="handleInput" + @select="handleTokenValueSelected(activeTokenValue)" + > + <template #view-token="viewTokenProps"> + <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + </template> + <template #view="viewTokenProps"> + <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + </template> + <template #suggestions> + <template v-if="defaultTokenValues.length"> + <gl-filtered-search-suggestion + v-for="token in defaultTokenValues" + :key="token.value" + :value="token.value" + > + {{ token.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider /> + </template> + <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey"> + <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header> + <slot name="token-values-list" :token-values="recentTokenValues"></slot> + <gl-dropdown-divider /> + </template> + <gl-loading-icon v-if="tokensListLoading" /> + <template v-else> + <slot name="token-values-list" :token-values="availableTokenValues"></slot> + </template> + </template> + </gl-filtered-search-token> +</template> 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 98190d716c9..f2f4787d80b 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 @@ -10,7 +10,7 @@ import { debounce } from 'lodash'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { @@ -33,7 +33,7 @@ export default { data() { return { emojis: this.config.initialEmojis || [], - defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY], + defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY, loading: true, }; }, @@ -47,6 +47,16 @@ export default { ); }, }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.emojis.length) { + this.fetchEmojiBySearchTerm(this.value.data); + } + }, + }, + }, methods: { fetchEmojiBySearchTerm(searchTerm) { this.loading = true; 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 101c7150c55..1450807b11d 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 @@ -1,15 +1,18 @@ <script> -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, +} from '@gitlab/ui'; import { debounce } from 'lodash'; - import createFlash from '~/flash'; -import { isNumeric } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; export default { components: { + GlDropdownDivider, GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon, @@ -32,29 +35,16 @@ export default { }, computed: { currentValue() { - /* - * When the URL contains the epic_iid, we'd get: '123' - */ - if (isNumeric(this.value.data)) { - return parseInt(this.value.data, 10); - } - - /* - * When the token is added in current session it'd be: 'Foo::&123' - */ - const id = this.value.data.split('::&')[1]; - - if (id) { - return parseInt(id, 10); - } - - return this.value.data; + return Number(this.value.data); + }, + defaultEpics() { + return this.config.defaultEpics || DEFAULT_NONE_ANY; + }, + idProperty() { + return this.config.idProperty || 'id'; }, activeEpic() { - const currentValueIsString = typeof this.currentValue === 'string'; - return this.epics.find( - (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue, - ); + return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); }, }, watch: { @@ -72,20 +62,8 @@ export default { this.loading = true; this.config .fetchEpics(searchTerm) - .then(({ data }) => { - this.epics = data; - }) - .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) - .finally(() => { - this.loading = false; - }); - }, - fetchSingleEpic(iid) { - this.loading = true; - this.config - .fetchSingleEpic(iid) - .then(({ data }) => { - this.epics = [data]; + .then((response) => { + this.epics = Array.isArray(response) ? response : response.data; }) .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) .finally(() => { @@ -93,17 +71,13 @@ export default { }); }, searchEpics: debounce(function debouncedSearch({ data }) { - if (isNumeric(data)) { - return this.fetchSingleEpic(data); - } - return this.fetchEpicsBySearchTerm(data); + this.fetchEpicsBySearchTerm(data); }, DEBOUNCE_DELAY), - getEpicValue(epic) { - return `${epic.title}::&${epic.iid}`; + getEpicDisplayText(epic) { + return `${epic.title}::&${epic[this.idProperty]}`; }, }, - stripQuotes, }; </script> @@ -115,17 +89,25 @@ export default { @input="searchEpics" > <template #view="{ inputValue }"> - <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span> + {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }} </template> <template #suggestions> + <gl-filtered-search-suggestion + v-for="epic in defaultEpics" + :key="epic.value" + :value="epic.value" + > + {{ epic.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultEpics.length" /> <gl-loading-icon v-if="loading" /> <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" - :key="epic.id" - :value="getEpicValue(epic)" + :key="epic[idProperty]" + :value="String(epic[idProperty])" > - <div>{{ epic.title }}</div> + {{ epic.title }} </gl-filtered-search-suggestion> </template> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue new file mode 100644 index 00000000000..7b6a590279a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -0,0 +1,110 @@ +<script> +import { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants'; + +export default { + components: { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + iterations: this.config.initialIterations || [], + defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS, + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data; + }, + activeIteration() { + return this.iterations.find((iteration) => iteration.title === this.currentValue); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.iterations.length) { + this.fetchIterationBySearchTerm(this.currentValue); + } + }, + }, + }, + methods: { + fetchIterationBySearchTerm(searchTerm) { + const fetchPromise = this.config.fetchPath + ? this.config.fetchIterations(this.config.fetchPath, searchTerm) + : this.config.fetchIterations(searchTerm); + + this.loading = true; + + fetchPromise + .then((response) => { + this.iterations = Array.isArray(response) ? response : response.data; + }) + .catch(() => createFlash({ message: __('There was a problem fetching iterations.') })) + .finally(() => { + this.loading = false; + }); + }, + searchIterations: debounce(function debouncedSearch({ data }) { + this.fetchIterationBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchIterations" + > + <template #view="{ inputValue }"> + {{ activeIteration ? activeIteration.title : inputValue }} + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="iteration in defaultIterations" + :key="iteration.value" + :value="iteration.value" + > + {{ iteration.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultIterations.length" /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="iteration in iterations" + :key="iteration.title" + :value="iteration.title" + > + {{ iteration.title }} + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue new file mode 100644 index 00000000000..72116f0e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui'; +import { DEFAULT_NONE_ANY } from '../constants'; + +export default { + baseWeights: ['0', '1', '2', '3', '4', '5'], + components: { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + weights: this.$options.baseWeights, + defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY, + }; + }, + methods: { + updateWeights({ data }) { + const weight = parseInt(data, 10); + this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)]; + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="updateWeights" + > + <template #suggestions> + <gl-filtered-search-suggestion + v-for="weight in defaultWeights" + :key="weight.value" + :value="weight.value" + > + {{ weight.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultWeights.length" /> + <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight"> + {{ weight }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> |