summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components/filtered_search_bar
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/filtered_search_bar')
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue62
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue126
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue110
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>