Commit fcc1d70c authored by Mark Florian's avatar Mark Florian Committed by Clement Ho

Integrate Instance Security Dashboard components

Part of the [Instance Security Dashboard MVC][1].

This improves the integration between the existing frontend parts of the
Instance Security Dashboard and gets it closer the the target UX.

In particular:

- The `ProjectManager` component is now included in the root component.
- The security dashboard store's mediator is now aware of a `lazy` flag
  for some mutations, allowing updates to filters *without* triggering
  an immediate re-fetch of vulnerabilities. This is to avoid expensive
  requests from firing when they're unnecessary.
- A new security dashboard store plugin registers the
  `projectSelector` store module, and sets up a mutation subscription to
  sync the dashboard's project filter dropdown with whatever projects
  have been chosen by the user in the `ProjectManager`.
- The correct module name (`projectSelector`) is bound in the root
  component.
- The `filters/setFilterOptions` action now updates the given filter's
  selection if the new set of options would otherwise result in an invalid
  selection. A nice side-effect of this is that for security dashboards
  with a router, if the user navigates to a URL with an invalid
  `project_id` in the URL, the project filter gets reset to 'All'.
  Previously, the dropdown would just render in an inconsistent state.
- A race condition was fixed, which caused invalid projects' names not
  to be displayed in the flash after attempting to add them with the
  `ProjectManager`.
- The "Add projects" button is now disabled when already adding projects
  (to prevent concurrent requests).
- The project list's loading spinner was moved/re-styled to avoid the
  list jumping around.
- Various derived state was refactored into store getters, e.g.,
  `isUpdatingProjects`.
- Various unused imported components were removed.
- The initial loading spinner's padding was increased.
- Various styling tweaks to conform better with design specifications.

