2020-06-02 15:08:24 +00:00
|
|
|
<script>
|
|
|
|
import {
|
|
|
|
GlFilteredSearch,
|
|
|
|
GlButtonGroup,
|
|
|
|
GlButton,
|
2020-09-14 12:09:34 +00:00
|
|
|
GlDropdown,
|
2020-09-14 18:09:48 +00:00
|
|
|
GlDropdownItem,
|
2020-11-05 21:08:51 +00:00
|
|
|
GlFormCheckbox,
|
2020-06-02 15:08:24 +00:00
|
|
|
GlTooltipDirective,
|
|
|
|
} from '@gitlab/ui';
|
|
|
|
|
2020-08-17 21:09:56 +00:00
|
|
|
import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
|
2020-06-02 15:08:24 +00:00
|
|
|
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
|
2021-02-14 18:09:20 +00:00
|
|
|
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
|
2022-09-28 12:07:50 +00:00
|
|
|
import { createAlert } from '~/flash';
|
2021-02-14 18:09:20 +00:00
|
|
|
import { __ } from '~/locale';
|
2020-06-02 15:08:24 +00:00
|
|
|
|
|
|
|
import { SortDirection } from './constants';
|
2021-07-21 12:09:35 +00:00
|
|
|
import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils';
|
2020-06-02 15:08:24 +00:00
|
|
|
|
|
|
|
export default {
|
|
|
|
components: {
|
|
|
|
GlFilteredSearch,
|
|
|
|
GlButtonGroup,
|
|
|
|
GlButton,
|
|
|
|
GlDropdown,
|
|
|
|
GlDropdownItem,
|
2020-11-05 21:08:51 +00:00
|
|
|
GlFormCheckbox,
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
|
|
|
directives: {
|
|
|
|
GlTooltip: GlTooltipDirective,
|
|
|
|
},
|
|
|
|
props: {
|
|
|
|
namespace: {
|
|
|
|
type: String,
|
|
|
|
required: true,
|
|
|
|
},
|
|
|
|
recentSearchesStorageKey: {
|
|
|
|
type: String,
|
|
|
|
required: false,
|
|
|
|
default: '',
|
|
|
|
},
|
|
|
|
tokens: {
|
|
|
|
type: Array,
|
|
|
|
required: true,
|
|
|
|
},
|
|
|
|
sortOptions: {
|
|
|
|
type: Array,
|
2020-08-10 18:09:54 +00:00
|
|
|
default: () => [],
|
|
|
|
required: false,
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
|
|
|
initialFilterValue: {
|
|
|
|
type: Array,
|
|
|
|
required: false,
|
|
|
|
default: () => [],
|
|
|
|
},
|
|
|
|
initialSortBy: {
|
|
|
|
type: String,
|
|
|
|
required: false,
|
|
|
|
default: '',
|
2021-03-17 21:11:29 +00:00
|
|
|
validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
2020-11-05 21:08:51 +00:00
|
|
|
showCheckbox: {
|
|
|
|
type: Boolean,
|
|
|
|
required: false,
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
checkboxChecked: {
|
|
|
|
type: Boolean,
|
|
|
|
required: false,
|
|
|
|
default: false,
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
searchInputPlaceholder: {
|
|
|
|
type: String,
|
|
|
|
required: true,
|
|
|
|
},
|
2020-11-13 12:09:03 +00:00
|
|
|
suggestionsListClass: {
|
|
|
|
type: String,
|
|
|
|
required: false,
|
|
|
|
default: '',
|
|
|
|
},
|
2022-04-19 21:09:48 +00:00
|
|
|
searchButtonAttributes: {
|
|
|
|
type: Object,
|
|
|
|
required: false,
|
|
|
|
default: () => ({}),
|
|
|
|
},
|
|
|
|
searchInputAttributes: {
|
|
|
|
type: Object,
|
|
|
|
required: false,
|
|
|
|
default: () => ({}),
|
|
|
|
},
|
2022-04-29 09:09:48 +00:00
|
|
|
syncFilterAndSort: {
|
|
|
|
type: Boolean,
|
|
|
|
required: false,
|
|
|
|
default: false,
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
|
|
|
data() {
|
|
|
|
return {
|
|
|
|
initialRender: true,
|
|
|
|
recentSearchesPromise: null,
|
2020-08-10 18:09:54 +00:00
|
|
|
recentSearches: [],
|
2020-06-02 15:08:24 +00:00
|
|
|
filterValue: this.initialFilterValue,
|
2022-04-29 09:09:48 +00:00
|
|
|
selectedSortOption: this.sortOptions[0],
|
|
|
|
selectedSortDirection: SortDirection.descending,
|
2020-06-02 15:08:24 +00:00
|
|
|
};
|
|
|
|
},
|
|
|
|
computed: {
|
|
|
|
tokenSymbols() {
|
|
|
|
return this.tokens.reduce(
|
|
|
|
(tokenSymbols, token) => ({
|
|
|
|
...tokenSymbols,
|
|
|
|
[token.type]: token.symbol,
|
|
|
|
}),
|
|
|
|
{},
|
|
|
|
);
|
|
|
|
},
|
2020-07-14 12:09:14 +00:00
|
|
|
tokenTitles() {
|
|
|
|
return this.tokens.reduce(
|
|
|
|
(tokenSymbols, token) => ({
|
|
|
|
...tokenSymbols,
|
|
|
|
[token.type]: token.title,
|
|
|
|
}),
|
|
|
|
{},
|
|
|
|
);
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
sortDirectionIcon() {
|
|
|
|
return this.selectedSortDirection === SortDirection.ascending
|
|
|
|
? 'sort-lowest'
|
|
|
|
: 'sort-highest';
|
|
|
|
},
|
|
|
|
sortDirectionTooltip() {
|
|
|
|
return this.selectedSortDirection === SortDirection.ascending
|
|
|
|
? __('Sort direction: Ascending')
|
|
|
|
: __('Sort direction: Descending');
|
|
|
|
},
|
2020-09-03 21:08:18 +00:00
|
|
|
/**
|
|
|
|
* This prop fixes a behaviour affecting GlFilteredSearch
|
|
|
|
* where selecting duplicate token values leads to history
|
|
|
|
* dropdown also showing that selection.
|
|
|
|
*/
|
2020-08-10 18:09:54 +00:00
|
|
|
filteredRecentSearches() {
|
2020-09-03 21:08:18 +00:00
|
|
|
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;
|
2020-08-10 18:09:54 +00:00
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
2022-04-29 09:09:48 +00:00
|
|
|
watch: {
|
|
|
|
initialFilterValue(newValue) {
|
|
|
|
if (this.syncFilterAndSort) {
|
|
|
|
this.filterValue = newValue;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
initialSortBy(newValue) {
|
|
|
|
if (this.syncFilterAndSort) {
|
|
|
|
this.updateSelectedSortValues(newValue);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
created() {
|
2022-04-29 09:09:48 +00:00
|
|
|
this.updateSelectedSortValues(this.initialSortBy);
|
2020-06-02 15:08:24 +00:00
|
|
|
if (this.recentSearchesStorageKey) this.setupRecentSearch();
|
|
|
|
},
|
|
|
|
methods: {
|
|
|
|
/**
|
|
|
|
* Initialize service and store instances for
|
|
|
|
* getting Recent Search functional.
|
|
|
|
*/
|
|
|
|
setupRecentSearch() {
|
|
|
|
this.recentSearchesService = new RecentSearchesService(
|
|
|
|
`${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`,
|
|
|
|
);
|
|
|
|
|
|
|
|
this.recentSearchesStore = new RecentSearchesStore({
|
|
|
|
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
|
2020-12-23 21:10:24 +00:00
|
|
|
allowedKeys: this.tokens.map((token) => token.type),
|
2020-06-02 15:08:24 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
this.recentSearchesPromise = this.recentSearchesService
|
|
|
|
.fetch()
|
2020-12-23 21:10:24 +00:00
|
|
|
.catch((error) => {
|
2020-06-02 15:08:24 +00:00
|
|
|
if (error.name === 'RecentSearchesServiceError') return undefined;
|
|
|
|
|
2022-09-28 12:07:50 +00:00
|
|
|
createAlert({
|
2021-06-02 21:10:00 +00:00
|
|
|
message: __('An error occurred while parsing recent searches'),
|
|
|
|
});
|
2020-06-02 15:08:24 +00:00
|
|
|
|
|
|
|
// Gracefully fail to empty array
|
|
|
|
return [];
|
|
|
|
})
|
2020-12-23 21:10:24 +00:00
|
|
|
.then((searches) => {
|
2020-06-02 15:08:24 +00:00
|
|
|
if (!searches) return;
|
|
|
|
|
|
|
|
// Put any searches that may have come in before
|
|
|
|
// we fetched the saved searches ahead of the already saved ones
|
2021-07-21 12:09:35 +00:00
|
|
|
let resultantSearches = this.recentSearchesStore.setRecentSearches(
|
2020-06-02 15:08:24 +00:00
|
|
|
this.recentSearchesStore.state.recentSearches.concat(searches),
|
|
|
|
);
|
2021-07-21 12:09:35 +00:00
|
|
|
// If visited URL has search params, add them to recent search store
|
|
|
|
if (filterEmptySearchTerm(this.filterValue).length) {
|
|
|
|
resultantSearches = this.recentSearchesStore.addRecentSearch(this.filterValue);
|
|
|
|
}
|
|
|
|
|
2020-06-02 15:08:24 +00:00
|
|
|
this.recentSearchesService.save(resultantSearches);
|
2020-07-15 12:09:26 +00:00
|
|
|
this.recentSearches = resultantSearches;
|
2020-06-02 15:08:24 +00:00
|
|
|
});
|
|
|
|
},
|
2020-08-03 15:09:44 +00:00
|
|
|
/**
|
|
|
|
* 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();
|
|
|
|
}
|
|
|
|
},
|
2020-08-12 15:10:02 +00:00
|
|
|
/**
|
|
|
|
* This method removes quotes enclosure from filter values which are
|
|
|
|
* done by `GlFilteredSearch` internally when filter value contains
|
|
|
|
* spaces.
|
|
|
|
*/
|
|
|
|
removeQuotesEnclosure(filters = []) {
|
2020-12-23 21:10:24 +00:00
|
|
|
return filters.map((filter) => {
|
2020-08-12 15:10:02 +00:00
|
|
|
if (typeof filter === 'object') {
|
|
|
|
const valueString = filter.value.data;
|
|
|
|
return {
|
|
|
|
...filter,
|
|
|
|
value: {
|
2021-01-14 12:10:54 +00:00
|
|
|
data: typeof valueString === 'string' ? stripQuotes(valueString) : valueString,
|
2020-08-12 15:10:02 +00:00
|
|
|
operator: filter.value.operator,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return filter;
|
|
|
|
});
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
handleSortOptionClick(sortBy) {
|
|
|
|
this.selectedSortOption = sortBy;
|
|
|
|
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
|
|
|
|
},
|
|
|
|
handleSortDirectionClick() {
|
|
|
|
this.selectedSortDirection =
|
|
|
|
this.selectedSortDirection === SortDirection.ascending
|
|
|
|
? SortDirection.descending
|
|
|
|
: SortDirection.ascending;
|
|
|
|
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
|
|
|
|
},
|
2020-07-15 12:09:26 +00:00
|
|
|
handleHistoryItemSelected(filters) {
|
2020-08-12 15:10:02 +00:00
|
|
|
this.$emit('onFilter', this.removeQuotesEnclosure(filters));
|
2020-07-15 12:09:26 +00:00
|
|
|
},
|
2020-07-14 12:09:14 +00:00
|
|
|
handleClearHistory() {
|
|
|
|
const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
|
|
|
|
this.recentSearchesService.save(resultantSearches);
|
2020-07-15 12:09:26 +00:00
|
|
|
this.recentSearches = [];
|
2020-07-14 12:09:14 +00:00
|
|
|
},
|
2020-09-03 21:08:18 +00:00
|
|
|
handleFilterSubmit() {
|
|
|
|
const filterTokens = uniqueTokens(this.filterValue);
|
|
|
|
this.filterValue = filterTokens;
|
2020-12-11 21:10:13 +00:00
|
|
|
|
2020-06-02 15:08:24 +00:00
|
|
|
if (this.recentSearchesStorageKey) {
|
|
|
|
this.recentSearchesPromise
|
|
|
|
.then(() => {
|
2020-09-03 21:08:18 +00:00
|
|
|
if (filterTokens.length) {
|
|
|
|
const resultantSearches = this.recentSearchesStore.addRecentSearch(filterTokens);
|
2020-06-02 15:08:24 +00:00
|
|
|
this.recentSearchesService.save(resultantSearches);
|
2020-07-15 12:09:26 +00:00
|
|
|
this.recentSearches = resultantSearches;
|
2020-06-02 15:08:24 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
.catch(() => {
|
|
|
|
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
|
|
|
|
});
|
|
|
|
}
|
2020-08-03 15:09:44 +00:00
|
|
|
this.blurSearchInput();
|
2020-09-03 21:08:18 +00:00
|
|
|
this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens));
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
2020-12-11 21:10:13 +00:00
|
|
|
historyTokenOptionTitle(historyToken) {
|
|
|
|
const tokenOption = this.tokens
|
2020-12-23 21:10:24 +00:00
|
|
|
.find((token) => token.type === historyToken.type)
|
|
|
|
?.options?.find((option) => option.value === historyToken.value.data);
|
2020-12-11 21:10:13 +00:00
|
|
|
|
|
|
|
if (!tokenOption?.title) {
|
|
|
|
return historyToken.value.data;
|
|
|
|
}
|
|
|
|
|
|
|
|
return tokenOption.title;
|
|
|
|
},
|
2022-04-13 18:08:33 +00:00
|
|
|
onClear() {
|
|
|
|
const cleared = true;
|
|
|
|
this.$emit('onFilter', [], cleared);
|
|
|
|
},
|
2022-04-29 09:09:48 +00:00
|
|
|
updateSelectedSortValues(sort) {
|
|
|
|
if (!sort) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.selectedSortOption = this.sortOptions.find(
|
|
|
|
(sortBy) =>
|
|
|
|
sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort,
|
|
|
|
);
|
2022-11-03 03:10:45 +00:00
|
|
|
this.selectedSortDirection = Object.keys(this.selectedSortOption?.sortDirection || {}).find(
|
2022-04-29 09:09:48 +00:00
|
|
|
(key) => this.selectedSortOption.sortDirection[key] === sort,
|
|
|
|
);
|
|
|
|
},
|
2020-06-02 15:08:24 +00:00
|
|
|
},
|
|
|
|
};
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
2022-05-03 18:07:53 +00:00
|
|
|
<div class="vue-filtered-search-bar-container gl-md-display-flex">
|
2020-11-05 21:08:51 +00:00
|
|
|
<gl-form-checkbox
|
|
|
|
v-if="showCheckbox"
|
|
|
|
class="gl-align-self-center"
|
|
|
|
:checked="checkboxChecked"
|
2022-01-08 00:14:32 +00:00
|
|
|
@change="$emit('checked-input', $event)"
|
2021-05-19 03:10:31 +00:00
|
|
|
>
|
|
|
|
<span class="gl-sr-only">{{ __('Select all') }}</span>
|
|
|
|
</gl-form-checkbox>
|
2020-06-02 15:08:24 +00:00
|
|
|
<gl-filtered-search
|
2020-08-03 15:09:44 +00:00
|
|
|
ref="filteredSearchInput"
|
2020-06-02 15:08:24 +00:00
|
|
|
v-model="filterValue"
|
|
|
|
:placeholder="searchInputPlaceholder"
|
|
|
|
:available-tokens="tokens"
|
2020-08-10 18:09:54 +00:00
|
|
|
:history-items="filteredRecentSearches"
|
2020-11-13 12:09:03 +00:00
|
|
|
:suggestions-list-class="suggestionsListClass"
|
2022-04-19 21:09:48 +00:00
|
|
|
:search-button-attributes="searchButtonAttributes"
|
|
|
|
:search-input-attributes="searchInputAttributes"
|
2022-10-13 18:10:20 +00:00
|
|
|
:recent-searches-header="__('Recent searches')"
|
|
|
|
:clear-button-title="__('Clear')"
|
|
|
|
:close-button-title="__('Close')"
|
|
|
|
:clear-recent-searches-text="__('Clear recent searches')"
|
|
|
|
:no-recent-searches-text="__(`You don't have any recent searches`)"
|
2020-06-02 15:08:24 +00:00
|
|
|
class="flex-grow-1"
|
2020-07-15 12:09:26 +00:00
|
|
|
@history-item-selected="handleHistoryItemSelected"
|
2022-04-13 18:08:33 +00:00
|
|
|
@clear="onClear"
|
2020-07-14 12:09:14 +00:00
|
|
|
@clear-history="handleClearHistory"
|
2020-06-02 15:08:24 +00:00
|
|
|
@submit="handleFilterSubmit"
|
2020-07-14 12:09:14 +00:00
|
|
|
>
|
|
|
|
<template #history-item="{ historyItem }">
|
2020-07-15 12:09:26 +00:00
|
|
|
<template v-for="(token, index) in historyItem">
|
|
|
|
<span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
|
2020-08-14 06:10:12 +00:00
|
|
|
<span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1">
|
2020-07-14 12:09:14 +00:00
|
|
|
<span v-if="tokenTitles[token.type]"
|
|
|
|
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
|
|
|
|
>
|
2020-12-11 21:10:13 +00:00
|
|
|
<strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong>
|
2020-07-14 12:09:14 +00:00
|
|
|
</span>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
</gl-filtered-search>
|
2020-08-10 18:09:54 +00:00
|
|
|
<gl-button-group v-if="selectedSortOption" class="sort-dropdown-container d-flex">
|
2020-06-08 15:08:20 +00:00
|
|
|
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
|
2020-06-02 15:08:24 +00:00
|
|
|
<gl-dropdown-item
|
|
|
|
v-for="sortBy in sortOptions"
|
|
|
|
:key="sortBy.id"
|
2022-08-26 15:11:58 +00:00
|
|
|
is-check-item
|
2020-06-02 15:08:24 +00:00
|
|
|
:is-checked="sortBy.id === selectedSortOption.id"
|
|
|
|
@click="handleSortOptionClick(sortBy)"
|
|
|
|
>{{ sortBy.title }}</gl-dropdown-item
|
|
|
|
>
|
|
|
|
</gl-dropdown>
|
|
|
|
<gl-button
|
|
|
|
v-gl-tooltip
|
|
|
|
:title="sortDirectionTooltip"
|
2021-03-26 12:09:15 +00:00
|
|
|
:aria-label="sortDirectionTooltip"
|
2020-06-02 15:08:24 +00:00
|
|
|
:icon="sortDirectionIcon"
|
2020-06-08 15:08:20 +00:00
|
|
|
class="flex-shrink-1"
|
2020-06-02 15:08:24 +00:00
|
|
|
@click="handleSortDirectionClick"
|
|
|
|
/>
|
|
|
|
</gl-button-group>
|
|
|
|
</div>
|
|
|
|
</template>
|