diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 2ab5dfb8deaf744d140fc1b87fbed10da6f5ab74..8928f80d83a56cb9e1f66992fd2540e6fc9b3a32 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 0000000000000000000000000000000000000000..74855482b5d94ebe271b2d5910ad3e7ddcc7bd89 --- /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 0000000000000000000000000000000000000000..7a9e6a2e4fcaf48d29b7d64207645b054b566c52 --- /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 0000000000000000000000000000000000000000..c38922a559ccad53bfce5c86bafea64e57e46f22 --- /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 e531abf523b75b37a7dc88f762cde299c0131540..c10b14bd116b725c7036f18efb894fcf788f2cda 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 d31d2b5ae11d4e3bb7fcc164ef235b7b13088d2b..c7cb595f42fd9f6718ded910397519926acaedfa 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)); diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 2665401c70be95c8b69084f8fb2545c032d6d96c..45aefe48538d644c8458620ece99c0a14ccbdf5b 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -34,6 +34,7 @@ def self.search_rate_limited_endpoints before_action only: :show do update_scope_for_code_search end + rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' diff --git a/ee/app/controllers/ee/search_controller.rb b/ee/app/controllers/ee/search_controller.rb index ace58586049b81ea4d2cfe60894e37af8c41fc9d..11b75f168f3943d2585d2088b2b767a30aebc7fa 100644 --- a/ee/app/controllers/ee/search_controller.rb +++ b/ee/app/controllers/ee/search_controller.rb @@ -38,6 +38,10 @@ def search_rate_limited_endpoints before_action :check_search_rate_limit!, only: search_rate_limited_endpoints + before_action only: :show do + push_frontend_feature_flag(:search_issue_label_aggregation, current_user) + end + after_action :run_index_integrity_worker, only: :show, if: :no_results_for_group_or_project_blobs_advanced_search? end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 64a882092d2df58df11ff853269f5216c750dce5..7f2995d2bb42e309bb81052083160cb2a3785438 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20875,6 +20875,9 @@ msgstr "" msgid "GlobalSearch|Close" msgstr "" +msgid "GlobalSearch|Fetching aggregations error." +msgstr "" + msgid "GlobalSearch|Group" msgstr "" @@ -20893,6 +20896,9 @@ msgstr "" msgid "GlobalSearch|Issues assigned to me" msgstr "" +msgid "GlobalSearch|Label(s)" +msgstr "" + msgid "GlobalSearch|Language" msgstr "" @@ -20941,6 +20947,9 @@ msgstr "" msgid "GlobalSearch|Search for projects, issues, etc." msgstr "" +msgid "GlobalSearch|Search labels" +msgstr "" + msgid "GlobalSearch|Search results are loading" msgstr "" diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 58824f8023d93d570390ad44baa7ebaf68042425..7cf8633d74911dbe61a59d9949d7818a941250d4 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -8,6 +8,7 @@ export const MOCK_QUERY = { group_id: 1, language: ['C', 'JavaScript'], labels: ['60', '37'], + search: '*', }; export const MOCK_GROUP = { diff --git a/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..135b12956b21f922b44bca2fcbda5d59d51fb0b0 --- /dev/null +++ b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js @@ -0,0 +1,57 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { shallowMount } from '@vue/test-utils'; +import { PROCESS_LABELS_DATA } from 'jest/search/mock_data'; +import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue'; + +Vue.use(Vuex); + +describe('LabelDropdownItems', () => { + let wrapper; + + const defaultProps = { + labels: PROCESS_LABELS_DATA, + }; + + const createComponent = (Props = defaultProps) => { + wrapper = shallowMount(LabelDropdownItems, { + propsData: { + ...Props, + }, + }); + }; + + const findAllLabelItems = () => wrapper.findAll('.label-filter-menu-item'); + const findFirstLabelCheckbox = () => findAllLabelItems().at(0).findComponent(GlFormCheckbox); + const findFirstLabelTitle = () => findAllLabelItems().at(0).findComponent('.label-title'); + const findFirstLabelColor = () => + findAllLabelItems().at(0).findComponent('[data-testid="label-color-indicator"]'); + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders items', () => { + expect(findAllLabelItems().exists()).toBe(true); + expect(findAllLabelItems()).toHaveLength(defaultProps.labels.length); + }); + + it('renders items checkbox', () => { + expect(findFirstLabelCheckbox().exists()).toBe(true); + }); + + it('renders label title', () => { + expect(findFirstLabelTitle().exists()).toBe(true); + expect(findFirstLabelTitle().text()).toBe(defaultProps.labels[0].title); + }); + + it('renders label color', () => { + expect(findFirstLabelColor().exists()).toBe(true); + expect(findFirstLabelColor().attributes('style')).toBe( + `background-color: ${defaultProps.labels[0].color};`, + ); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c5df374d4ef7f085728a387b9a4c6948b5e3ed2d --- /dev/null +++ b/spec/frontend/search/sidebar/components/label_filter_spec.js @@ -0,0 +1,322 @@ +import { + GlAlert, + GlLoadingIcon, + GlSearchBoxByType, + GlLabel, + GlDropdownForm, + GlFormCheckboxGroup, + GlDropdownSectionHeader, + GlDropdownDivider, +} from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_QUERY, MOCK_LABEL_AGGREGATIONS } from 'jest/search/mock_data'; +import LabelFilter from '~/search/sidebar/components/label_filter/index.vue'; +import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue'; + +import * as actions from '~/search/store/actions'; +import * as getters from '~/search/store/getters'; +import mutations from '~/search/store/mutations'; +import createState from '~/search/store/state'; + +import { + TRACKING_LABEL_FILTER, + TRACKING_LABEL_DROPDOWN, + TRACKING_LABEL_CHECKBOX, + TRACKING_ACTION_SELECT, + TRACKING_ACTION_SHOW, +} from '~/search/sidebar/components/label_filter/tracking'; + +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; + +import { + RECEIVE_AGGREGATIONS_SUCCESS, + REQUEST_AGGREGATIONS, + RECEIVE_AGGREGATIONS_ERROR, +} from '~/search/store/mutation_types'; + +Vue.use(Vuex); + +const actionSpies = { + fetchAllAggregation: jest.fn(), + setQuery: jest.fn(), + closeLabel: jest.fn(), + setLabelFilterSearch: jest.fn(), +}; + +describe('GlobalSearchSidebarLabelFilter', () => { + let wrapper; + let trackingSpy; + let config; + let store; + + const createComponent = (initialState) => { + config = { + actions: { + ...actions, + fetchAllAggregation: actionSpies.fetchAllAggregation, + closeLabel: actionSpies.closeLabel, + setLabelFilterSearch: actionSpies.setLabelFilterSearch, + setQuery: actionSpies.setQuery, + }, + getters, + mutations, + state: createState({ + query: MOCK_QUERY, + aggregations: MOCK_LABEL_AGGREGATIONS, + ...initialState, + }), + }; + + store = new Vuex.Store(config); + + wrapper = mountExtended(LabelFilter, { + store, + provide: { + glFeatures: { + searchIssueLabelAggregation: true, + }, + }, + }); + }; + + const findComponentTitle = () => wrapper.findComponentByTestId('label-filter-title'); + const findAllSelectedLabelsAbove = () => wrapper.findAllComponents(GlLabel); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownForm = () => wrapper.findComponent(GlDropdownForm); + const findCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); + const findDropdownSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader); + const findDivider = () => wrapper.findComponent(GlDropdownDivider); + const findCheckboxFilter = () => wrapper.findAllComponents(LabelDropdownItems); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('Renders correctly closed', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + + await Vue.nextTick(); + }); + + it('renders component title', () => { + expect(findComponentTitle().exists()).toBe(true); + }); + + it('renders selected labels above search box', () => { + expect(findAllSelectedLabelsAbove().exists()).toBe(true); + expect(findAllSelectedLabelsAbove()).toHaveLength(2); + }); + + it('renders search box', () => { + expect(findSearchBox().exists()).toBe(true); + }); + + it("doesn't render dropdown form", () => { + expect(findDropdownForm().exists()).toBe(false); + }); + + it("doesn't render checkbox group", () => { + expect(findCheckboxGroup().exists()).toBe(false); + }); + + it("doesn't render dropdown section header", () => { + expect(findDropdownSectionHeader().exists()).toBe(false); + }); + + it("doesn't render divider", () => { + expect(findDivider().exists()).toBe(false); + }); + + it("doesn't render checkbox filter", () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('Renders correctly opened', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + + await Vue.nextTick(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + findSearchBox().vm.$emit('focusin'); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('renders component title', () => { + expect(findComponentTitle().exists()).toBe(true); + }); + + it('renders selected labels above search box', () => { + // default data need to provide at least two selected labels + expect(findAllSelectedLabelsAbove().exists()).toBe(true); + expect(findAllSelectedLabelsAbove()).toHaveLength(2); + }); + + it('renders search box', () => { + expect(findSearchBox().exists()).toBe(true); + }); + + it('renders dropdown form', () => { + expect(findDropdownForm().exists()).toBe(true); + }); + + it('renders checkbox group', () => { + expect(findCheckboxGroup().exists()).toBe(true); + }); + + it('renders dropdown section header', () => { + expect(findDropdownSectionHeader().exists()).toBe(true); + }); + + it('renders divider', () => { + expect(findDivider().exists()).toBe(true); + }); + + it('renders checkbox filter', () => { + expect(findCheckboxFilter().exists()).toBe(true); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('sends tracking information when dropdown is opened', () => { + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, { + label: TRACKING_LABEL_DROPDOWN, + }); + }); + }); + + describe('Renders loading state correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(REQUEST_AGGREGATIONS); + await Vue.nextTick(); + + findSearchBox().vm.$emit('focusin'); + }); + + it('renders checkbox filter', () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('Renders error state correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_ERROR); + await Vue.nextTick(); + + findSearchBox().vm.$emit('focusin'); + }); + + it("doesn't render checkbox filter", () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it('renders alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('Actions', () => { + describe('dispatch action when component is created', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders checkbox filter', async () => { + await Vue.nextTick(); + expect(actionSpies.fetchAllAggregation).toHaveBeenCalled(); + }); + }); + + describe('Closing label works correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + await Vue.nextTick(); + }); + + it('renders checkbox filter', async () => { + await findAllSelectedLabelsAbove().at(0).find('.btn-reset').trigger('click'); + expect(actionSpies.closeLabel).toHaveBeenCalled(); + }); + }); + + describe('label search input box works properly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders checkbox filter', () => { + findSearchBox().find('input').setValue('test'); + expect(actionSpies.setLabelFilterSearch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + value: 'test', + }), + ); + }); + }); + + describe('dropdown checkboxes work', () => { + beforeEach(async () => { + createComponent(); + + await findSearchBox().vm.$emit('focusin'); + await Vue.nextTick(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + await findCheckboxGroup().vm.$emit('input', 6); + await Vue.nextTick(); + }); + + it('trigger event', () => { + expect(actionSpies.setQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ key: labelFilterData?.filterParam, value: 6 }), + ); + }); + + it('sends tracking information when checkbox is selected', () => { + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_FILTER, + property: 6, + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 18f7e1ce21c502204185229719d2d7a07d372591..2051e7316471b785cb9864c9298826b2d1702af5 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -133,7 +133,7 @@ describe('Global Search Store Actions', () => { describe('when groupId is set', () => { it('calls Api.groupProjects with expected parameters', () => { - actions.fetchProjects({ commit: mockCommit, state }, undefined); + actions.fetchProjects({ commit: mockCommit, state }, MOCK_QUERY.search); expect(Api.groupProjects).toHaveBeenCalledWith(state.query.group_id, state.query.search, { order_by: 'similarity', include_subgroups: true, diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js index 51692cb1ab49a91e6a69391ecbe9de8ddfaea5d9..772acb39a57ddcdd6fc9da78680ac2bc98e80599 100644 --- a/spec/frontend/search/store/getters_spec.js +++ b/spec/frontend/search/store/getters_spec.js @@ -33,10 +33,6 @@ describe('Global Search Store Getters', () => { useMockLocationHelper(); }); - afterEach(() => { - state = cloneDeep(defaultState); - }); - describe('frequentGroups', () => { it('returns the correct data', () => { state.frequentItems[GROUPS_LOCAL_STORAGE_KEY] = MOCK_GROUPS;