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 @@
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 {
</header>
<template v-if="isInitialized">
<section v-if="showProjectSelector" class="js-dashboard-project-selector">
<h3>{{ s__('SecurityDashboard|Add or remove projects from your dashboard') }}</h3>
</section>
<project-manager v-if="showProjectSelector" />
<template v-else>
<gl-empty-state
......@@ -142,6 +149,6 @@ export default {
</template>
</template>
<gl-loading-icon v-else size="md" />
<gl-loading-icon v-else size="md" class="mt-4" />
</article>
</template>
......@@ -19,6 +19,10 @@ export default {
type: Array,
required: true,
},
showLoadingIndicator: {
type: Boolean,
required: true,
},
},
methods: {
projectRemoved(project) {
......@@ -31,10 +35,11 @@ export default {
<template>
<section>
<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') }}
<gl-badge>{{ projects.length }}</gl-badge>
</h3>
<gl-badge pill class="font-weight-bold">{{ projects.length }}</gl-badge>
<gl-loading-icon v-if="showLoadingIndicator" size="sm" class="float-right" />
</h4>
<ul v-if="projects.length" class="list-unstyled">
<li
v-for="project in projects"
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlBadge, GlButton, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectList from './project_list.vue';
export default {
components: {
GlBadge,
GlButton,
GlLoadingIcon,
Icon,
ProjectList,
ProjectSelector,
},
computed: {
...mapState('projectSelector', [
'projects',
'isAddingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'messages',
]),
isSearchingProjects() {
return this.searchCount > 0;
},
hasProjectsSelected() {
return this.selectedProjects.length > 0;
},
...mapGetters('projectSelector', [
'canAddProjects',
'isSearchingProjects',
'isUpdatingProjects',
]),
},
methods: {
...mapActions('projectSelector', [
......@@ -41,10 +32,6 @@ export default {
'setSearchQuery',
'removeProject',
]),
addProjectsAndClearSearchResults() {
this.addProjects();
this.clearSearchResults();
},
searched(query) {
this.setSearchQuery(query);
this.fetchSearchResults();
......@@ -63,9 +50,9 @@ export default {
<section class="container">
<div class="row justify-content-center mt-md-4">
<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') }}
</h2>
</h3>
<div class="d-flex flex-column flex-md-row">
<project-selector
class="flex-grow mr-md-2"
......@@ -79,11 +66,7 @@ export default {
@projectClicked="projectClicked"
/>
<div class="mb-3">
<gl-button
:disabled="!hasProjectsSelected"
variant="success"
@click="addProjectsAndClearSearchResults"
>
<gl-button :disabled="!canAddProjects" variant="success" @click="addProjects">
{{ s__('SecurityDashboard|Add projects') }}
</gl-button>
</div>
......@@ -91,8 +74,12 @@ export default {
</div>
</div>
<div class="row justify-content-center mt-md-3">
<project-list :projects="projects" class="col col-lg-7" @projectRemoved="projectRemoved" />
<gl-loading-icon v-if="isAddingProjects" size="sm" />
<project-list
:projects="projects"
:show-loading-indicator="isUpdatingProjects"
class="col col-lg-7"
@projectRemoved="projectRemoved"
/>
</div>
</section>
</template>
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) => {
......
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)));
......@@ -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 }) => {
......
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 mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default () => ({
namespaced: true,
state,
mutations,
actions,
getters,
});
......@@ -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}`: {
if (!payload.lazy) {
refreshVulnerabilities(store.getters['filters/activeFilters']);
}
break;
}
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';
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,
......
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 => {
......
......@@ -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 }) => {
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);
it.each([false, true])(
'passes showLoadingIndicator = %p to the project-list component',
isUpdatingProjects => {
factory({ isUpdatingProjects });
store.state.projectSelector.isAddingProjects = true;
return wrapper.vm.$nextTick().then(() => {
expect(getLoadingIcon().exists()).toBe(true);
});
});
expect(getProjectList().props('showLoadingIndicator')).toBe(isUpdatingProjects);
},
);
});
});
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', () => {
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' },
],
);
});
});
......
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';
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,7 @@ 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,
......@@ -49,6 +69,59 @@ describe('filters actions', () => {
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', () => {
......
......@@ -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();
});
});
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