diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/cluster_filter.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/cluster_filter.vue index c388e974cb56e60b0cd3b65104f649de91f9b260..4b75d9a0c9b4b4c2e6797cc59e39804d8bba7fe0 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/filters/cluster_filter.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/filters/cluster_filter.vue @@ -1,20 +1,16 @@ <script> -import { GlDropdown } from '@gitlab/ui'; -import { xor } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import getClusterAgentsQuery from 'ee/security_dashboard/graphql/queries/cluster_agents.query.graphql'; import { createAlert } from '~/alert'; -import FilterItem from './filter_item.vue'; import QuerystringSync from './querystring_sync.vue'; -import DropdownButtonText from './dropdown_button_text.vue'; import { ALL_ID } from './constants'; +import { getSelectedOptionsText } from './utils'; export default { components: { - FilterItem, - GlDropdown, + GlCollapsibleListbox, QuerystringSync, - DropdownButtonText, }, apollo: { clusterAgents: { @@ -26,8 +22,8 @@ export default { }, update: (data) => data.project?.clusterAgents?.nodes.map((c) => ({ - id: c.name, - name: c.name, + value: c.name, + text: c.name, gid: c.id, })) || [], error() { @@ -41,30 +37,43 @@ export default { selected: [], }), computed: { - selectedItemNames() { - const options = this.clusterAgents?.filter(({ id }) => this.selected.includes(id)); - // Return the text for selected items, or all items if nothing is selected. - return options.length ? options.map(({ name }) => name) : [this.$options.i18n.allItemsText]; + toggleText() { + const options = this.clusterAgents?.filter(({ value }) => this.selected.includes(value)); + return getSelectedOptionsText(options, this.selected, this.$options.i18n.allItemsText); }, isLoading() { return this.$apollo.queries.clusterAgents.loading; }, + items() { + return [ + { + text: this.$options.i18n.allItemsText, + value: ALL_ID, + }, + ...this.clusterAgents, + ]; + }, + selectedItems() { + return this.selected.length ? this.selected : [ALL_ID]; + }, }, watch: { selected() { const gids = this.clusterAgents - .filter(({ id }) => this.selected.includes(id)) + .filter(({ value }) => this.selected.includes(value)) .map(({ gid }) => gid); this.$emit('filter-changed', { clusterAgentId: gids }); }, }, methods: { - deselectAll() { - this.selected = []; - }, - toggleSelected(id) { - this.selected = xor(this.selected, [id]); + handleSelect(selected) { + if (selected?.at(-1) === ALL_ID) { + this.selected = []; + return; + } + + this.selected = selected.filter((value) => value !== ALL_ID); }, }, i18n: { @@ -80,31 +89,15 @@ export default { <div> <querystring-sync v-model="selected" querystring-key="cluster" /> <label class="gl-mb-2">{{ $options.i18n.label }}</label> - <gl-dropdown + <gl-collapsible-listbox + :selected="selectedItems" + :items="items" + :toggle-text="toggleText" :header-text="$options.i18n.label" :loading="isLoading" + multiple block - toggle-class="gl-mb-0" - > - <template #button-text> - <dropdown-button-text :items="selectedItemNames" :name="$options.i18n.label" /> - </template> - - <filter-item - :is-checked="!selected.length" - :text="$options.i18n.allItemsText" - :data-testid="$options.ALL_ID" - @click="deselectAll" - /> - - <filter-item - v-for="{ id } in clusterAgents" - :key="id" - :data-testid="id" - :is-checked="selected.includes(id)" - :text="id" - @click="toggleSelected(id)" - /> - </gl-dropdown> + @select="handleSelect" + /> </div> </template> diff --git a/ee/spec/frontend/security_dashboard/components/mock_data.js b/ee/spec/frontend/security_dashboard/components/mock_data.js index 350c5ba68e9168d8f0cb56eb3ff6d0fddba4a039..3fe81119df628de0bf4e0b65d681afc5ffb20202 100644 --- a/ee/spec/frontend/security_dashboard/components/mock_data.js +++ b/ee/spec/frontend/security_dashboard/components/mock_data.js @@ -54,6 +54,11 @@ export const projectClusters = { name: 'primary-agent', __typename: 'ClusterAgentConnection', }, + { + id: 'gid://gitlab/Clusters::Agent/007', + name: 'james-bond-agent', + __typename: 'ClusterAgentConnection', + }, ], __typename: 'ClusterAgentConnection', }, diff --git a/ee/spec/frontend/security_dashboard/components/shared/filters/cluster_filter_spec.js b/ee/spec/frontend/security_dashboard/components/shared/filters/cluster_filter_spec.js index e1095f617ae1b408a3f36ee299eaee3a72dc6e77..3a21dc4f860c701b7d2e19495fe133acecacceee 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/filters/cluster_filter_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/filters/cluster_filter_spec.js @@ -1,10 +1,8 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import ClusterFilter from 'ee/security_dashboard/components/shared/filters/cluster_filter.vue'; import QuerystringSync from 'ee/security_dashboard/components/shared/filters/querystring_sync.vue'; -import DropdownButtonText from 'ee/security_dashboard/components/shared/filters/dropdown_button_text.vue'; -import FilterItem from 'ee/security_dashboard/components/shared/filters/filter_item.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { ALL_ID } from 'ee/security_dashboard/components/shared/filters/constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -20,6 +18,7 @@ describe('ClusterFilter component', () => { let wrapper; const defaultQueryResolver = jest.fn().mockResolvedValue(projectClusters); const mockClusters = projectClusters.data.project.clusterAgents.nodes; + const firstMockClusterName = mockClusters[0].name; const createWrapper = (queryResolver = defaultQueryResolver) => { wrapper = mountExtended(ClusterFilter, { @@ -30,20 +29,15 @@ describe('ClusterFilter component', () => { }; const findQuerystringSync = () => wrapper.findComponent(QuerystringSync); - const findDropdownItems = () => wrapper.findAllComponents(FilterItem); - const findDropdownItem = (name) => wrapper.findByTestId(name); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxItem = (name) => wrapper.findByTestId(`listbox-item-${name}`); - const clickDropdownItem = async (name) => { - findDropdownItem(name).trigger('click'); - await nextTick(); + const clickListboxItem = (name) => { + return findListboxItem(name).trigger('click'); }; const expectSelectedItems = (ids) => { - const checkedItems = findDropdownItems() - .wrappers.filter((item) => item.props('isChecked')) - .map((item) => item.attributes('data-testid')); - - expect(checkedItems).toEqual(ids); + expect(findListbox().props('selected')).toEqual(ids); }; describe('basic structure', () => { @@ -63,7 +57,7 @@ describe('ClusterFilter component', () => { it.each` emitted | expected ${[]} | ${[ALL_ID]} - ${[mockClusters[0].name]} | ${[mockClusters[0].name]} + ${[firstMockClusterName]} | ${[firstMockClusterName]} `('restores selected items - $emitted', async ({ emitted, expected }) => { findQuerystringSync().vm.$emit('input', emitted); await nextTick(); @@ -77,29 +71,37 @@ describe('ClusterFilter component', () => { expect(wrapper.find('label').text()).toBe(ClusterFilter.i18n.label); }); - it('shows the dropdown with correct header text', () => { - expect(wrapper.findComponent(GlDropdown).props('headerText')).toBe( - ClusterFilter.i18n.label, - ); + it('shows the listbox with correct header text', () => { + expect(findListbox().props('headerText')).toBe(ClusterFilter.i18n.label); }); - it('shows the DropdownButtonText component with the correct props', () => { - expect(wrapper.findComponent(DropdownButtonText).props()).toMatchObject({ - items: [ClusterFilter.i18n.allItemsText], - name: ClusterFilter.i18n.label, - }); + it('passes the placeholder toggle text when no items are selected', () => { + expect(findListbox().props('toggleText')).toBe(ClusterFilter.i18n.allItemsText); + }); + + it(`passes '${firstMockClusterName}' when only ${firstMockClusterName} is selected`, async () => { + await clickListboxItem(firstMockClusterName); + + expect(findListbox().props('toggleText')).toBe(firstMockClusterName); + }); + + it(`passes '${firstMockClusterName} +1 more' when ${firstMockClusterName} and another image is selected`, async () => { + await clickListboxItem(firstMockClusterName); + await clickListboxItem(mockClusters[1].name); + + expect(findListbox().props('toggleText')).toBe(`${firstMockClusterName} +1 more`); }); }); describe('filter-changed event', () => { it('emits filter-changed event when selected item is changed', async () => { const ids = []; - await clickDropdownItem(ALL_ID); + await clickListboxItem(ALL_ID); expect(wrapper.emitted('filter-changed')[0][0].clusterAgentId).toEqual([]); for await (const { id, name } of mockClusters) { - await clickDropdownItem(name); + await clickListboxItem(name); ids.push(id); expect(wrapper.emitted('filter-changed')[ids.length][0].clusterAgentId).toEqual(ids); @@ -107,13 +109,13 @@ describe('ClusterFilter component', () => { }); }); - describe('dropdown items', () => { + describe('listbox items', () => { it('populates all dropdown items with correct text', () => { - expect(findDropdownItems()).toHaveLength(mockClusters.length + 1); - expect(findDropdownItem(ALL_ID).text()).toBe(ClusterFilter.i18n.allItemsText); + expect(findListbox().props('items')).toHaveLength(mockClusters.length + 1); + expect(findListboxItem(ALL_ID).text()).toBe(ClusterFilter.i18n.allItemsText); mockClusters.forEach(({ name }) => { - expect(findDropdownItem(name).text()).toBe(name); + expect(findListboxItem(name).text()).toBe(name); }); }); @@ -121,7 +123,7 @@ describe('ClusterFilter component', () => { const names = []; for await (const { name } of mockClusters) { - await clickDropdownItem(name); + await clickListboxItem(name); names.push(name); expectSelectedItems(names); @@ -130,11 +132,11 @@ describe('ClusterFilter component', () => { it('toggles the item selection when clicked on', async () => { for await (const { name } of mockClusters) { - await clickDropdownItem(name); + await clickListboxItem(name); expectSelectedItems([name]); - await clickDropdownItem(name); + await clickListboxItem(name); expectSelectedItems([ALL_ID]); } @@ -145,17 +147,17 @@ describe('ClusterFilter component', () => { }); it('selects ALL item and deselects everything else when it is clicked', async () => { - await clickDropdownItem(ALL_ID); - await clickDropdownItem(ALL_ID); // Click again to verify that it doesn't toggle. + await clickListboxItem(ALL_ID); + await clickListboxItem(ALL_ID); // Click again to verify that it doesn't toggle. expectSelectedItems([ALL_ID]); }); it('deselects the ALL item when another item is clicked', async () => { - await clickDropdownItem(ALL_ID); - await clickDropdownItem(mockClusters[0].name); + await clickListboxItem(ALL_ID); + await clickListboxItem(firstMockClusterName); - expectSelectedItems([mockClusters[0].name]); + expectSelectedItems([firstMockClusterName]); }); }); });