diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-16 12:09:20 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-16 12:09:20 +0300 |
commit | 8ae36d93f1a63874b584f0488fde88c1fee999c4 (patch) | |
tree | f1788ba1a7fb00248ff008f817f6feea89304339 /app/assets/javascripts/search | |
parent | b394e58cc2e52e17e7991e3d7b705aa383c362ee (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/search')
6 files changed, 373 insertions, 5 deletions
diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 2ab5dfb8dea..8928f80d83a 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -2,6 +2,7 @@ import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HR_DEFAULT_CLASSES, TRACKING_ACTION_CLICK, @@ -12,6 +13,8 @@ import { import { confidentialFilterData } from '../constants/confidential_filter_data'; import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; +import { labelFilterData } from './label_filter/data'; +import LabelFilter from './label_filter/index.vue'; import StatusFilter from './status_filter.vue'; export default { @@ -21,7 +24,9 @@ export default { GlLink, StatusFilter, ConfidentialityFilter, + LabelFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), ...mapGetters(['currentScope']), @@ -34,6 +39,12 @@ export default { showStatusFilter() { return Object.values(stateFilterData.scopes).includes(this.currentScope); }, + showLabelFilter() { + return ( + Object.values(labelFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchIssueLabelAggregation + ); + }, hrClasses() { return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; }, @@ -61,7 +72,8 @@ export default { <hr v-if="!useNewNavigation" :class="hrClasses" /> <status-filter v-if="showStatusFilter" class="gl-mb-5" /> <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" /> - <div class="gl-display-flex gl-align-items-center gl-mt-5"> + <label-filter v-if="showLabelFilter" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue new file mode 100644 index 00000000000..74855482b5d --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -0,0 +1,291 @@ +<script> +import { + GlSearchBoxByType, + GlLabel, + GlLoadingIcon, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlAlert, + GlOutsideDirective as Outside, +} from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { uniq } from 'lodash'; +import { rgbFromHex } from '@gitlab/ui/dist/utils/utils'; +import { slugify } from '~/lib/utils/text_utility'; +import { s__, sprintf } from '~/locale'; + +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; + +import { + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_RESULTS_LOADING, +} from '~/vue_shared/global_search/constants'; + +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import LabelDropdownItems from './label_dropdown_items.vue'; + +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +} from './data'; + +import { trackSelectCheckbox, trackOpenDropdown } from './tracking'; + +export default { + name: 'LabelFilter', + directives: { Outside }, + components: { + DropdownKeyboardNavigation, + GlSearchBoxByType, + LabelDropdownItems, + GlLabel, + GlDropdownDivider, + GlDropdownSectionHeader, + GlFormCheckboxGroup, + GlDropdownForm, + GlLoadingIcon, + GlAlert, + }, + data() { + return { + currentFocusIndex: SEARCH_BOX_INDEX, + isFocused: false, + }; + }, + i18n: { + SEARCH_LABELS: s__('GlobalSearch|Search labels'), + DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'), + AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'), + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_RESULTS_LOADING, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + }, + computed: { + ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']), + ...mapGetters([ + 'filteredLabels', + 'filteredUnselectedLabels', + 'filteredAppliedSelectedLabels', + 'appliedSelectedLabels', + 'filteredUnappliedSelectedLabels', + ]), + searchInputDescribeBy() { + if (this.isLoggedIn) { + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; + } + return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + }, + dropdownResultsDescription() { + if (!this.showSearchDropdown) { + return ''; // This allows aria-live to see register an update when the dropdown is shown + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { + count: this.filteredLabels.length, + }); + } + + return this.loading + ? this.$options.i18n.SEARCH_RESULTS_LOADING + : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { + count: this.filteredLabels.length, + }); + }, + currentFocusedOption() { + return this.filteredLabels[this.currentFocusIndex] || null; + }, + currentFocusedId() { + return `${slugify(this.currentFocusedOption?.parent_full_name || 'undefined-name')}_${slugify( + this.currentFocusedOption?.title || 'undefined-title', + )}`; + }, + defaultIndex() { + if (this.showDefaultItems) { + return SEARCH_BOX_INDEX; + } + return FIRST_DROPDOWN_INDEX; + }, + hasSelectedLabels() { + return this.filteredAppliedSelectedLabels.length > 0; + }, + hasUnselectedLabels() { + return this.filteredUnselectedLabels.length > 0; + }, + dividerClasses() { + return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; + }, + labelSearchBox() { + return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]'); + }, + combinedSelectedFilters() { + const appliedSelectedLabelKeys = this.appliedSelectedLabels.map((label) => label.key); + const { labels = [] } = this.query; + + return uniq([...appliedSelectedLabelKeys, ...labels]); + }, + searchLabels: { + get() { + return this.searchLabelString; + }, + set(value) { + this.setLabelFilterSearch({ value }); + }, + }, + selectedFilters: { + get() { + return this.combinedSelectedFilters; + }, + set(value) { + this.setQuery({ key: this.$options.labelFilterData?.filterParam, value }); + + trackSelectCheckbox(value); + }, + }, + }, + async created() { + await this.fetchAllAggregation(); + }, + methods: { + ...mapActions(['fetchAllAggregation', 'setQuery', 'closeLabel', 'setLabelFilterSearch']), + openDropdown() { + this.isFocused = true; + + trackOpenDropdown(); + }, + closeDropdown(event) { + const { target } = event; + + if (this.labelSearchBox !== target) { + this.isFocused = false; + } + }, + onLabelClose(event) { + if (!event?.target?.closest('.gl-label')?.dataset) { + return; + } + + const { key } = event.target.closest('.gl-label').dataset; + this.closeLabel({ key }); + }, + reactiveLabelColor(label) { + const { color, key } = label; + + return this.query?.labels?.some((labelKey) => labelKey === key) + ? color + : `rgba(${rgbFromHex(color)}, 0.3)`; + }, + isLabelClosable(label) { + const { key } = label; + return this.query?.labels?.some((labelKey) => labelKey === key); + }, + }, + FIRST_DROPDOWN_INDEX, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_INPUT_DESCRIPTION, + labelFilterData, +}; +</script> + +<template> + <div class="gl-pb-0 gl-md-pt-0 label-filter gl-relative"> + <h5 + class="gl-my-0" + data-testid="label-filter-title" + :class="{ 'gl-font-sm': useSidebarNavigation }" + > + {{ $options.labelFilterData.header }} + </h5> + <div class="gl-my-5"> + <gl-label + v-for="label in appliedSelectedLabels" + :key="label.key" + class="gl-mr-2 gl-mb-2 gl-bg-gray-10" + :data-key="label.key" + :background-color="reactiveLabelColor(label)" + :title="label.title" + :show-close-button="isLabelClosable(label)" + @close="onLabelClose" + /> + </div> + <gl-search-box-by-type + ref="searchLabelInputBox" + v-model="searchLabels" + role="searchbox" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_LABELS" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + @focusin="openDropdown" + @keydown.esc="closeDropdown" + /> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ + searchInputDescribeBy + }}</span> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ dropdownResultsDescription }} + </span> + <div + v-if="isFocused" + v-outside="closeDropdown" + data-testid="header-search-dropdown-menu" + class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-1" + :class="{ + 'gl-max-w-none!': useSidebarNavigation, + 'gl-min-w-full!': useSidebarNavigation, + 'gl-w-full!': useSidebarNavigation, + }" + > + <div class="header-search-dropdown-content gl-py-2"> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="filteredLabels.length - 1" + :min="$options.FIRST_DROPDOWN_INDEX" + :default-index="defaultIndex" + :enable-cycle="true" + /> + <div v-if="!aggregations.error"> + <gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{ + $options.i18n.DROPDOWN_HEADER + }}</gl-dropdown-section-header> + <gl-dropdown-form> + <gl-form-checkbox-group v-model="selectedFilters"> + <label-dropdown-items + v-if="hasSelectedLabels" + data-testid="selected-lavel-items" + :labels="filteredAppliedSelectedLabels" + /> + <gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" /> + <label-dropdown-items + v-if="hasUnselectedLabels" + data-testid="unselected-lavel-items" + :labels="filteredUnselectedLabels" + /> + </gl-form-checkbox-group> + </gl-dropdown-form> + </div> + <gl-alert v-else :dismissible="false" variant="danger"> + {{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }} + </gl-alert> + <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" /> + </div> + </div> + <hr v-if="!useSidebarNavigation" :class="dividerClasses" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue new file mode 100644 index 00000000000..7a9e6a2e4fc --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue @@ -0,0 +1,43 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + name: 'LabelDropdownItems', + components: { + GlFormCheckbox, + }, + props: { + labels: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <ul class="gl-list-style-none gl-px-0"> + <li + v-for="label in labels" + :id="label.key" + :ref="label.key" + :key="label.key" + :aria-label="label.title" + tabindex="-1" + class="gl-px-5 gl-py-3 label-filter-menu-item" + > + <gl-form-checkbox + class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5" + :value="label.key" + > + <span + data-testid="label-color-indicator" + class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3" + :style="{ 'background-color': label.color }" + ></span> + <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{ + label.title + }}</span></gl-form-checkbox + > + </li> + </ul> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js new file mode 100644 index 00000000000..c38922a559c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js @@ -0,0 +1,21 @@ +import Tracking from '~/tracking'; + +export const TRACKING_CATEGORY = 'Language filters'; +export const TRACKING_LABEL_FILTER = 'Label Key'; + +export const TRACKING_LABEL_DROPDOWN = 'Dropdown'; +export const TRACKING_LABEL_CHECKBOX = 'Label Checkbox'; + +export const TRACKING_ACTION_SELECT = 'search:agreggations:label:select'; +export const TRACKING_ACTION_SHOW = 'search:agreggations:label:show'; + +export const trackSelectCheckbox = (value) => + Tracking.event(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_FILTER, + property: value, + }); + +export const trackOpenDropdown = () => + Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, { + label: TRACKING_LABEL_DROPDOWN, + }); diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index e531abf523b..c10b14bd116 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -2,7 +2,6 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__, sprintf } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; import { convertFiltersData } from '../../utils'; import CheckboxFilter from './checkbox_filter.vue'; @@ -24,7 +23,6 @@ export default { GlAlert, GlForm, }, - mixins: [glFeatureFlagsMixin()], data() { return { showAll: false, diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index d31d2b5ae11..c7cb595f42f 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -41,8 +41,11 @@ export const filteredLabels = (state) => { export const filteredAppliedSelectedLabels = (state) => filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); -export const appliedSelectedLabels = (state) => - labelAggregationBuckets(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); +export const appliedSelectedLabels = (state) => { + return labelAggregationBuckets(state)?.filter((label) => + state?.urlQuery?.labels?.includes(label.key), + ); +}; export const filteredUnappliedSelectedLabels = (state) => filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key)); |