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 8d43859cdab302b456d244991e42aaf481c54829..a9dd74a9e52515bb427de5867860b2b583602264 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 @@ -8,6 +8,7 @@ import LicenseToken from './tokens/license_token.vue'; import ProjectToken from './tokens/project_token.vue'; import ComponentToken from './tokens/component_token.vue'; import PackagerToken from './tokens/package_manager_token.vue'; +import VersionToken from './tokens/version_token.vue'; import DependenciesFilteredSearch from './dependencies_filtered_search.vue'; export default { @@ -61,6 +62,18 @@ export default { }, ] : []), + ...(this.glFeatures.versionFilteringOnGroupLevelDependencyList + ? [ + { + type: 'version', + title: __('Version'), + multiSelect: true, + unique: true, + token: VersionToken, + operators: OPERATORS_IS, + }, + ] + : []), ]; }, }, diff --git a/ee/app/assets/javascripts/dependencies/components/filtered_search/project_dependencies_filtered_search.vue b/ee/app/assets/javascripts/dependencies/components/filtered_search/project_dependencies_filtered_search.vue index a13c8173709b85c8546f96f4a3a4fd4237519a43..bdc0379472a29f20d5bcf5cbac6acd2a308debe4 100644 --- a/ee/app/assets/javascripts/dependencies/components/filtered_search/project_dependencies_filtered_search.vue +++ b/ee/app/assets/javascripts/dependencies/components/filtered_search/project_dependencies_filtered_search.vue @@ -1,13 +1,16 @@ <script> import { __ } from '~/locale'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DependenciesFilteredSearch from './dependencies_filtered_search.vue'; import ComponentToken from './tokens/component_token.vue'; +import VersionToken from './tokens/version_token.vue'; export default { components: { DependenciesFilteredSearch, }, + mixins: [glFeatureFlagsMixin()], computed: { tokens() { return [ @@ -19,6 +22,18 @@ export default { token: ComponentToken, operators: OPERATORS_IS, }, + ...(this.glFeatures.versionFilteringOnProjectLevelDependencyList + ? [ + { + type: 'version', + title: __('Version'), + multiSelect: true, + unique: true, + token: VersionToken, + operators: OPERATORS_IS, + }, + ] + : []), ]; }, }, diff --git a/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/version_token.vue b/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/version_token.vue new file mode 100644 index 0000000000000000000000000000000000000000..6b071ae8c693d3a0d53ea9ee308dec89237aeb27 --- /dev/null +++ b/ee/app/assets/javascripts/dependencies/components/filtered_search/tokens/version_token.vue @@ -0,0 +1,68 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; + +export default { + components: { + GlFilteredSearchToken, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return {}; + }, + computed: { + ...mapGetters(['selectedComponents']), + 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 : [], + }; + }, + noSelectedComponent() { + return this.selectedComponents.length === 0; + }, + multipleSelectedComponents() { + return this.selectedComponents.length > 1; + }, + viewOnly() { + return this.noSelectedComponent || this.multipleSelectedComponents; + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + :value="tokenValue" + :view-only="viewOnly" + v-on="$listeners" + > + <template #suggestions> + <div v-if="noSelectedComponent" class="gl-p-2 gl-text-secondary"> + {{ s__('Dependencies|To filter by version, filter by one component first') }} + </div> + <div v-else-if="multipleSelectedComponents" class="gl-p-2 gl-text-secondary"> + {{ s__('Dependencies|To filter by version, select exactly one component first') }} + </div> + </template> + </gl-filtered-search-token> +</template> diff --git a/ee/app/assets/javascripts/dependencies/store/getters.js b/ee/app/assets/javascripts/dependencies/store/getters.js index b1994aceea0a8a5145f4a74b9810ab723e14e082..6d8e9d2d95100790d08be96b1fb904a464a4720f 100644 --- a/ee/app/assets/javascripts/dependencies/store/getters.js +++ b/ee/app/assets/javascripts/dependencies/store/getters.js @@ -8,3 +8,6 @@ export const totals = (state) => }), {}, ); + +export const selectedComponents = ({ currentList, ...state }) => + state[currentList].searchFilterParameters.component_names || []; 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 329610d730e345bab5cc9906c435a58084e28de4..49636f140dc12c211e7ea29bae680143a734aed0 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 @@ -6,6 +6,7 @@ import LicenseToken from 'ee/dependencies/components/filtered_search/tokens/lice import ProjectToken from 'ee/dependencies/components/filtered_search/tokens/project_token.vue'; import PackagerToken from 'ee/dependencies/components/filtered_search/tokens/package_manager_token.vue'; import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue'; +import VersionToken from 'ee/dependencies/components/filtered_search/tokens/version_token.vue'; describe('GroupDependenciesFilteredSearch', () => { let wrapper; @@ -21,6 +22,7 @@ describe('GroupDependenciesFilteredSearch', () => { belowGroupLimit: true, glFeatures: { groupLevelDependenciesFilteringByPackager: true, + versionFilteringOnGroupLevelDependencyList: true, }, ...provide, }, @@ -56,6 +58,7 @@ describe('GroupDependenciesFilteredSearch', () => { ${'Project'} | ${{ title: 'Project', type: 'project_ids', multiSelect: true, token: ProjectToken }} ${'Packager'} | ${{ title: 'Packager', type: 'package_managers', multiSelect: true, token: PackagerToken }} ${'Component'} | ${{ title: 'Component', type: 'component_names', multiSelect: true, token: ComponentToken }} + ${'Version'} | ${{ title: 'Version', type: 'version', multiSelect: true, token: VersionToken }} `('contains a "$tokenTitle" search token', ({ tokenConfig }) => { expect(findDependenciesFilteredSearch().props('tokens')).toMatchObject( expect.arrayContaining([ @@ -67,8 +70,8 @@ describe('GroupDependenciesFilteredSearch', () => { }); }); - describe('when group_leven_dependencies_filtering_by_packager feature flag is disabled', () => { - it('does not contain a "Packager" search token when the feature flag is not enabled', () => { + describe('when group_level_dependencies_filtering_by_packager feature flag is disabled', () => { + it('does not contain a "Packager" search token', () => { createComponent({ provide: { belowGroupLimit: true, @@ -89,6 +92,28 @@ describe('GroupDependenciesFilteredSearch', () => { }); }); + describe('when version_filtering_on_group_level_dependency_list feature flag is disabled', () => { + it('does not contain a "Version" token', () => { + createComponent({ + provide: { + belowGroupLimit: true, + glFeatures: { versionFilteringOnGroupLevelDependencyList: false }, + }, + }); + + expect(findDependenciesFilteredSearch().props('tokens')).not.toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + title: 'Version', + type: 'version', + multiSelect: true, + token: VersionToken, + }), + ]), + ); + }); + }); + describe('when sub-group limit-count is reached', () => { beforeEach(() => { createComponent({ diff --git a/ee/spec/frontend/dependencies/components/filtered_search/project_dependencies_filtered_search_spec.js b/ee/spec/frontend/dependencies/components/filtered_search/project_dependencies_filtered_search_spec.js index 979c5b37763701682b664c68930f4c5de09f02e2..24c513a4a598313f3c324fdab0cceb0d8e8a86d9 100644 --- a/ee/spec/frontend/dependencies/components/filtered_search/project_dependencies_filtered_search_spec.js +++ b/ee/spec/frontend/dependencies/components/filtered_search/project_dependencies_filtered_search_spec.js @@ -2,12 +2,20 @@ import { shallowMount } from '@vue/test-utils'; import ProjectDependenciesFilteredSearch from 'ee/dependencies/components/filtered_search/project_dependencies_filtered_search.vue'; import DependenciesFilteredSearch from 'ee/dependencies/components/filtered_search/dependencies_filtered_search.vue'; import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue'; +import VersionToken from 'ee/dependencies/components/filtered_search/tokens/version_token.vue'; describe('ProjectDependenciesFilteredSearch', () => { let wrapper; - const createComponent = () => { - wrapper = shallowMount(ProjectDependenciesFilteredSearch); + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMount(ProjectDependenciesFilteredSearch, { + provide: { + glFeatures: { + versionFilteringOnProjectLevelDependencyList: true, + }, + ...provide, + }, + }); }; const findDependenciesFilteredSearch = () => wrapper.findComponent(DependenciesFilteredSearch); @@ -25,6 +33,7 @@ describe('ProjectDependenciesFilteredSearch', () => { it.each` tokenTitle | tokenConfig ${'Component'} | ${{ title: 'Component', type: 'component_names', multiSelect: true, token: ComponentToken }} + ${'Version'} | ${{ title: 'Version', type: 'version', multiSelect: true, token: VersionToken }} `('contains a "$tokenTitle" search token', ({ tokenConfig }) => { expect(findDependenciesFilteredSearch().props('tokens')).toMatchObject( expect.arrayContaining([ @@ -34,4 +43,25 @@ describe('ProjectDependenciesFilteredSearch', () => { ]), ); }); + + describe('when version_filtering_on_project_level_dependency_list feature flag is disabled', () => { + it('does not contain a "Version" token', () => { + createComponent({ + provide: { + glFeatures: { versionFilteringOnProjectLevelDependencyList: false }, + }, + }); + + expect(findDependenciesFilteredSearch().props('tokens')).not.toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + title: 'Version', + type: 'version', + multiSelect: true, + token: VersionToken, + }), + ]), + ); + }); + }); }); diff --git a/ee/spec/frontend/dependencies/components/filtered_search/tokens/version_token_spec.js b/ee/spec/frontend/dependencies/components/filtered_search/tokens/version_token_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d63ba946e8d7625f2bff6a0465c22ff8731bbdac --- /dev/null +++ b/ee/spec/frontend/dependencies/components/filtered_search/tokens/version_token_spec.js @@ -0,0 +1,96 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import VersionToken from 'ee/dependencies/components/filtered_search/tokens/version_token.vue'; +import createStore from 'ee/dependencies/store'; + +describe('ee/dependencies/components/filtered_search/tokens/version_token.vue', () => { + let wrapper; + let store; + + const createVuexStore = () => { + store = createStore(); + }; + + const createComponent = () => { + wrapper = shallowMountExtended(VersionToken, { + store, + propsData: { + config: { + multiSelect: true, + }, + value: {}, + active: false, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="view"></slot><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + + beforeEach(() => { + createVuexStore(); + createComponent(); + }); + + describe('when the component is initially rendered', () => { + it('passes the correct props to the GlFilteredSearchToken', () => { + expect(findFilteredSearchToken().props()).toMatchObject({ + config: { multiSelect: true }, + value: { data: [] }, + viewOnly: true, + active: false, + }); + }); + }); + + describe('when no components are selected', () => { + it('shows the correct guidance message', () => { + expect(findFilteredSearchToken().text()).toBe( + 'To filter by version, filter by one component first', + ); + }); + + it('sets viewOnly prop to true', () => { + expect(findFilteredSearchToken().props('viewOnly')).toBe(true); + }); + }); + + describe('when multiple components are selected', () => { + beforeEach(() => { + store.state.allDependencies.searchFilterParameters = { + component_names: ['component-1', 'component-2'], + }; + }); + + it('shows the correct guidance message', () => { + expect(findFilteredSearchToken().text()).toBe( + 'To filter by version, select exactly one component first', + ); + }); + + it('sets viewOnly prop to true', () => { + expect(findFilteredSearchToken().props('viewOnly')).toBe(true); + }); + }); + + describe('when exactly one component is selected', () => { + beforeEach(() => { + store.state.allDependencies.searchFilterParameters = { + component_names: ['component-1'], + }; + }); + + it('does not show any guidance messages', () => { + expect(findFilteredSearchToken().text()).toBe(''); + }); + + it('sets viewOnly prop to false', () => { + expect(findFilteredSearchToken().props('viewOnly')).toBe(false); + }); + }); +}); diff --git a/ee/spec/frontend/dependencies/store/getters_spec.js b/ee/spec/frontend/dependencies/store/getters_spec.js index 802c815d38a07e7b4c2a60be10b7237462954428..f897331b85608b114f70414b4ac8189c4417ab0f 100644 --- a/ee/spec/frontend/dependencies/store/getters_spec.js +++ b/ee/spec/frontend/dependencies/store/getters_spec.js @@ -41,4 +41,31 @@ describe('Dependencies getters', () => { }); }); }); + + describe('selectedComponents', () => { + it('returns the `component_name` array in `searchFilterParameters`', () => { + const mockComponentNames = ['component-1', 'component-2']; + const state = { + listFoo: { + searchFilterParameters: { + component_names: mockComponentNames, + }, + }, + currentList: 'listFoo', + }; + + expect(getters.selectedComponents(state)).toEqual(mockComponentNames); + }); + + it('returns empty array if `component_names` is not set', () => { + const state = { + listFoo: { + searchFilterParameters: {}, + }, + currentList: 'listFoo', + }; + + expect(getters.selectedComponents(state)).toEqual([]); + }); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e859b64ad12640c386e5366edac46245786b8431..96b3892af3ed065a26b4c62e5ebf741cd538178f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19972,6 +19972,12 @@ msgstr "" msgid "Dependencies|This link will expire in %{number} days." msgstr "" +msgid "Dependencies|To filter by version, filter by one component first" +msgstr "" + +msgid "Dependencies|To filter by version, select exactly one component first" +msgstr "" + msgid "Dependencies|Toggle vulnerability list" msgstr ""