diff --git a/app/assets/javascripts/lib/utils/set.js b/app/assets/javascripts/lib/utils/set.js new file mode 100644 index 0000000000000000000000000000000000000000..3845d648b61f45f72c49d72880fad343b9111fbd --- /dev/null +++ b/app/assets/javascripts/lib/utils/set.js @@ -0,0 +1,9 @@ +/** + * Checks if the first argument is a subset of the second argument. + * @param {Set} subset The set to be considered as the subset. + * @param {Set} superset The set to be considered as the superset. + * @returns {boolean} + */ +// eslint-disable-next-line import/prefer-default-export +export const isSubset = (subset, superset) => + Array.from(subset).every(value => superset.has(value)); diff --git a/ee/app/assets/javascripts/security_dashboard/components/instance_security_dashboard.vue b/ee/app/assets/javascripts/security_dashboard/components/instance_security_dashboard.vue index ceeca2a0f78db990385cabdab8f491095e9167b7..b4c141fd8c9b09b48f20e37a8f0f26c28d85f0a8 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/instance_security_dashboard.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/instance_security_dashboard.vue @@ -2,6 +2,7 @@ import { mapActions, mapState } from 'vuex'; import { GlButton, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; +import ProjectManager from './project_manager.vue'; import SecurityDashboard from './app.vue'; export default { @@ -11,6 +12,7 @@ export default { GlEmptyState, GlLink, GlLoadingIcon, + ProjectManager, SecurityDashboard, }, props: { @@ -26,7 +28,11 @@ export default { type: String, required: true, }, - projectsEndpoint: { + projectAddEndpoint: { + type: String, + required: true, + }, + projectListEndpoint: { type: String, required: true, }, @@ -54,7 +60,7 @@ export default { }; }, computed: { - ...mapState('projects', ['projects']), + ...mapState('projectSelector', ['projects']), toggleButtonProps() { return this.showProjectSelector ? { @@ -71,7 +77,10 @@ export default { }, }, created() { - this.setProjectsEndpoint(this.projectsEndpoint); + this.setProjectEndpoints({ + add: this.projectAddEndpoint, + list: this.projectListEndpoint, + }); this.fetchProjects() // Failure to fetch projects will be handled in the store, so do nothing here. .catch(() => {}) @@ -80,7 +89,7 @@ export default { }); }, methods: { - ...mapActions('projects', ['setProjectsEndpoint', 'fetchProjects']), + ...mapActions('projectSelector', ['setProjectEndpoints', 'fetchProjects']), toggleProjectSelector() { this.showProjectSelector = !this.showProjectSelector; }, @@ -102,9 +111,7 @@ export default { diff --git a/ee/app/assets/javascripts/security_dashboard/components/project_list.vue b/ee/app/assets/javascripts/security_dashboard/components/project_list.vue index d7499ba0a998d481e0d4070b8b4135831d4d995c..afbd5c193566448ff8d1fd024b3b44d2cd3fecb5 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/project_list.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/project_list.vue @@ -19,6 +19,10 @@ export default { type: Array, required: true, }, + showLoadingIndicator: { + type: Boolean, + required: true, + }, }, methods: { projectRemoved(project) { @@ -31,10 +35,11 @@ export default { diff --git a/ee/app/assets/javascripts/security_dashboard/store/modules/filters/actions.js b/ee/app/assets/javascripts/security_dashboard/store/modules/filters/actions.js index d7f4cf556db2c89d55ffa0775d5c27ca6ba4df91..a21ee78cba3996880cc32b722bd4d8c53928481e 100644 --- a/ee/app/assets/javascripts/security_dashboard/store/modules/filters/actions.js +++ b/ee/app/assets/javascripts/security_dashboard/store/modules/filters/actions.js @@ -1,18 +1,25 @@ import Tracking from '~/tracking'; import { getParameterValues } from '~/lib/utils/url_utility'; import * as types from './mutation_types'; +import { ALL } from './constants'; +import { hasValidSelection } from './utils'; -export const setFilter = ({ commit }, payload) => { - commit(types.SET_FILTER, payload); +export const setFilter = ({ commit }, { filterId, optionId, lazy = false }) => { + commit(types.SET_FILTER, { filterId, optionId, lazy }); Tracking.event(document.body.dataset.page, 'set_filter', { - label: payload.filterId, - value: payload.optionId, + label: filterId, + value: optionId, }); }; -export const setFilterOptions = ({ commit }, payload) => { - commit(types.SET_FILTER_OPTIONS, payload); +export const setFilterOptions = ({ commit, state }, { filterId, options, lazy = false }) => { + commit(types.SET_FILTER_OPTIONS, { filterId, options }); + + const { selection } = state.filters.find(({ id }) => id === filterId); + if (!hasValidSelection({ selection, options })) { + commit(types.SET_FILTER, { filterId, optionId: ALL, lazy }); + } }; export const setAllFilters = ({ commit }, payload) => { diff --git a/ee/app/assets/javascripts/security_dashboard/store/modules/filters/utils.js b/ee/app/assets/javascripts/security_dashboard/store/modules/filters/utils.js index d7269527218583808702991eaeb4311c4ed84f1b..3b549ad2262f0e9569a8fcc4555197cc988d6900 100644 --- a/ee/app/assets/javascripts/security_dashboard/store/modules/filters/utils.js +++ b/ee/app/assets/javascripts/security_dashboard/store/modules/filters/utils.js @@ -1,4 +1,13 @@ +import { isSubset } from '~/lib/utils/set'; import { ALL } from './constants'; -// eslint-disable-next-line import/prefer-default-export export const isBaseFilterOption = id => id === ALL; + +/** + * Returns whether or not the given state filter has a valid selection, + * considering its available options. + * @param {Object} filter The filter from the state to check. + * @returns boolean + */ +export const hasValidSelection = ({ selection, options }) => + isSubset(selection, new Set(options.map(({ id }) => id))); diff --git a/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/actions.js b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/actions.js index f016f1a1a9ae1eea8b8d234e205e197c897a78ed..d8143e05da17a5f5501019b0bea76d047c26a7e3 100644 --- a/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/actions.js +++ b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/actions.js @@ -36,7 +36,8 @@ export const addProjects = ({ state, dispatch }) => { project_ids: state.selectedProjects.map(p => p.id), }) .then(response => dispatch('receiveAddProjectsSuccess', response.data)) - .catch(() => dispatch('receiveAddProjectsError')); + .catch(() => dispatch('receiveAddProjectsError')) + .finally(() => dispatch('clearSearchResults')); }; export const requestAddProjects = ({ commit }) => { diff --git a/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/getters.js b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..c12bf3f19bc4c2992a5e270cb56d5377dff9036d --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/getters.js @@ -0,0 +1,7 @@ +export const canAddProjects = ({ isAddingProjects, selectedProjects }) => + !isAddingProjects && selectedProjects.length > 0; + +export const isSearchingProjects = ({ searchCount }) => searchCount > 0; + +export const isUpdatingProjects = ({ isAddingProjects, isLoadingProjects, isRemovingProject }) => + isAddingProjects || isLoadingProjects || isRemovingProject; diff --git a/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/index.js b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/index.js index 636092ce1a9aab25cda419c7e2c9bb1d6145029e..3e2701435b377175b61dcc3b582b86894c311a9d 100644 --- a/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/index.js +++ b/ee/app/assets/javascripts/security_dashboard/store/modules/project_selector/index.js @@ -1,10 +1,12 @@ import state from './state'; import mutations from './mutations'; import * as actions from './actions'; +import * as getters from './getters'; export default () => ({ namespaced: true, state, mutations, actions, + getters, }); diff --git a/ee/app/assets/javascripts/security_dashboard/store/plugins/mediator.js b/ee/app/assets/javascripts/security_dashboard/store/plugins/mediator.js index 460e994da3b3b3a94d9207a5a3ac05f57c38c468..e0fe496468642a421be1c1294bcb87692c56302f 100644 --- a/ee/app/assets/javascripts/security_dashboard/store/plugins/mediator.js +++ b/ee/app/assets/javascripts/security_dashboard/store/plugins/mediator.js @@ -7,7 +7,7 @@ export default store => { store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', payload); }; - store.subscribe(({ type }) => { + store.subscribe(({ type, payload }) => { switch (type) { // SET_ALL_FILTERS mutations are triggered by navigation events, in such case we // want to preserve the page number that was set in the sync_with_router plugin @@ -21,7 +21,9 @@ export default store => { // in that case we want to reset the page number case `filters/${filtersMutationTypes.SET_FILTER}`: case `filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`: { - refreshVulnerabilities(store.getters['filters/activeFilters']); + if (!payload.lazy) { + refreshVulnerabilities(store.getters['filters/activeFilters']); + } break; } default: diff --git a/ee/app/assets/javascripts/security_dashboard/store/plugins/project_selector.js b/ee/app/assets/javascripts/security_dashboard/store/plugins/project_selector.js new file mode 100644 index 0000000000000000000000000000000000000000..3cc6bd6fb1ff92621e23ad3915a7cbe14cab6802 --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/store/plugins/project_selector.js @@ -0,0 +1,23 @@ +import projectSelectorModule from '../modules/project_selector'; +import * as projectSelectorMutationTypes from '../modules/project_selector/mutation_types'; +import { BASE_FILTERS } from '../modules/filters/constants'; + +export default store => { + store.registerModule('projectSelector', projectSelectorModule()); + + store.subscribe(({ type, payload }) => { + if (type === `projectSelector/${projectSelectorMutationTypes.RECEIVE_PROJECTS_SUCCESS}`) { + store.dispatch('filters/setFilterOptions', { + filterId: 'project_id', + options: [ + BASE_FILTERS.project_id, + ...payload.map(({ name, id }) => ({ + name, + id: id.toString(), + })), + ], + lazy: true, + }); + } + }); +}; diff --git a/ee/spec/frontend/security_dashboard/components/instance_security_dashboard_spec.js b/ee/spec/frontend/security_dashboard/components/instance_security_dashboard_spec.js index a41e58ff063499114591c8b7f83de51bc6339e85..d1f239eafa3ccdbf64fc893062a422341322da38 100644 --- a/ee/spec/frontend/security_dashboard/components/instance_security_dashboard_spec.js +++ b/ee/spec/frontend/security_dashboard/components/instance_security_dashboard_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import InstanceSecurityDashboard from 'ee/security_dashboard/components/instance_security_dashboard.vue'; import SecurityDashboard from 'ee/security_dashboard/components/app.vue'; +import ProjectManager from 'ee/security_dashboard/components/project_manager.vue'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -10,7 +11,8 @@ localVue.use(Vuex); const dashboardDocumentation = '/help/docs'; const emptyStateSvgPath = '/svgs/empty.svg'; const emptyDashboardStateSvgPath = '/svgs/empty-dash.svg'; -const projectsEndpoint = '/projects'; +const projectAddEndpoint = '/projects/add'; +const projectListEndpoint = '/projects/list'; const vulnerabilitiesEndpoint = '/vulnerabilities'; const vulnerabilitiesCountEndpoint = '/vulnerabilities_summary'; const vulnerabilitiesHistoryEndpoint = '/vulnerabilities_history'; @@ -24,11 +26,11 @@ describe('Instance Security Dashboard component', () => { const factory = ({ projects = [] } = {}) => { store = new Vuex.Store({ modules: { - projects: { + projectSelector: { namespaced: true, actions: { fetchProjects() {}, - setProjectsEndpoint() {}, + setProjectEndpoints() {}, }, state: { projects, @@ -53,7 +55,8 @@ describe('Instance Security Dashboard component', () => { dashboardDocumentation, emptyStateSvgPath, emptyDashboardStateSvgPath, - projectsEndpoint, + projectAddEndpoint, + projectListEndpoint, vulnerabilitiesEndpoint, vulnerabilitiesCountEndpoint, vulnerabilitiesHistoryEndpoint, @@ -85,6 +88,7 @@ describe('Instance Security Dashboard component', () => { expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(SecurityDashboard).exists()).toBe(false); + expect(wrapper.find(ProjectManager).exists()).toBe(true); }; afterEach(() => { @@ -98,8 +102,14 @@ describe('Instance Security Dashboard component', () => { it('dispatches the expected actions', () => { expect(store.dispatch.mock.calls).toEqual([ - ['projects/setProjectsEndpoint', projectsEndpoint], - ['projects/fetchProjects', undefined], + [ + 'projectSelector/setProjectEndpoints', + { + add: projectAddEndpoint, + list: projectListEndpoint, + }, + ], + ['projectSelector/fetchProjects', undefined], ]); }); @@ -108,6 +118,7 @@ describe('Instance Security Dashboard component', () => { expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(SecurityDashboard).exists()).toBe(false); + expect(wrapper.find(ProjectManager).exists()).toBe(false); }); }); @@ -121,6 +132,7 @@ describe('Instance Security Dashboard component', () => { expect(findProjectSelectorToggleButton().exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(SecurityDashboard).exists()).toBe(false); + expect(wrapper.find(ProjectManager).exists()).toBe(false); expectComponentWithProps(GlEmptyState, { svgPath: emptyStateSvgPath, @@ -146,6 +158,7 @@ describe('Instance Security Dashboard component', () => { expect(findProjectSelectorToggleButton().exists()).toBe(true); expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(ProjectManager).exists()).toBe(false); expectComponentWithProps(SecurityDashboard, { dashboardDocumentation, diff --git a/ee/spec/frontend/security_dashboard/components/project_list_spec.js b/ee/spec/frontend/security_dashboard/components/project_list_spec.js index 81ffe80da763ffb76ef17d140fec590f54fe1212..21956512fdb3b17f39d0a1ad0b04f1780c7ae03d 100644 --- a/ee/spec/frontend/security_dashboard/components/project_list_spec.js +++ b/ee/spec/frontend/security_dashboard/components/project_list_spec.js @@ -1,6 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlBadge, GlButton } from '@gitlab/ui'; +import { GlBadge, GlButton, GlLoadingIcon } from '@gitlab/ui'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import ProjectList from 'ee/security_dashboard/components/project_list.vue'; @@ -14,12 +14,13 @@ const generateMockProjects = (projectsCount, mockProject = {}) => describe('Project List component', () => { let wrapper; - const factory = ({ projects = [], stubs = {} } = {}) => { + const factory = ({ projects = [], stubs = {}, showLoadingIndicator = false } = {}) => { wrapper = shallowMount(ProjectList, { stubs, localVue, propsData: { projects, + showLoadingIndicator, }, sync: false, }); @@ -39,6 +40,18 @@ describe('Project List component', () => { ); }); + it('does not show a loading indicator when showLoadingIndicator = false', () => { + factory(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('shows a loading indicator when showLoadingIndicator = true', () => { + factory({ showLoadingIndicator: true }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + it.each([0, 1, 2])( 'renders a list of projects and displays a count of how many there are', projectsCount => { diff --git a/ee/spec/frontend/security_dashboard/components/project_manager_spec.js b/ee/spec/frontend/security_dashboard/components/project_manager_spec.js index f5e8bd1c0e554f7ececb52f0f86d0bdd35f6e3ef..4fe205946e6ad2fc193de1a46aa294826c122d6a 100644 --- a/ee/spec/frontend/security_dashboard/components/project_manager_spec.js +++ b/ee/spec/frontend/security_dashboard/components/project_manager_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import createDefaultState from 'ee/security_dashboard/store/modules/project_selector/state'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectManager from 'ee/security_dashboard/components/project_manager.vue'; @@ -17,7 +17,12 @@ describe('Project Manager component', () => { let store; let wrapper; - const factory = ({ stateOverrides = {} } = {}) => { + const factory = ({ + state = {}, + canAddProjects = false, + isSearchingProjects = false, + isUpdatingProjects = false, + } = {}) => { storeOptions = { modules: { projectSelector: { @@ -30,9 +35,14 @@ describe('Project Manager component', () => { toggleSelectedProject: jest.fn(), removeProject: jest.fn(), }, + getters: { + canAddProjects: jest.fn().mockReturnValue(canAddProjects), + isSearchingProjects: jest.fn().mockReturnValue(isSearchingProjects), + isUpdatingProjects: jest.fn().mockReturnValue(isUpdatingProjects), + }, state: { ...createDefaultState(), - ...stateOverrides, + ...state, }, }, }, @@ -51,7 +61,6 @@ describe('Project Manager component', () => { const getMockActionDispatchedPayload = actionName => getMockAction(actionName).mock.calls[0][1]; const getAddProjectsButton = () => wrapper.find(GlButton); - const getLoadingIcon = () => wrapper.find(GlLoadingIcon); const getProjectList = () => wrapper.find(ProjectList); const getProjectSelector = () => wrapper.find(ProjectSelector); @@ -87,18 +96,11 @@ describe('Project Manager component', () => { expect(getAddProjectsButton().attributes('disabled')).toBe('true'); }); - it.each` - actionName | payload - ${'addProjects'} | ${undefined} - ${'clearSearchResults'} | ${undefined} - `( - 'dispatches the correct actions when the add-projects button has been clicked', - ({ actionName, payload }) => { - getAddProjectsButton().vm.$emit('click'); + it('dispatches the addProjects when the "Add projects" button has been clicked', () => { + getAddProjectsButton().vm.$emit('click'); - expect(getMockActionDispatchedPayload(actionName)).toBe(payload); - }, - ); + expect(getMockAction('addProjects')).toHaveBeenCalled(); + }); it('contains a project-list component', () => { expect(getProjectList().exists()).toBe(true); @@ -116,26 +118,26 @@ describe('Project Manager component', () => { }); }); - describe('given the state changes', () => { + describe('given the store state', () => { it.each` - state | projectSelectorPropName | expectedPropValue - ${{ searchCount: 1 }} | ${'showLoadingIndicator'} | ${true} - ${{ selectedProjects: ['bar'] }} | ${'selectedProjects'} | ${['bar']} - ${{ projectSearchResults: ['foo'] }} | ${'projectSearchResults'} | ${['foo']} - ${{ messages: { noResults: true } }} | ${'showNoResultsMessage'} | ${true} - ${{ messages: { searchError: true } }} | ${'showSearchErrorMessage'} | ${true} - ${{ messages: { minimumQuery: true } }} | ${'showMinimumSearchQueryMessage'} | ${true} + config | projectSelectorPropName | expectedPropValue + ${{ isSearchingProjects: true }} | ${'showLoadingIndicator'} | ${true} + ${{ state: { selectedProjects: ['bar'] } }} | ${'selectedProjects'} | ${['bar']} + ${{ state: { projectSearchResults: ['foo'] } }} | ${'projectSearchResults'} | ${['foo']} + ${{ state: { messages: { noResults: true } } }} | ${'showNoResultsMessage'} | ${true} + ${{ state: { messages: { searchError: true } } }} | ${'showSearchErrorMessage'} | ${true} + ${{ state: { messages: { minimumQuery: true } } }} | ${'showMinimumSearchQueryMessage'} | ${true} `( - 'passes the correct prop-values to the project-selector', - ({ state, projectSelectorPropName, expectedPropValue }) => { - factory({ stateOverrides: state }); + 'passes $projectSelectorPropName = $expectedPropValue to the project-selector', + ({ config, projectSelectorPropName, expectedPropValue }) => { + factory(config); expect(getProjectSelector().props(projectSelectorPropName)).toEqual(expectedPropValue); }, ); - it('enables the add-projects button when at least one projects is selected', () => { - factory({ stateOverrides: { selectedProjects: [{}] } }); + it('enables the add-projects button when projects can be added', () => { + factory({ canAddProjects: true }); expect(getAddProjectsButton().attributes('disabled')).toBe(undefined); }); @@ -143,21 +145,18 @@ describe('Project Manager component', () => { it('passes the list of projects to the project-list component', () => { const projects = [{}]; - factory({ stateOverrides: { projects } }); + factory({ state: { projects } }); expect(getProjectList().props('projects')).toBe(projects); }); - it('toggles the loading icon when a project is being added', () => { - factory({ stateOverrides: { isAddingProjects: false } }); - - expect(getLoadingIcon().exists()).toBe(false); - - store.state.projectSelector.isAddingProjects = true; + it.each([false, true])( + 'passes showLoadingIndicator = %p to the project-list component', + isUpdatingProjects => { + factory({ isUpdatingProjects }); - return wrapper.vm.$nextTick().then(() => { - expect(getLoadingIcon().exists()).toBe(true); - }); - }); + expect(getProjectList().props('showLoadingIndicator')).toBe(isUpdatingProjects); + }, + ); }); }); diff --git a/ee/spec/frontend/security_dashboard/store/filters/utils_spec.js b/ee/spec/frontend/security_dashboard/store/filters/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ebde83ec0cd9b6ea644b6c70859e3e0e9eee0dc9 --- /dev/null +++ b/ee/spec/frontend/security_dashboard/store/filters/utils_spec.js @@ -0,0 +1,29 @@ +import { hasValidSelection } from 'ee/security_dashboard/store/modules/filters/utils'; + +describe('filters module utils', () => { + describe('hasValidSelection', () => { + describe.each` + selection | options | expected + ${[]} | ${[]} | ${true} + ${[]} | ${['foo']} | ${true} + ${['foo']} | ${['foo']} | ${true} + ${['foo']} | ${['foo', 'bar']} | ${true} + ${['bar', 'foo']} | ${['foo', 'bar']} | ${true} + ${['foo']} | ${[]} | ${false} + ${['foo']} | ${['bar']} | ${false} + ${['foo', 'bar']} | ${['foo']} | ${false} + `('given selection $selection and options $options', ({ selection, options, expected }) => { + let filter; + beforeEach(() => { + filter = { + selection, + options: options.map(id => ({ id })), + }; + }); + + it(`return ${expected}`, () => { + expect(hasValidSelection(filter)).toBe(expected); + }); + }); + }); +}); diff --git a/ee/spec/frontend/security_dashboard/store/modules/project_selector/actions_spec.js b/ee/spec/frontend/security_dashboard/store/modules/project_selector/actions_spec.js index 56b1086200c83f4d1b628ac7fcc0af107e7fb918..a78fe967392748a217d9386cad60b7fb582b1423 100644 --- a/ee/spec/frontend/security_dashboard/store/modules/project_selector/actions_spec.js +++ b/ee/spec/frontend/security_dashboard/store/modules/project_selector/actions_spec.js @@ -91,6 +91,9 @@ describe('projectSelector actions', () => { type: 'receiveAddProjectsSuccess', payload: mockResponse, }, + { + type: 'clearSearchResults', + }, ], ); }); @@ -103,7 +106,11 @@ describe('projectSelector actions', () => { null, state, [], - [{ type: 'requestAddProjects' }, { type: 'receiveAddProjectsError' }], + [ + { type: 'requestAddProjects' }, + { type: 'receiveAddProjectsError' }, + { type: 'clearSearchResults' }, + ], ); }); }); diff --git a/ee/spec/frontend/security_dashboard/store/modules/project_selector/getters_spec.js b/ee/spec/frontend/security_dashboard/store/modules/project_selector/getters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..69f28b539c82550d3dd8dca22eb6befe5690fad2 --- /dev/null +++ b/ee/spec/frontend/security_dashboard/store/modules/project_selector/getters_spec.js @@ -0,0 +1,73 @@ +import createState from 'ee/security_dashboard/store/modules/project_selector/state'; +import * as getters from 'ee/security_dashboard/store/modules/project_selector/getters'; + +describe('project selector module getters', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('canAddProjects', () => { + describe.each` + isAddingProjects | selectedProjectCount | expected + ${true} | ${0} | ${false} + ${true} | ${1} | ${false} + ${false} | ${0} | ${false} + ${false} | ${1} | ${true} + `( + 'given isAddingProjects = $isAddingProjects and $selectedProjectCount selected projects', + ({ isAddingProjects, selectedProjectCount, expected }) => { + beforeEach(() => { + state = { + ...state, + isAddingProjects, + selectedProjects: Array(selectedProjectCount).fill({}), + }; + }); + + it(`returns ${expected}`, () => { + expect(getters.canAddProjects(state)).toBe(expected); + }); + }, + ); + }); + + describe('isSearchingProjects', () => { + describe.each` + searchCount | expected + ${0} | ${false} + ${1} | ${true} + ${2} | ${true} + `('given searchCount = $searchCount', ({ searchCount, expected }) => { + beforeEach(() => { + state = { ...state, searchCount }; + }); + + it(`returns ${expected}`, () => { + expect(getters.isSearchingProjects(state)).toBe(expected); + }); + }); + }); + + describe('isUpdatingProjects', () => { + describe.each` + isAddingProjects | isRemovingProject | isLoadingProjects | expected + ${false} | ${false} | ${false} | ${false} + ${true} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${true} + ${false} | ${false} | ${true} | ${true} + `( + 'given isAddingProjects = $isAddingProjects, isRemovingProject = $isRemovingProject, isLoadingProjects = $isLoadingProjects', + ({ isAddingProjects, isRemovingProject, isLoadingProjects, expected }) => { + beforeEach(() => { + state = { ...state, isAddingProjects, isRemovingProject, isLoadingProjects }; + }); + + it(`returns ${expected}`, () => { + expect(getters.isUpdatingProjects(state)).toBe(expected); + }); + }, + ); + }); +}); diff --git a/ee/spec/frontend/security_dashboard/store/plugins/project_selector_spec.js b/ee/spec/frontend/security_dashboard/store/plugins/project_selector_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6b16168f4b37f8df44490df812ac7aa2159034ab --- /dev/null +++ b/ee/spec/frontend/security_dashboard/store/plugins/project_selector_spec.js @@ -0,0 +1,40 @@ +import Vuex from 'vuex'; +import createStore from 'ee/security_dashboard/store'; +import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants'; +import projectSelectorModule from 'ee/security_dashboard/store/modules/project_selector'; +import projectSelectorPlugin from 'ee/security_dashboard/store/plugins/project_selector'; +import * as projectSelectorMutationTypes from 'ee/security_dashboard/store/modules/projects/mutation_types'; + +describe('project selector plugin', () => { + let store; + + beforeEach(() => { + jest.spyOn(Vuex.Store.prototype, 'registerModule'); + store = createStore({ plugins: [projectSelectorPlugin] }); + }); + + it('registers the project selector module on the store', () => { + expect(Vuex.Store.prototype.registerModule).toHaveBeenCalledTimes(1); + expect(Vuex.Store.prototype.registerModule).toHaveBeenCalledWith( + 'projectSelector', + projectSelectorModule(), + ); + }); + + it('sets project filter options with lazy = true after projects have been received', () => { + jest.spyOn(store, 'dispatch').mockImplementation(); + const projects = [{ name: 'foo', id: '1' }]; + + store.commit( + `projectSelector/${projectSelectorMutationTypes.RECEIVE_PROJECTS_SUCCESS}`, + projects, + ); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith('filters/setFilterOptions', { + filterId: 'project_id', + options: [BASE_FILTERS.project_id, ...projects], + lazy: true, + }); + }); +}); diff --git a/ee/spec/javascripts/security_dashboard/store/filters/actions_spec.js b/ee/spec/javascripts/security_dashboard/store/filters/actions_spec.js index 041162737e20ff6bd01d7752779c91896b6812e8..6fec50fd811cc7a97b8fe7487bdee543b4156c1a 100644 --- a/ee/spec/javascripts/security_dashboard/store/filters/actions_spec.js +++ b/ee/spec/javascripts/security_dashboard/store/filters/actions_spec.js @@ -3,6 +3,7 @@ import Tracking from '~/tracking'; import createState from 'ee/security_dashboard/store/modules/filters/state'; import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types'; import module, * as actions from 'ee/security_dashboard/store/modules/filters/actions'; +import { ALL } from 'ee/security_dashboard/store/modules/filters/constants'; describe('filters actions', () => { beforeEach(() => { @@ -12,7 +13,26 @@ describe('filters actions', () => { describe('setFilter', () => { it('should commit the SET_FILTER mutuation', done => { const state = createState(); - const payload = { filterId: 'type', optionId: 'sast' }; + const payload = { filterId: 'report_type', optionId: 'sast' }; + + testAction( + actions.setFilter, + payload, + state, + [ + { + type: types.SET_FILTER, + payload: { ...payload, lazy: false }, + }, + ], + [], + done, + ); + }); + + it('should commit the SET_FILTER mutuation passing through lazy = true', done => { + const state = createState(); + const payload = { filterId: 'report_type', optionId: 'sast', lazy: true }; testAction( actions.setFilter, @@ -33,7 +53,26 @@ describe('filters actions', () => { describe('setFilterOptions', () => { it('should commit the SET_FILTER_OPTIONS mutuation', done => { const state = createState(); - const payload = { filterId: 'project', options: [] }; + const payload = { filterId: 'project_id', options: [{ id: ALL }] }; + + testAction( + actions.setFilterOptions, + payload, + state, + [ + { + type: types.SET_FILTER_OPTIONS, + payload, + }, + ], + [], + done, + ); + }); + + it('should commit the SET_FILTER_OPTIONS and SET_FILTER mutation when filter selection is invalid', done => { + const state = createState(); + const payload = { filterId: 'project_id', options: [{ id: 'foo' }] }; testAction( actions.setFilterOptions, @@ -44,6 +83,40 @@ describe('filters actions', () => { type: types.SET_FILTER_OPTIONS, payload, }, + { + type: types.SET_FILTER, + payload: jasmine.objectContaining({ + filterId: 'project_id', + optionId: ALL, + }), + }, + ], + [], + done, + ); + }); + + it('should commit the SET_FILTER_OPTIONS and SET_FILTER mutation when filter selection is invalid, passing the lazy flag', done => { + const state = createState(); + const payload = { filterId: 'project_id', options: [{ id: 'foo' }] }; + + testAction( + actions.setFilterOptions, + { ...payload, lazy: true }, + state, + [ + { + type: types.SET_FILTER_OPTIONS, + payload, + }, + { + type: types.SET_FILTER, + payload: { + filterId: 'project_id', + optionId: ALL, + lazy: true, + }, + }, ], [], done, diff --git a/ee/spec/javascripts/security_dashboard/store/plugins/mediator_spec.js b/ee/spec/javascripts/security_dashboard/store/plugins/mediator_spec.js index aa14edd3ffd8fb0955ad9b614337739449a897e8..514b9db12757f5264495d585012c9b562db20fa6 100644 --- a/ee/spec/javascripts/security_dashboard/store/plugins/mediator_spec.js +++ b/ee/spec/javascripts/security_dashboard/store/plugins/mediator_spec.js @@ -6,11 +6,10 @@ describe('mediator', () => { beforeEach(() => { store = createStore(); + spyOn(store, 'dispatch'); }); it('triggers fetching vulnerabilities after one filter changes', () => { - spyOn(store, 'dispatch'); - const activeFilters = store.getters['filters/activeFilters']; store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, {}); @@ -32,9 +31,13 @@ describe('mediator', () => { ); }); - it('triggers fetching vulnerabilities after filters change', () => { - spyOn(store, 'dispatch'); + it('does not fetch vulnerabilities after one filter changes with lazy = true', () => { + store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, { lazy: true }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + it('triggers fetching vulnerabilities after filters change', () => { const payload = { ...store.getters['filters/activeFilters'], page: store.state.vulnerabilities.pageInfo.page, @@ -57,8 +60,6 @@ describe('mediator', () => { }); it('triggers fetching vulnerabilities after "Hide dismissed" toggle changes', () => { - spyOn(store, 'dispatch'); - const activeFilters = store.getters['filters/activeFilters']; store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, {}); @@ -79,4 +80,10 @@ describe('mediator', () => { activeFilters, ); }); + + it('does not fetch vulnerabilities after "Hide dismissed" toggle changes with lazy = true', () => { + store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, { lazy: true }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/lib/utils/set_spec.js b/spec/frontend/lib/utils/set_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7636a1c634cfd33fde050cf5ee36919f00e3c016 --- /dev/null +++ b/spec/frontend/lib/utils/set_spec.js @@ -0,0 +1,19 @@ +import { isSubset } from '~/lib/utils/set'; + +describe('utils/set', () => { + describe('isSubset', () => { + it.each` + subset | superset | expected + ${new Set()} | ${new Set()} | ${true} + ${new Set()} | ${new Set([1])} | ${true} + ${new Set([1])} | ${new Set([1])} | ${true} + ${new Set([1, 3])} | ${new Set([1, 2, 3])} | ${true} + ${new Set([1])} | ${new Set()} | ${false} + ${new Set([1])} | ${new Set([2])} | ${false} + ${new Set([7, 8, 9])} | ${new Set([1, 2, 3])} | ${false} + ${new Set([1, 2, 3, 4])} | ${new Set([1, 2, 3])} | ${false} + `('isSubset($subset, $superset) === $expected', ({ subset, superset, expected }) => { + expect(isSubset(subset, superset)).toBe(expected); + }); + }); +});