diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/filtered_search_bar')
6 files changed, 312 insertions, 13 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 6665a5754b3..7b3d1d0afd6 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,23 @@ +import { __ } from '~/locale'; + export const ANY_AUTHOR = 'Any'; +export const NO_LABEL = 'No label'; + export const DEBOUNCE_DELAY = 200; export const SortDirection = { descending: 'descending', 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 + { value: 'Upcoming', text: __('Upcoming') }, + // eslint-disable-next-line @gitlab/require-i18n-strings + { value: 'Started', text: __('Started') }, +]; 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 04090213218..ee293d37b66 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 @@ -8,13 +8,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; +import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +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 RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; +import { stripQuotes } from './filtered_search_utils'; import { SortDirection } from './constants'; export default { @@ -44,7 +45,8 @@ export default { }, sortOptions: { type: Array, - required: true, + default: () => [], + required: false, }, initialFilterValue: { type: Array, @@ -63,7 +65,7 @@ export default { }, }, data() { - let selectedSortOption = this.sortOptions[0].sortDirection.descending; + let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; let selectedSortDirection = SortDirection.descending; // Extract correct sortBy value based on initialSortBy @@ -118,6 +120,11 @@ export default { ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, + filteredRecentSearches() { + return this.recentSearchesStorageKey + ? this.recentSearches.filter(item => typeof item !== 'string') + : undefined; + }, }, watch: { /** @@ -184,6 +191,41 @@ export default { this.recentSearches = resultantSearches; }); }, + /** + * When user hits Enter/Return key while typing tokens, we emit `onFilter` + * event immediately so at that time, we don't want to keep tokens dropdown + * visible on UI so this is essentially a hack which allows us to do that + * until `GlFilteredSearch` natively supports this. + * See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546 + */ + blurSearchInput() { + const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector( + '.gl-filtered-search-token-segment-input', + ); + if (searchInputEl) { + searchInputEl.blur(); + } + }, + /** + * This method removes quotes enclosure from filter values which are + * done by `GlFilteredSearch` internally when filter value contains + * spaces. + */ + removeQuotesEnclosure(filters = []) { + return filters.map(filter => { + if (typeof filter === 'object') { + const valueString = filter.value.data; + return { + ...filter, + value: { + data: stripQuotes(valueString), + operator: filter.value.operator, + }, + }; + } + return filter; + }); + }, handleSortOptionClick(sortBy) { this.selectedSortOption = sortBy; this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); @@ -196,7 +238,7 @@ export default { this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, handleHistoryItemSelected(filters) { - this.$emit('onFilter', filters); + this.$emit('onFilter', this.removeQuotesEnclosure(filters)); }, handleClearHistory() { const resultantSearches = this.recentSearchesStore.setRecentSearches([]); @@ -217,7 +259,8 @@ export default { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); } - this.$emit('onFilter', filters); + this.blurSearchInput(); + this.$emit('onFilter', this.removeQuotesEnclosure(filters)); }, }, }; @@ -226,10 +269,11 @@ export default { <template> <div class="vue-filtered-search-bar-container d-md-flex"> <gl-filtered-search + ref="filteredSearchInput" v-model="filterValue" :placeholder="searchInputPlaceholder" :available-tokens="tokens" - :history-items="recentSearches" + :history-items="filteredRecentSearches" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear-history="handleClearHistory" @@ -238,7 +282,7 @@ export default { <template #history-item="{ historyItem }"> <template v-for="(token, index) in historyItem"> <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span> - <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1"> + <span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1"> <span v-if="tokenTitles[token.type]" >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span > @@ -247,7 +291,7 @@ export default { </template> </template> </gl-filtered-search> - <gl-button-group class="sort-dropdown-container d-flex"> + <gl-button-group v-if="selectedSortOption" class="sort-dropdown-container d-flex"> <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> <gl-dropdown-item v-for="sortBy in sortOptions" 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 new file mode 100644 index 00000000000..85f7f746b49 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const stripQuotes = value => { + return value.includes(' ') ? value.slice(1, -1) : value; +}; 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 d50649d2581..969e914ef0c 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,12 +3,12 @@ import { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants'; @@ -19,7 +19,7 @@ export default { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, }, props: { @@ -102,7 +102,7 @@ export default { <gl-filtered-search-suggestion :value="$options.anyAuthor"> {{ __('Any') }} </gl-filtered-search-suggestion> - <gl-dropdown-divider /> + <gl-deprecated-dropdown-divider /> <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/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue new file mode 100644 index 00000000000..726a1c49993 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -0,0 +1,126 @@ +<script> +import { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlNewDropdownDivider as GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; + +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +import { stripQuotes } from '../filtered_search_utils'; +import { NO_LABEL, DEBOUNCE_DELAY } from '../constants'; + +export default { + noLabel: NO_LABEL, + components: { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + labels: this.config.initialLabels || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeLabel() { + return this.labels.find( + label => label.title.toLowerCase() === stripQuotes(this.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: { + fetchLabelBySearchTerm(searchTerm) { + this.loading = true; + this.config + .fetchLabels(searchTerm) + .then(res => { + // We'd want to avoid doing this check but + // labels.json and /groups/:id/labels & /projects/:id/labels + // return response differently. + this.labels = Array.isArray(res) ? res : res.data; + }) + .catch(() => createFlash(__('There was a problem fetching labels.'))) + .finally(() => { + this.loading = false; + }); + }, + searchLabels: debounce(function debouncedSearch({ data }) { + this.fetchLabelBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchLabels" + > + <template #view-token="{ inputValue, cssClasses, listeners }"> + <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners" + >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token + > + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.noLabel">{{ + __('No label') + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title"> + <div class="gl-display-flex"> + <span + :style="{ backgroundColor: label.color }" + class="gl-display-inline-block mr-2 p-2" + ></span> + <div>{{ label.title }}</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/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue new file mode 100644 index 00000000000..cf1ac4e718b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -0,0 +1,110 @@ +<script> +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlNewDropdownDivider as GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +import { stripQuotes } from '../filtered_search_utils'; +import { defaultMilestones, DEBOUNCE_DELAY } from '../constants'; + +export default { + defaultMilestones, + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + milestones: this.config.initialMilestones || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeMilestone() { + return this.milestones.find( + milestone => milestone.title.toLowerCase() === stripQuotes(this.currentValue), + ); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.milestones.length) { + this.fetchMilestoneBySearchTerm(this.value.data); + } + }, + }, + }, + methods: { + fetchMilestoneBySearchTerm(searchTerm = '') { + this.loading = true; + this.config + .fetchMilestones(searchTerm) + .then(({ data }) => { + this.milestones = data; + }) + .catch(() => createFlash(__('There was a problem fetching milestones.'))) + .finally(() => { + this.loading = false; + }); + }, + searchMilestones: debounce(function debouncedSearch({ data }) { + this.fetchMilestoneBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchMilestones" + > + <template #view="{ inputValue }"> + <span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="milestone in $options.defaultMilestones" + :key="milestone.value" + :value="milestone.value" + >{{ milestone.text }}</gl-filtered-search-suggestion + > + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="milestone in milestones" + :key="milestone.id" + :value="milestone.title" + > + <div>{{ milestone.title }}</div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> |