diff --git a/ee/app/assets/javascripts/dependencies/components/filtered_search/group_dependencies_filtered_search.vue b/ee/app/assets/javascripts/dependencies/components/filtered_search/group_dependencies_filtered_search.vue index 908da197e6a4e2b974e8ef24ea592bac2b1286f2..1dd6a6727607202d403cbf9ccd422080e9091bbb 100644 --- a/ee/app/assets/javascripts/dependencies/components/filtered_search/group_dependencies_filtered_search.vue +++ b/ee/app/assets/javascripts/dependencies/components/filtered_search/group_dependencies_filtered_search.vue @@ -5,8 +5,10 @@ import { mapActions, mapState } from 'vuex'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LicenseToken from './tokens/license_token.vue'; import ProjectToken from './tokens/project_token.vue'; +import ComponentToken from './tokens/component_token.vue'; export default { components: { @@ -15,6 +17,7 @@ export default { GlLink, GlSprintf, }, + mixins: [glFeatureFlagsMixin()], inject: ['belowGroupLimit'], data() { return { @@ -42,6 +45,18 @@ export default { token: ProjectToken, operators: OPERATORS_IS, }, + ...(this.glFeatures.groupLevelDependenciesFilteringByComponent + ? [ + { + type: 'component_ids', + title: __('Component'), + multiSelect: true, + unique: true, + token: ComponentToken, + operators: OPERATORS_IS, + }, + ] + : []), ]; }, }, diff --git a/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/component_token.vue b/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/component_token.vue new file mode 100644 index 0000000000000000000000000000000000000000..f4c48d75a2afaf785893db353bd2aea44e888005 --- /dev/null +++ b/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/component_token.vue @@ -0,0 +1,168 @@ +<script> +import { + GlIcon, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + GlIntersperse, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + components: { + GlIcon, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + GlIntersperse, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + searchTerm: '', + components: [], + selectedComponents: [], + isLoadingComponents: true, + }; + }, + computed: { + filteredComponents() { + if (!this.searchTerm) { + return this.components; + } + + const nameIncludesSearchTerm = (component) => + component.name.toLowerCase().includes(this.searchTerm); + const isSelected = (component) => this.selectedComponentNames.includes(component.name); + + return this.components.filter( + (component) => nameIncludesSearchTerm(component) || isSelected(component), + ); + }, + selectedComponentNames() { + return this.selectedComponents.map(({ name }) => name); + }, + selectedComponentIds() { + return this.selectedComponents.map(({ id }) => getIdFromGraphQLId(id)); + }, + tokenValue() { + return { + ...this.value, + // when the token is active (dropdown is open), we set the value to null to prevent an UX issue + // in which only the last selected item is being displayed. + // more information: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2381 + data: this.active ? null : this.selectedComponentNames, + }; + }, + }, + created() { + this.fetchComponents(); + }, + methods: { + async fetchComponents() { + try { + this.isLoadingComponents = true; + // Note: This is just a placeholder. Adding the actual fetch logic will be addressed in a seperate issue: + // https://gitlab.com/gitlab-org/gitlab/-/issues/442407 + this.components = await new Promise((resolve) => { + resolve([ + { id: 'gid://gitlab/Component/1', name: 'ComponentOne' }, + { id: 'gid://gitlab/Component/2', name: 'ComponentTwo' }, + { id: 'gid://gitlab/Component/3', name: 'ComponentThree' }, + ]); + }); + } catch { + createAlert({ + message: this.$options.i18n.fetchErrorMessage, + }); + } finally { + this.isLoadingComponents = false; + } + }, + isComponentSelected(component) { + return this.selectedComponents.some((c) => c.id === component.id); + }, + toggleSelectedComponent(component) { + if (this.isComponentSelected(component)) { + this.selectedComponents = this.selectedComponents.filter((c) => c.id !== component.id); + } else { + this.selectedComponents.push(component); + } + }, + handleInput(token) { + // the dropdown shows a list of component names but we need to emit the project ids for filtering + this.$emit('input', { ...token, data: this.selectedComponentIds }); + }, + setSearchTerm(token) { + // the data can be either a string or an array, in which case we don't want to perform the search + if (typeof token.data === 'string') { + this.searchTerm = token.data.toLowerCase(); + } + }, + }, + i18n: { + fetchErrorMessage: s__( + 'Dependencies|There was an error fetching the components for this group. Please try again later.', + ), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + :multi-select-values="selectedComponentNames" + :value="tokenValue" + v-on="{ ...$listeners, input: handleInput }" + @select="toggleSelectedComponent" + @input="setSearchTerm" + > + <template #view> + <gl-intersperse data-testid="selected-components"> + <span + v-for="selectedComponentName in selectedComponentNames" + :key="selectedComponentName" + >{{ selectedComponentName }}</span + > + </gl-intersperse> + </template> + <template #suggestions> + <gl-loading-icon v-if="isLoadingComponents" size="sm" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="component in filteredComponents" + :key="component.id" + :value="component" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-icon + v-if="config.multiSelect" + name="check" + class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700" + :class="{ + 'gl-visibility-hidden': !selectedComponentNames.includes(component.name), + }" + /> + {{ component.name }} + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/ee/app/controllers/groups/dependencies_controller.rb b/ee/app/controllers/groups/dependencies_controller.rb index acb4b232d26eab3ecf5e5a98e0436ced1fe2aa26..cfadee01e8e6a66a2086dd6419d2b7c3e7aeaef5 100644 --- a/ee/app/controllers/groups/dependencies_controller.rb +++ b/ee/app/controllers/groups/dependencies_controller.rb @@ -4,6 +4,10 @@ module Groups class DependenciesController < Groups::ApplicationController include GovernUsageGroupTracking + before_action only: :index do + push_frontend_feature_flag(:group_level_dependencies_filtering_by_component, group) + end + before_action :authorize_read_dependency_list! before_action :validate_project_ids_limit!, only: :index diff --git a/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml new file mode 100644 index 0000000000000000000000000000000000000000..c23416e76f440deba399539274767afa9f1f6dfc --- /dev/null +++ b/ee/config/feature_flags/wip/group_level_dependencies_filtering_by_component.yml @@ -0,0 +1,9 @@ +--- +name: group_level_dependencies_filtering_by_component +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442406 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148257 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454589 +milestone: '16.11' +group: group::threat insights +type: wip +default_enabled: false diff --git a/ee/spec/frontend/dependencies/components/filtered_search/group_dependencies_filtered_search_spec.js b/ee/spec/frontend/dependencies/components/filtered_search/group_dependencies_filtered_search_spec.js index 4784086413b013c4da9d590fd3fa4413499b61ca..b86a3ef48566d9d4d192e768c2a5ada6b41f8233 100644 --- a/ee/spec/frontend/dependencies/components/filtered_search/group_dependencies_filtered_search_spec.js +++ b/ee/spec/frontend/dependencies/components/filtered_search/group_dependencies_filtered_search_spec.js @@ -3,6 +3,7 @@ import { GlFilteredSearch, GlPopover, GlSprintf } from '@gitlab/ui'; import GroupDependenciesFilteredSearch from 'ee/dependencies/components/filtered_search/group_dependencies_filtered_search.vue'; import LicenseToken from 'ee/dependencies/components/filtered_search/tokens/license_token.vue'; import ProjectToken from 'ee/dependencies/components/filtered_search/tokens/project_token.vue'; +import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue'; import createStore from 'ee/dependencies/store'; describe('GroupDependenciesFilteredSearch', () => { @@ -17,7 +18,10 @@ describe('GroupDependenciesFilteredSearch', () => { const createComponent = (mountOptions = {}) => { wrapper = shallowMount(GroupDependenciesFilteredSearch, { store, - provide: { belowGroupLimit: true }, + provide: { + belowGroupLimit: true, + glFeatures: { groupLevelDependenciesFilteringByComponent: true }, + }, stubs: { GlSprintf, }, @@ -47,9 +51,10 @@ describe('GroupDependenciesFilteredSearch', () => { }); it.each` - tokenTitle | tokenConfig - ${'License'} | ${{ title: 'License', type: 'licenses', multiSelect: true, token: LicenseToken }} - ${'Project'} | ${{ title: 'Project', type: 'project_ids', multiSelect: true, token: ProjectToken }} + tokenTitle | tokenConfig + ${'License'} | ${{ title: 'License', type: 'licenses', multiSelect: true, token: LicenseToken }} + ${'Project'} | ${{ title: 'Project', type: 'project_ids', multiSelect: true, token: ProjectToken }} + ${'Component'} | ${{ title: 'Component', type: 'component_ids', multiSelect: true, token: ComponentToken }} `('contains a "$tokenTitle" search token', ({ tokenConfig }) => { expect(findFilteredSearch().props('availableTokens')).toMatchObject( expect.arrayContaining([ @@ -102,4 +107,23 @@ describe('GroupDependenciesFilteredSearch', () => { ); }); }); + + describe('with "groupLevelDependenciesFilteringByComponent" feature flag disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + belowGroupLimit: true, + glFeatures: { groupLevelDependenciesFilteringByComponent: false }, + }, + }); + }); + + it('does not show the Component token', () => { + expect(findFilteredSearch().props('availableTokens')).not.toContainEqual( + expect.objectContaining({ + title: 'Component', + }), + ); + }); + }); }); diff --git a/ee/spec/frontend/dependencies/components/filtered_search/tokens/component_token_spec.js b/ee/spec/frontend/dependencies/components/filtered_search/tokens/component_token_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d2e1c67f95b9a9f0e049e0fb17318e9d5403c6cc --- /dev/null +++ b/ee/spec/frontend/dependencies/components/filtered_search/tokens/component_token_spec.js @@ -0,0 +1,172 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlIcon, + GlIntersperse, + GlLoadingIcon, +} from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; + +const TEST_COMPONENTS = [ + { id: 'gid://gitlab/Component/1', name: 'ComponentOne' }, + { id: 'gid://gitlab/Component/2', name: 'ComponentTwo' }, + { id: 'gid://gitlab/Component/3', name: 'ComponentThree' }, +]; + +jest.mock('~/alert'); + +describe('ee/dependencies/components/filtered_search/tokens/component_token.vue', () => { + let wrapper; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(ComponentToken, { + propsData: { + config: { + multiSelect: true, + }, + value: {}, + active: false, + ...propsData, + }, + stubs: { + GlIntersperse, + }, + }); + }; + + const isLoadingSuggestions = () => wrapper.findComponent(GlLoadingIcon).exists(); + const findSuggestions = () => wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findFirstSearchSuggestionIcon = () => findSuggestions().at(0).findComponent(GlIcon); + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const selectComponent = (component) => { + findFilteredSearchToken().vm.$emit('select', component); + return nextTick(); + }; + const searchForComponent = (searchTerm = '') => { + findFilteredSearchToken().vm.$emit('input', { data: searchTerm }); + return waitForPromises(); + }; + + describe('when the component is initially rendered', () => { + it('shows a loading indicator while fetching the list of licenses', () => { + createComponent(); + + expect(isLoadingSuggestions()).toBe(true); + }); + + it.each([ + { active: true, expectedValue: null }, + { active: false, expectedValue: { data: [] } }, + ])( + 'passes "$expectedValue" to the search-token when the dropdown is open: "$active"', + async ({ active, expectedValue }) => { + createComponent({ + propsData: { + active, + value: { data: [] }, + }, + }); + + await waitForPromises(); + + expect(findFilteredSearchToken().props('value')).toEqual( + expect.objectContaining(expectedValue), + ); + }, + ); + }); + + describe('when the list of components have been fetched successfully', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('does not show an error message', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('does not show a loading indicator', () => { + expect(isLoadingSuggestions()).toBe(false); + }); + + it('shows a list of project suggestions', () => { + const suggestions = findSuggestions(); + + expect(suggestions).toHaveLength(TEST_COMPONENTS.length); + + expect(suggestions.at(0).text()).toBe(TEST_COMPONENTS[0].name); + expect(suggestions.at(1).text()).toBe(TEST_COMPONENTS[1].name); + expect(suggestions.at(2).text()).toBe(TEST_COMPONENTS[2].name); + }); + + describe('when a user selects projects to be filtered', () => { + it('displays a check-icon next to the selected project', async () => { + expect(findFirstSearchSuggestionIcon().classes()).toContain('gl-visibility-hidden'); + + await selectComponent(TEST_COMPONENTS[0]); + + expect(findFirstSearchSuggestionIcon().classes()).not.toContain('gl-visibility-hidden'); + }); + + it('shows a comma seperated list of selected projects', async () => { + await selectComponent(TEST_COMPONENTS[0]); + await selectComponent(TEST_COMPONENTS[1]); + + expect(wrapper.findByTestId('selected-components').text()).toMatchInterpolatedText( + `${TEST_COMPONENTS[0].name}, ${TEST_COMPONENTS[1].name}`, + ); + }); + + it(`emits the selected project's IDs without the GraphQL prefix`, async () => { + const tokenData = { + id: 'component_id', + type: 'component', + operator: '=', + }; + + const expectedIds = TEST_COMPONENTS.map((component) => + Number(component.id.replace('gid://gitlab/Component/', '')), + ); + + await selectComponent(TEST_COMPONENTS[0]); + await selectComponent(TEST_COMPONENTS[1]); + await selectComponent(TEST_COMPONENTS[2]); + + findFilteredSearchToken().vm.$emit('input', tokenData); + + expect(wrapper.emitted('input')).toEqual([ + [ + { + ...tokenData, + data: expectedIds, + }, + ], + ]); + }); + }); + + describe('when a user enters a search term', () => { + it('shows the filtered list of components', async () => { + await searchForComponent(TEST_COMPONENTS[0].name); + + expect(findSuggestions()).toHaveLength(1); + expect(findSuggestions().at(0).text()).toBe(TEST_COMPONENTS[0].name); + }); + + it('shows the already selected components in the filtered list', async () => { + await selectComponent(TEST_COMPONENTS[0]); + await searchForComponent(TEST_COMPONENTS[1].name); + + expect(findSuggestions()).toHaveLength(2); + expect(findSuggestions().at(0).text()).toBe(TEST_COMPONENTS[0].name); + expect(findSuggestions().at(1).text()).toBe(TEST_COMPONENTS[1].name); + }); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 96350d402c6e14b601d03909338bc450a4a05d7f..9a5965acf0160f00825b578f562c5c0b2dd661d3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17222,6 +17222,9 @@ msgstr "" msgid "Dependencies|There was a problem fetching vulnerabilities." msgstr "" +msgid "Dependencies|There was an error fetching the components for this group. Please try again later." +msgstr "" + msgid "Dependencies|There was an error fetching the projects for this group. Please try again later." msgstr ""