[1]: https://gitlab.com/gitlab-org/gitlab/issues/6953
parent 2983980a
/**
* 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));
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlButton, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ProjectManager from './project_manager.vue';
import SecurityDashboard from './app.vue'; import SecurityDashboard from './app.vue';
export default { export default {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
GlEmptyState, GlEmptyState,
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
ProjectManager,
SecurityDashboard, SecurityDashboard,
}, },
props: { props: {
...@@ -26,7 +28,11 @@ export default { ...@@ -26,7 +28,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectsEndpoint: { projectAddEndpoint: {
type: String,
required: true,
},
projectListEndpoint: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -54,7 +60,7 @@ export default { ...@@ -54,7 +60,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState('projects', ['projects']), ...mapState('projectSelector', ['projects']),
toggleButtonProps() { toggleButtonProps() {
return this.showProjectSelector return this.showProjectSelector
? { ? {
...@@ -71,7 +77,10 @@ export default { ...@@ -71,7 +77,10 @@ export default {
}, },
}, },
created() { created() {
this.setProjectsEndpoint(this.projectsEndpoint); this.setProjectEndpoints({
add: this.projectAddEndpoint,
list: this.projectListEndpoint,
});
this.fetchProjects() this.fetchProjects()
// Failure to fetch projects will be handled in the store, so do nothing here. // Failure to fetch projects will be handled in the store, so do nothing here.
.catch(() => {}) .catch(() => {})
...@@ -80,7 +89,7 @@ export default { ...@@ -80,7 +89,7 @@ export default {
}); });
}, },
methods: { methods: {
...mapActions('projects', ['setProjectsEndpoint', 'fetchProjects']), ...mapActions('projectSelector', ['setProjectEndpoints', 'fetchProjects']),
toggleProjectSelector() { toggleProjectSelector() {
this.showProjectSelector = !this.showProjectSelector; this.showProjectSelector = !this.showProjectSelector;
}, },
...@@ -102,9 +111,7 @@ export default { ...@@ -102,9 +111,7 @@ export default {
</header> </header>
<template v-if="isInitialized"> <template v-if="isInitialized">
<section v-if="showProjectSelector" class="js-dashboard-project-selector"> <project-manager v-if="showProjectSelector" />
<h3>{{ s__('SecurityDashboard|Add or remove projects from your dashboard') }}</h3>
</section>
<template v-else> <template v-else>
<gl-empty-state <gl-empty-state
...@@ -142,6 +149,6 @@ export default { ...@@ -142,6 +149,6 @@ export default {
</template> </template>
</template> </template>
<gl-loading-icon v-else size="md" /> <gl-loading-icon v-else size="md" class="mt-4" />
</article> </article>
</template> </template>
...@@ -19,6 +19,10 @@ export default { ...@@ -19,6 +19,10 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
showLoadingIndicator: {
type: Boolean,
required: true,
},
}, },
methods: { methods: {
projectRemoved(project) { projectRemoved(project) {
...@@ -31,10 +35,11 @@ export default { ...@@ -31,10 +35,11 @@ export default {
<template> <template>
<section> <section>
<div> <div>
<h3 class="h5 text-secondary border-bottom mb-3 pb-2"> <h4 class="h5 font-weight-bold text-secondary border-bottom mb-3 pb-2">
{{ s__('SecurityDashboard|Projects added') }} {{ s__('SecurityDashboard|Projects added') }}
<gl-badge>{{ projects.length }}</gl-badge> <gl-badge pill class="font-weight-bold">{{ projects.length }}</gl-badge>
</h3> <gl-loading-icon v-if="showLoadingIndicator" size="sm" class="float-right" />
</h4>
<ul v-if="projects.length" class="list-unstyled"> <ul v-if="projects.length" class="list-unstyled">
<li <li
v-for="project in projects" v-for="project in projects"
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import { GlBadge, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectList from './project_list.vue'; import ProjectList from './project_list.vue';
export default { export default {
components: { components: {
GlBadge,
GlButton, GlButton,
GlLoadingIcon,
Icon,
ProjectList, ProjectList,
ProjectSelector, ProjectSelector,
}, },
computed: { computed: {
...mapState('projectSelector', [ ...mapState('projectSelector', [
'projects', 'projects',
'isAddingProjects',
'selectedProjects', 'selectedProjects',
'projectSearchResults', 'projectSearchResults',
'searchCount',
'messages', 'messages',
]), ]),
isSearchingProjects() { ...mapGetters('projectSelector', [
return this.searchCount > 0; 'canAddProjects',
}, 'isSearchingProjects',
hasProjectsSelected() { 'isUpdatingProjects',
return this.selectedProjects.length > 0; ]),
},
}, },
methods: { methods: {
...mapActions('projectSelector', [ ...mapActions('projectSelector', [
...@@ -41,10 +32,6 @@ export default { ...@@ -41,10 +32,6 @@ export default {
'setSearchQuery', 'setSearchQuery',
'removeProject', 'removeProject',
]), ]),
addProjectsAndClearSearchResults() {
this.addProjects();
this.clearSearchResults();
},
searched(query) { searched(query) {
this.setSearchQuery(query); this.setSearchQuery(query);
this.fetchSearchResults(); this.fetchSearchResults();
...@@ -63,9 +50,9 @@ export default { ...@@ -63,9 +50,9 @@ export default {
<section class="container"> <section class="container">
<div class="row justify-content-center mt-md-4"> <div class="row justify-content-center mt-md-4">
<div class="col col-lg-7"> <div class="col col-lg-7">
<h2 class="h5 border-bottom mb-4 pb-3"> <h3 class="text-3 font-weight-bold border-bottom mb-4 pb-3">
{{ s__('SecurityDashboard|Add or remove projects from your dashboard') }} {{ s__('SecurityDashboard|Add or remove projects from your dashboard') }}
</h2> </h3>
<div class="d-flex flex-column flex-md-row"> <div class="d-flex flex-column flex-md-row">
<project-selector <project-selector
class="flex-grow mr-md-2" class="flex-grow mr-md-2"
...@@ -79,11 +66,7 @@ export default { ...@@ -79,11 +66,7 @@ export default {
@projectClicked="projectClicked" @projectClicked="projectClicked"
/> />
<div class="mb-3"> <div class="mb-3">
<gl-button <gl-button :disabled="!canAddProjects" variant="success" @click="addProjects">
:disabled="!hasProjectsSelected"
variant="success"
@click="addProjectsAndClearSearchResults"
>
{{ s__('SecurityDashboard|Add projects') }} {{ s__('SecurityDashboard|Add projects') }}
</gl-button> </gl-button>
</div> </div>
...@@ -91,8 +74,12 @@ export default { ...@@ -91,8 +74,12 @@ export default {
</div> </div>
</div> </div>
<div class="row justify-content-center mt-md-3"> <div class="row justify-content-center mt-md-3">
<project-list :projects="projects" class="col col-lg-7" @projectRemoved="projectRemoved" /> <project-list
<gl-loading-icon v-if="isAddingProjects" size="sm" /> :projects="projects"
:show-loading-indicator="isUpdatingProjects"
class="col col-lg-7"
@projectRemoved="projectRemoved"
/>
</div> </div>
</section> </section>
</template> </template>
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { getParameterValues } from '~/lib/utils/url_utility'; import { getParameterValues } from '~/lib/utils/url_utility';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { ALL } from './constants';
import { hasValidSelection } from './utils';
export const setFilter = ({ commit }, payload) => { export const setFilter = ({ commit }, { filterId, optionId, lazy = false }) => {
commit(types.SET_FILTER, payload); commit(types.SET_FILTER, { filterId, optionId, lazy });
Tracking.event(document.body.dataset.page, 'set_filter', { Tracking.event(document.body.dataset.page, 'set_filter', {
label: payload.filterId, label: filterId,
value: payload.optionId, value: optionId,
}); });
}; };
export const setFilterOptions = ({ commit }, payload) => { export const setFilterOptions = ({ commit, state }, { filterId, options, lazy = false }) => {
commit(types.SET_FILTER_OPTIONS, payload); 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) => { export const setAllFilters = ({ commit }, payload) => {
......
import { isSubset } from '~/lib/utils/set';
import { ALL } from './constants'; import { ALL } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const isBaseFilterOption = id => id === ALL; 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)));
...@@ -36,7 +36,8 @@ export const addProjects = ({ state, dispatch }) => { ...@@ -36,7 +36,8 @@ export const addProjects = ({ state, dispatch }) => {
project_ids: state.selectedProjects.map(p => p.id), project_ids: state.selectedProjects.map(p => p.id),
}) })
.then(response => dispatch('receiveAddProjectsSuccess', response.data)) .then(response => dispatch('receiveAddProjectsSuccess', response.data))
.catch(() => dispatch('receiveAddProjectsError')); .catch(() => dispatch('receiveAddProjectsError'))
.finally(() => dispatch('clearSearchResults'));
}; };
export const requestAddProjects = ({ commit }) => { export const requestAddProjects = ({ commit }) => {
......
export const canAddProjects = ({ isAddingProjects, selectedProjects }) =>
!isAddingProjects && selectedProjects.length > 0;
export const isSearchingProjects = ({ searchCount }) => searchCount > 0;
export const isUpdatingProjects = ({ isAddingProjects, isLoadingProjects, isRemovingProject }) =>
isAddingProjects || isLoadingProjects || isRemovingProject;
import state from './state'; import state from './state';
import mutations from './mutations'; import mutations from './mutations';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
export default () => ({ export default () => ({
namespaced: true, namespaced: true,
state, state,
mutations, mutations,
actions, actions,
getters,
}); });
...@@ -7,7 +7,7 @@ export default store => { ...@@ -7,7 +7,7 @@ export default store => {
store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', payload); store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', payload);
}; };
store.subscribe(({ type }) => { store.subscribe(({ type, payload }) => {
switch (type) { switch (type) {
// SET_ALL_FILTERS mutations are triggered by navigation events, in such case we // 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 // want to preserve the page number that was set in the sync_with_router plugin
...@@ -21,7 +21,9 @@ export default store => { ...@@ -21,7 +21,9 @@ export default store => {
// in that case we want to reset the page number // in that case we want to reset the page number
case `filters/${filtersMutationTypes.SET_FILTER}`: case `filters/${filtersMutationTypes.SET_FILTER}`:
case `filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`: { case `filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`: {
if (!payload.lazy) {
refreshVulnerabilities(store.getters['filters/activeFilters']); refreshVulnerabilities(store.getters['filters/activeFilters']);
}
break; break;
} }
default: default:
......
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,
});
}
});
};
...@@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; ...@@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import InstanceSecurityDashboard from 'ee/security_dashboard/components/instance_security_dashboard.vue'; import InstanceSecurityDashboard from 'ee/security_dashboard/components/instance_security_dashboard.vue';
import SecurityDashboard from 'ee/security_dashboard/components/app.vue'; import SecurityDashboard from 'ee/security_dashboard/components/app.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -10,7 +11,8 @@ localVue.use(Vuex); ...@@ -10,7 +11,8 @@ localVue.use(Vuex);
const dashboardDocumentation = '/help/docs'; const dashboardDocumentation = '/help/docs';
const emptyStateSvgPath = '/svgs/empty.svg'; const emptyStateSvgPath = '/svgs/empty.svg';
const emptyDashboardStateSvgPath = '/svgs/empty-dash.svg'; const emptyDashboardStateSvgPath = '/svgs/empty-dash.svg';
const projectsEndpoint = '/projects'; const projectAddEndpoint = '/projects/add';
const projectListEndpoint = '/projects/list';
const vulnerabilitiesEndpoint = '/vulnerabilities'; const vulnerabilitiesEndpoint = '/vulnerabilities';
const vulnerabilitiesCountEndpoint = '/vulnerabilities_summary'; const vulnerabilitiesCountEndpoint = '/vulnerabilities_summary';
const vulnerabilitiesHistoryEndpoint = '/vulnerabilities_history'; const vulnerabilitiesHistoryEndpoint = '/vulnerabilities_history';
...@@ -24,11 +26,11 @@ describe('Instance Security Dashboard component', () => { ...@@ -24,11 +26,11 @@ describe('Instance Security Dashboard component', () => {
const factory = ({ projects = [] } = {}) => { const factory = ({ projects = [] } = {}) => {
store = new Vuex.Store({ store = new Vuex.Store({
modules: { modules: {
projects: { projectSelector: {
namespaced: true, namespaced: true,
actions: { actions: {
fetchProjects() {}, fetchProjects() {},
setProjectsEndpoint() {}, setProjectEndpoints() {},
}, },
state: { state: {
projects, projects,
...@@ -53,7 +55,8 @@ describe('Instance Security Dashboard component', () => { ...@@ -53,7 +55,8 @@ describe('Instance Security Dashboard component', () => {
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
emptyDashboardStateSvgPath, emptyDashboardStateSvgPath,
projectsEndpoint, projectAddEndpoint,
projectListEndpoint,
vulnerabilitiesEndpoint, vulnerabilitiesEndpoint,
vulnerabilitiesCountEndpoint, vulnerabilitiesCountEndpoint,
vulnerabilitiesHistoryEndpoint, vulnerabilitiesHistoryEndpoint,
...@@ -85,6 +88,7 @@ describe('Instance Security Dashboard component', () => { ...@@ -85,6 +88,7 @@ describe('Instance Security Dashboard component', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlEmptyState).exists()).toBe(false);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(SecurityDashboard).exists()).toBe(false); expect(wrapper.find(SecurityDashboard).exists()).toBe(false);
expect(wrapper.find(ProjectManager).exists()).toBe(true);
}; };
afterEach(() => { afterEach(() => {
...@@ -98,8 +102,14 @@ describe('Instance Security Dashboard component', () => { ...@@ -98,8 +102,14 @@ describe('Instance Security Dashboard component', () => {
it('dispatches the expected actions', () => { it('dispatches the expected actions', () => {
expect(store.dispatch.mock.calls).toEqual([ 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', () => { ...@@ -108,6 +118,7 @@ describe('Instance Security Dashboard component', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlEmptyState).exists()).toBe(false);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(SecurityDashboard).exists()).toBe(false); expect(wrapper.find(SecurityDashboard).exists()).toBe(false);
expect(wrapper.find(ProjectManager).exists()).toBe(false);
}); });
}); });
...@@ -121,6 +132,7 @@ describe('Instance Security Dashboard component', () => { ...@@ -121,6 +132,7 @@ describe('Instance Security Dashboard component', () => {
expect(findProjectSelectorToggleButton().exists()).toBe(true); expect(findProjectSelectorToggleButton().exists()).toBe(true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(SecurityDashboard).exists()).toBe(false); expect(wrapper.find(SecurityDashboard).exists()).toBe(false);
expect(wrapper.find(ProjectManager).exists()).toBe(false);
expectComponentWithProps(GlEmptyState, { expectComponentWithProps(GlEmptyState, {
svgPath: emptyStateSvgPath, svgPath: emptyStateSvgPath,
...@@ -146,6 +158,7 @@ describe('Instance Security Dashboard component', () => { ...@@ -146,6 +158,7 @@ describe('Instance Security Dashboard component', () => {
expect(findProjectSelectorToggleButton().exists()).toBe(true); expect(findProjectSelectorToggleButton().exists()).toBe(true);
expect(wrapper.find(GlEmptyState).exists()).toBe(false); expect(wrapper.find(GlEmptyState).exists()).toBe(false);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(ProjectManager).exists()).toBe(false);
expectComponentWithProps(SecurityDashboard, { expectComponentWithProps(SecurityDashboard, {
dashboardDocumentation, dashboardDocumentation,
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; 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 ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import ProjectList from 'ee/security_dashboard/components/project_list.vue'; import ProjectList from 'ee/security_dashboard/components/project_list.vue';
...@@ -14,12 +14,13 @@ const generateMockProjects = (projectsCount, mockProject = {}) => ...@@ -14,12 +14,13 @@ const generateMockProjects = (projectsCount, mockProject = {}) =>
describe('Project List component', () => { describe('Project List component', () => {
let wrapper; let wrapper;
const factory = ({ projects = [], stubs = {} } = {}) => { const factory = ({ projects = [], stubs = {}, showLoadingIndicator = false } = {}) => {
wrapper = shallowMount(ProjectList, { wrapper = shallowMount(ProjectList, {
stubs, stubs,
localVue, localVue,
propsData: { propsData: {
projects, projects,
showLoadingIndicator,
}, },
sync: false, sync: false,
}); });
...@@ -39,6 +40,18 @@ describe('Project List component', () => { ...@@ -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])( it.each([0, 1, 2])(
'renders a list of projects and displays a count of how many there are', 'renders a list of projects and displays a count of how many there are',
projectsCount => { projectsCount => {
......
...@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; ...@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import createDefaultState from 'ee/security_dashboard/store/modules/project_selector/state'; 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 ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue'; import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
...@@ -17,7 +17,12 @@ describe('Project Manager component', () => { ...@@ -17,7 +17,12 @@ describe('Project Manager component', () => {
let store; let store;
let wrapper; let wrapper;
const factory = ({ stateOverrides = {} } = {}) => { const factory = ({
state = {},
canAddProjects = false,
isSearchingProjects = false,
isUpdatingProjects = false,
} = {}) => {
storeOptions = { storeOptions = {
modules: { modules: {
projectSelector: { projectSelector: {
...@@ -30,9 +35,14 @@ describe('Project Manager component', () => { ...@@ -30,9 +35,14 @@ describe('Project Manager component', () => {
toggleSelectedProject: jest.fn(), toggleSelectedProject: jest.fn(),
removeProject: jest.fn(), removeProject: jest.fn(),
}, },
getters: {
canAddProjects: jest.fn().mockReturnValue(canAddProjects),
isSearchingProjects: jest.fn().mockReturnValue(isSearchingProjects),
isUpdatingProjects: jest.fn().mockReturnValue(isUpdatingProjects),
},
state: { state: {
...createDefaultState(), ...createDefaultState(),
...stateOverrides, ...state,
}, },
}, },
}, },
...@@ -51,7 +61,6 @@ describe('Project Manager component', () => { ...@@ -51,7 +61,6 @@ describe('Project Manager component', () => {
const getMockActionDispatchedPayload = actionName => getMockAction(actionName).mock.calls[0][1]; const getMockActionDispatchedPayload = actionName => getMockAction(actionName).mock.calls[0][1];
const getAddProjectsButton = () => wrapper.find(GlButton); const getAddProjectsButton = () => wrapper.find(GlButton);
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getProjectList = () => wrapper.find(ProjectList); const getProjectList = () => wrapper.find(ProjectList);
const getProjectSelector = () => wrapper.find(ProjectSelector); const getProjectSelector = () => wrapper.find(ProjectSelector);
...@@ -87,18 +96,11 @@ describe('Project Manager component', () => { ...@@ -87,18 +96,11 @@ describe('Project Manager component', () => {
expect(getAddProjectsButton().attributes('disabled')).toBe('true'); expect(getAddProjectsButton().attributes('disabled')).toBe('true');
}); });
it.each` it('dispatches the addProjects when the "Add projects" button has been clicked', () => {
actionName | payload
${'addProjects'} | ${undefined}
${'clearSearchResults'} | ${undefined}
`(
'dispatches the correct actions when the add-projects button has been clicked',
({ actionName, payload }) => {
getAddProjectsButton().vm.$emit('click'); getAddProjectsButton().vm.$emit('click');
expect(getMockActionDispatchedPayload(actionName)).toBe(payload); expect(getMockAction('addProjects')).toHaveBeenCalled();
}, });
);
it('contains a project-list component', () => { it('contains a project-list component', () => {
expect(getProjectList().exists()).toBe(true); expect(getProjectList().exists()).toBe(true);
...@@ -116,26 +118,26 @@ describe('Project Manager component', () => { ...@@ -116,26 +118,26 @@ describe('Project Manager component', () => {
}); });
}); });
describe('given the state changes', () => { describe('given the store state', () => {
it.each` it.each`
state | projectSelectorPropName | expectedPropValue config | projectSelectorPropName | expectedPropValue
${{ searchCount: 1 }} | ${'showLoadingIndicator'} | ${true} ${{ isSearchingProjects: true }} | ${'showLoadingIndicator'} | ${true}
${{ selectedProjects: ['bar'] }} | ${'selectedProjects'} | ${['bar']} ${{ state: { selectedProjects: ['bar'] } }} | ${'selectedProjects'} | ${['bar']}
${{ projectSearchResults: ['foo'] }} | ${'projectSearchResults'} | ${['foo']} ${{ state: { projectSearchResults: ['foo'] } }} | ${'projectSearchResults'} | ${['foo']}
${{ messages: { noResults: true } }} | ${'showNoResultsMessage'} | ${true} ${{ state: { messages: { noResults: true } } }} | ${'showNoResultsMessage'} | ${true}
${{ messages: { searchError: true } }} | ${'showSearchErrorMessage'} | ${true} ${{ state: { messages: { searchError: true } } }} | ${'showSearchErrorMessage'} | ${true}
${{ messages: { minimumQuery: true } }} | ${'showMinimumSearchQueryMessage'} | ${true} ${{ state: { messages: { minimumQuery: true } } }} | ${'showMinimumSearchQueryMessage'} | ${true}
`( `(
'passes the correct prop-values to the project-selector', 'passes $projectSelectorPropName = $expectedPropValue to the project-selector',
({ state, projectSelectorPropName, expectedPropValue }) => { ({ config, projectSelectorPropName, expectedPropValue }) => {
factory({ stateOverrides: state }); factory(config);
expect(getProjectSelector().props(projectSelectorPropName)).toEqual(expectedPropValue); expect(getProjectSelector().props(projectSelectorPropName)).toEqual(expectedPropValue);
}, },
); );
it('enables the add-projects button when at least one projects is selected', () => { it('enables the add-projects button when projects can be added', () => {
factory({ stateOverrides: { selectedProjects: [{}] } }); factory({ canAddProjects: true });
expect(getAddProjectsButton().attributes('disabled')).toBe(undefined); expect(getAddProjectsButton().attributes('disabled')).toBe(undefined);
}); });
...@@ -143,21 +145,18 @@ describe('Project Manager component', () => { ...@@ -143,21 +145,18 @@ describe('Project Manager component', () => {
it('passes the list of projects to the project-list component', () => { it('passes the list of projects to the project-list component', () => {
const projects = [{}]; const projects = [{}];
factory({ stateOverrides: { projects } }); factory({ state: { projects } });
expect(getProjectList().props('projects')).toBe(projects); expect(getProjectList().props('projects')).toBe(projects);
}); });
it('toggles the loading icon when a project is being added', () => { it.each([false, true])(
factory({ stateOverrides: { isAddingProjects: false } }); 'passes showLoadingIndicator = %p to the project-list component',
isUpdatingProjects => {
expect(getLoadingIcon().exists()).toBe(false); factory({ isUpdatingProjects });
store.state.projectSelector.isAddingProjects = true; expect(getProjectList().props('showLoadingIndicator')).toBe(isUpdatingProjects);
},
return wrapper.vm.$nextTick().then(() => { );
expect(getLoadingIcon().exists()).toBe(true);
});
});
}); });
}); });
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);
});
});
});
});
...@@ -91,6 +91,9 @@ describe('projectSelector actions', () => { ...@@ -91,6 +91,9 @@ describe('projectSelector actions', () => {
type: 'receiveAddProjectsSuccess', type: 'receiveAddProjectsSuccess',
payload: mockResponse, payload: mockResponse,
}, },
{
type: 'clearSearchResults',
},
], ],
); );
}); });
...@@ -103,7 +106,11 @@ describe('projectSelector actions', () => { ...@@ -103,7 +106,11 @@ describe('projectSelector actions', () => {
null, null,
state, state,
[], [],
[{ type: 'requestAddProjects' }, { type: 'receiveAddProjectsError' }], [
{ type: 'requestAddProjects' },
{ type: 'receiveAddProjectsError' },
{ type: 'clearSearchResults' },
],
); );
}); });
}); });
......
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);
});
},
);
});
});
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,
});
});
});
...@@ -3,6 +3,7 @@ import Tracking from '~/tracking'; ...@@ -3,6 +3,7 @@ import Tracking from '~/tracking';
import createState from 'ee/security_dashboard/store/modules/filters/state'; import createState from 'ee/security_dashboard/store/modules/filters/state';
import * as types from 'ee/security_dashboard/store/modules/filters/mutation_types'; 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 module, * as actions from 'ee/security_dashboard/store/modules/filters/actions';
import { ALL } from 'ee/security_dashboard/store/modules/filters/constants';
describe('filters actions', () => { describe('filters actions', () => {
beforeEach(() => { beforeEach(() => {
...@@ -12,7 +13,26 @@ describe('filters actions', () => { ...@@ -12,7 +13,26 @@ describe('filters actions', () => {
describe('setFilter', () => { describe('setFilter', () => {
it('should commit the SET_FILTER mutuation', done => { it('should commit the SET_FILTER mutuation', done => {
const state = createState(); 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( testAction(
actions.setFilter, actions.setFilter,
...@@ -33,7 +53,7 @@ describe('filters actions', () => { ...@@ -33,7 +53,7 @@ describe('filters actions', () => {
describe('setFilterOptions', () => { describe('setFilterOptions', () => {
it('should commit the SET_FILTER_OPTIONS mutuation', done => { it('should commit the SET_FILTER_OPTIONS mutuation', done => {
const state = createState(); const state = createState();
const payload = { filterId: 'project', options: [] }; const payload = { filterId: 'project_id', options: [{ id: ALL }] };
testAction( testAction(
actions.setFilterOptions, actions.setFilterOptions,
...@@ -49,6 +69,59 @@ describe('filters actions', () => { ...@@ -49,6 +69,59 @@ describe('filters actions', () => {
done, 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,
payload,
state,
[
{
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,
);
});
}); });
describe('setAllFilters', () => { describe('setAllFilters', () => {
......
...@@ -6,11 +6,10 @@ describe('mediator', () => { ...@@ -6,11 +6,10 @@ describe('mediator', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
spyOn(store, 'dispatch');
}); });
it('triggers fetching vulnerabilities after one filter changes', () => { it('triggers fetching vulnerabilities after one filter changes', () => {
spyOn(store, 'dispatch');
const activeFilters = store.getters['filters/activeFilters']; const activeFilters = store.getters['filters/activeFilters'];
store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, {}); store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, {});
...@@ -32,9 +31,13 @@ describe('mediator', () => { ...@@ -32,9 +31,13 @@ describe('mediator', () => {
); );
}); });
it('triggers fetching vulnerabilities after filters change', () => { it('does not fetch vulnerabilities after one filter changes with lazy = true', () => {
spyOn(store, 'dispatch'); store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, { lazy: true });
expect(store.dispatch).not.toHaveBeenCalled();
});
it('triggers fetching vulnerabilities after filters change', () => {
const payload = { const payload = {
...store.getters['filters/activeFilters'], ...store.getters['filters/activeFilters'],
page: store.state.vulnerabilities.pageInfo.page, page: store.state.vulnerabilities.pageInfo.page,
...@@ -57,8 +60,6 @@ describe('mediator', () => { ...@@ -57,8 +60,6 @@ describe('mediator', () => {
}); });
it('triggers fetching vulnerabilities after "Hide dismissed" toggle changes', () => { it('triggers fetching vulnerabilities after "Hide dismissed" toggle changes', () => {
spyOn(store, 'dispatch');
const activeFilters = store.getters['filters/activeFilters']; const activeFilters = store.getters['filters/activeFilters'];
store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, {}); store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, {});
...@@ -79,4 +80,10 @@ describe('mediator', () => { ...@@ -79,4 +80,10 @@ describe('mediator', () => {
activeFilters, 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();
});
}); });
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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment