Commit f52c9926 authored by Lukas Eipert's avatar Lukas Eipert

Persist Security Dashboard state in URL

With the help of Vue-Router we persist the URL as query parameters
every time we made a successful request. Every time the user updates the
URL by using the browser navigation, we update the filter state.

This is all tied together in the moderator and utilizes vue-router.
parent 6e634c99
......@@ -64,14 +64,18 @@ export default {
this.setVulnerabilitiesEndpoint(this.vulnerabilitiesEndpoint);
this.setVulnerabilitiesCountEndpoint(this.vulnerabilitiesCountEndpoint);
this.setVulnerabilitiesHistoryEndpoint(this.vulnerabilitiesHistoryEndpoint);
this.fetchVulnerabilitiesCount();
this.fetchVulnerabilities(this.activeFilters);
this.fetchVulnerabilitiesCount(this.activeFilters);
this.fetchVulnerabilitiesHistory(this.activeFilters);
this.fetchProjects();
},
methods: {
...mapActions('vulnerabilities', [
'createIssue',
'dismissVulnerability',
'fetchVulnerabilities',
'fetchVulnerabilitiesCount',
'fetchVulnerabilitiesHistory',
'revertDismissal',
'setVulnerabilitiesCountEndpoint',
'setVulnerabilitiesEndpoint',
......
......@@ -43,9 +43,6 @@ export default {
return this.pageInfo && this.pageInfo.total;
},
},
created() {
this.fetchVulnerabilities(this.activeFilters);
},
methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilities', 'openModal']),
fetchPage(page) {
......
<script>
import dateFormat from 'dateformat';
import { mapState, mapActions } from 'vuex';
import { mapState } from 'vuex';
import { GlChart } from '@gitlab/ui/dist/charts';
import ChartTooltip from './vulnerability_chart_tooltip.vue';
......@@ -142,11 +142,7 @@ export default {
};
},
},
created() {
this.fetchVulnerabilitiesHistory();
},
methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilitiesHistory']),
renderTooltip(params, ticket, callback) {
this.tooltipTitle = dateFormat(params[0].axisValue, 'd mmmm');
this.tooltipEntries = params;
......
import Vue from 'vue';
import GroupSecurityDashboardApp from './components/app.vue';
import createStore from './store';
import router from './store/router';
export default () => {
const el = document.getElementById('js-group-security-dashboard');
......@@ -10,6 +11,7 @@ export default () => {
return new Vue({
el,
store,
router,
components: {
GroupSecurityDashboardApp,
},
......
import Vue from 'vue';
import Vuex from 'vuex';
import router from './router';
import configureModerator from './moderator';
import filters from './modules/filters/index';
import projects from './modules/projects/index';
......@@ -16,6 +17,8 @@ export default () => {
},
});
store.$router = router;
configureModerator(store);
return store;
......
import * as vulnerabilitiesMutationTypes from './modules/vulnerabilities/mutation_types';
import * as filtersMutationTypes from './modules/filters/mutation_types';
import * as projectsMutationTypes from './modules/projects/mutation_types';
export default function configureModerator(store) {
store.$router.beforeEach((to, from, next) => {
const updatedFromState = (to.params && to.params.updatedFromState) || false;
if (to.name === 'dashboard' && !updatedFromState) {
store.dispatch(`filters/setAllFilters`, to.query);
}
next();
});
store.subscribe(({ type, payload }) => {
switch (type) {
case `projects/${projectsMutationTypes.RECEIVE_PROJECTS_SUCCESS}`:
......@@ -19,6 +30,7 @@ export default function configureModerator(store) {
],
});
break;
case `filters/${filtersMutationTypes.SET_ALL_FILTERS}`:
case `filters/${filtersMutationTypes.SET_FILTER}`: {
const activeFilters = store.getters['filters/activeFilters'];
store.dispatch('vulnerabilities/fetchVulnerabilities', activeFilters);
......@@ -26,6 +38,15 @@ export default function configureModerator(store) {
store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', activeFilters);
break;
}
case `vulnerabilities/${vulnerabilitiesMutationTypes.RECEIVE_VULNERABILITIES_SUCCESS}`: {
const activeFilters = store.getters['filters/activeFilters'];
store.$router.push({
name: 'dashboard',
query: activeFilters,
params: { updatedFromState: true },
});
break;
}
default:
}
});
......
......@@ -14,6 +14,10 @@ export const setFilterOptions = ({ commit }, payload) => {
commit(types.SET_FILTER_OPTIONS, payload);
};
export const setAllFilters = ({ commit }, payload) => {
commit(types.SET_ALL_FILTERS, payload);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-ce#52179 is merged
export default () => {};
export const SET_FILTER = 'SET_FILTER';
export const SET_FILTER_OPTIONS = 'SET_FILTER_OPTIONS';
export const SET_ALL_FILTERS = 'SET_ALL_FILTERS';
import * as types from './mutation_types';
export default {
[types.SET_ALL_FILTERS](state, payload = {}) {
state.filters = state.filters.map(filter => {
// If the payload is empty, we fall back to an empty selection
const selectedOptions = (payload && payload[filter.id]) || [];
const selection = Array.isArray(selectedOptions)
? new Set(selectedOptions)
: new Set([selectedOptions]);
// This prevents us from selecting nothing at all
if (selection.size === 0) {
selection.add('all');
}
return { ...filter, selection };
});
},
[types.SET_FILTER](state, payload) {
const { filterId, optionId } = payload;
const activeFilter = state.filters.find(filter => filter.id === filterId);
......@@ -21,7 +38,7 @@ export default {
// This prevents us from selecting nothing at all
if (selection.size === 0) {
selection = new Set(['all']);
selection.add('all');
}
activeFilter.selection = selection;
}
......
......@@ -14,6 +14,9 @@ export const setVulnerabilitiesCountEndpoint = ({ commit }, endpoint) => {
};
export const fetchVulnerabilitiesCount = ({ state, dispatch }, params = {}) => {
if (!state.vulnerabilitiesCountEndpoint) {
return;
}
dispatch('requestVulnerabilitiesCount');
axios({
......@@ -43,6 +46,9 @@ export const receiveVulnerabilitiesCountError = ({ commit }) => {
};
export const fetchVulnerabilities = ({ state, dispatch }, params = {}) => {
if (!state.vulnerabilitiesEndpoint) {
return;
}
dispatch('requestVulnerabilities');
axios({
......@@ -208,6 +214,9 @@ export const setVulnerabilitiesHistoryEndpoint = ({ commit }, endpoint) => {
};
export const fetchVulnerabilitiesHistory = ({ state, dispatch }, params = {}) => {
if (!state.vulnerabilitiesHistoryEndpoint) {
return;
}
dispatch('requestVulnerabilitiesHistory');
axios({
......
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
// Unfortunately Vue Router doesn't work without at least a fake component
// If you do only data handling
const EmptyRouterComponent = {
render(createElement) {
return createElement('div');
},
};
const routes = [{ path: '/', name: 'dashboard', component: EmptyRouterComponent }];
const router = new VueRouter({
mode: 'history',
base: window.location.pathname,
routes,
});
export default router;
---
title: Persist Group Level Security Dashboard state in URL
merge_request: 9108
author:
type: added
import Vue from 'vue';
import MockAdapater from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import component from 'ee/security_dashboard/components/security_dashboard_table.vue';
import createStore from 'ee/security_dashboard/store';
import { TEST_HOST } from 'spec/test_constants';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import waitForPromises from 'spec/helpers/wait_for_promises';
import {
RECEIVE_VULNERABILITIES_ERROR,
RECEIVE_VULNERABILITIES_SUCCESS,
REQUEST_VULNERABILITIES,
} from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import { resetStore } from '../helpers';
import mockDataVulnerabilities from '../store/vulnerabilities/data/mock_data_vulnerabilities.json';
......@@ -19,11 +22,9 @@ describe('Security Dashboard Table', () => {
emptyStateSvgPath: TEST_HOST,
};
let store;
let mock;
let vm;
beforeEach(() => {
mock = new MockAdapater(axios);
store = createStore();
store.state.vulnerabilities.vulnerabilitiesEndpoint = vulnerabilitiesEndpoint;
});
......@@ -31,12 +32,11 @@ describe('Security Dashboard Table', () => {
afterEach(() => {
resetStore(store);
vm.$destroy();
mock.restore();
});
describe('while loading', () => {
beforeEach(() => {
store.dispatch('vulnerabilities/requestVulnerabilities');
store.commit(`vulnerabilities/${REQUEST_VULNERABILITIES}`);
vm = mountComponentWithStore(Component, { store, props });
});
......@@ -47,60 +47,42 @@ describe('Security Dashboard Table', () => {
describe('with a list of vulnerabilities', () => {
beforeEach(() => {
mock.onGet(vulnerabilitiesEndpoint).replyOnce(200, mockDataVulnerabilities);
store.commit(`vulnerabilities/${RECEIVE_VULNERABILITIES_SUCCESS}`, {
vulnerabilities: mockDataVulnerabilities,
});
vm = mountComponentWithStore(Component, { store, props });
});
it('should render a row for each vulnerability', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(
mockDataVulnerabilities.length,
);
done();
})
.catch(done.fail);
it('should render a row for each vulnerability', () => {
expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(
mockDataVulnerabilities.length,
);
});
});
describe('with no vulnerabilties', () => {
beforeEach(() => {
mock.onGet(vulnerabilitiesEndpoint).replyOnce(200, []);
store.commit(`vulnerabilities/${RECEIVE_VULNERABILITIES_SUCCESS}`, { vulnerabilities: [] });
vm = mountComponentWithStore(Component, { store, props });
});
it('should render the empty state', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelector('.empty-state')).not.toBeNull();
done();
})
.catch(done.fail);
it('should render the empty state', () => {
expect(vm.$el.querySelector('.empty-state')).not.toBeNull();
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet(vulnerabilitiesEndpoint).replyOnce(404, []);
store.commit(`vulnerabilities/${RECEIVE_VULNERABILITIES_ERROR}`);
vm = mountComponentWithStore(Component, { store, props });
});
it('should not render the empty state', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelector('.empty-state')).toBeNull();
done();
})
.catch(done.fail);
it('should not render the empty state', () => {
expect(vm.$el.querySelector('.empty-state')).toBeNull();
});
it('should render the error alert', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
done();
})
.catch(done.fail);
it('should render the error alert', () => {
expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
});
});
});
......@@ -46,4 +46,25 @@ describe('filters actions', () => {
);
});
});
describe('setAllFilters', () => {
it('should commit the SET_ALL_FILTERS mutuation', done => {
const state = createState();
const payload = { project_id: ['12', '15'] };
testAction(
actions.setAllFilters,
payload,
state,
[
{
type: types.SET_ALL_FILTERS,
payload,
},
],
[],
done,
);
});
});
});
......@@ -47,6 +47,46 @@ describe('filters module mutations', () => {
});
});
describe('SET_ALL_FILTERS', () => {
it('should set options if they are a single string', () => {
mutations[types.SET_ALL_FILTERS](state, { [severityFilter.id]: criticalOption.id });
const expected = new Set([criticalOption.id]);
expect(state.filters[0].selection).toEqual(expected);
});
it('should set options if they are given as an array', () => {
mutations[types.SET_ALL_FILTERS](state, {
[severityFilter.id]: [criticalOption.id, highOption.id],
});
const expected = new Set([criticalOption.id, highOption.id]);
expect(state.filters[0].selection).toEqual(expected);
});
it('should set options to `all` if no payload is given', () => {
mutations[types.SET_ALL_FILTERS](state);
const expected = new Set(['all']);
state.filters.forEach(filter => {
expect(filter.selection).toEqual(expected);
});
});
it('should set options to `all` if payload contains an empty array', () => {
mutations[types.SET_ALL_FILTERS](state, {
[severityFilter.id]: [],
});
const expected = new Set(['all']);
expect(state.filters[0].selection).toEqual(expected);
});
});
describe('SET_FILTER_OPTIONS', () => {
const options = [{ id: 0, name: 'c' }, { id: 3, name: 'c' }];
......
import createStore from 'ee/security_dashboard/store/index';
import * as projectsMutationTypes from 'ee/security_dashboard/store/modules/projects/mutation_types';
import * as filtersMutationTypes from 'ee/security_dashboard/store/modules/filters/mutation_types';
import * as vulnerabilitiesMutationTypes from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
describe('moderator', () => {
let store;
......@@ -10,7 +11,7 @@ describe('moderator', () => {
});
it('sets project filter options after projects have been received', () => {
spyOn(store, 'dispatch').and.returnValue();
spyOn(store, 'dispatch');
store.commit(`projects/${projectsMutationTypes.RECEIVE_PROJECTS_SUCCESS}`, {
projects: [{ name: 'foo', id: 1, otherProp: 'foobar' }],
......@@ -26,8 +27,8 @@ describe('moderator', () => {
);
});
it('triggers fetching vulnerabilities after filters change', () => {
spyOn(store, 'dispatch').and.returnValue();
it('triggers fetching vulnerabilities after one filter changes', () => {
spyOn(store, 'dispatch');
const activeFilters = store.getters['filters/activeFilters'];
......@@ -38,13 +39,80 @@ describe('moderator', () => {
'vulnerabilities/fetchVulnerabilities',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesCount',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesHistory',
activeFilters,
);
});
it('triggers fetching vulnerabilities after filters change', () => {
spyOn(store, 'dispatch');
const activeFilters = store.getters['filters/activeFilters'];
store.commit(`filters/${filtersMutationTypes.SET_ALL_FILTERS}`, {});
expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilities',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesCount',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesHistory',
activeFilters,
);
});
describe('routing', () => {
it('updates store after URL changes', () => {
const query = { example: ['test'] };
spyOn(store, 'dispatch');
store.$router.push({ name: 'dashboard', query });
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(`filters/setAllFilters`, query);
});
it("doesn't update the store if the URL update originated from the moderator", () => {
const query = { example: ['test'] };
spyOn(store, 'commit');
store.$router.push({ name: 'dashboard', query, params: { updatedFromState: true } });
expect(store.commit).toHaveBeenCalledTimes(0);
});
it('it updates the route after a successful vulnerability retrieval', () => {
const activeFilters = store.getters['filters/activeFilters'];
spyOn(store.$router, 'push');
store.commit(
`vulnerabilities/${vulnerabilitiesMutationTypes.RECEIVE_VULNERABILITIES_SUCCESS}`,
{},
);
expect(store.$router.push).toHaveBeenCalledTimes(1);
expect(store.$router.push).toHaveBeenCalledWith({
name: 'dashboard',
query: activeFilters,
params: { updatedFromState: true },
});
});
});
});
......@@ -11,7 +11,7 @@ import mockDataVulnerabilities from './data/mock_data_vulnerabilities.json';
import mockDataVulnerabilitiesCount from './data/mock_data_vulnerabilities_count.json';
import mockDataVulnerabilitiesHistory from './data/mock_data_vulnerabilities_history.json';
describe('vulnerabiliites count actions', () => {
describe('vulnerabilities count actions', () => {
const data = mockDataVulnerabilitiesCount;
const params = { filters: { type: ['sast'] } };
const filteredData = mockDataVulnerabilitiesCount.sast;
......@@ -37,7 +37,7 @@ describe('vulnerabiliites count actions', () => {
});
});
describe('fetchVulnerabilitesCount', () => {
describe('fetchVulnerabilitiesCount', () => {
let mock;
const state = initialState;
......@@ -112,7 +112,7 @@ describe('vulnerabiliites count actions', () => {
});
});
describe('requestVulnerabilitesCount', () => {
describe('requestVulnerabilitiesCount', () => {
it('should commit the request mutation', done => {
const state = initialState;
......@@ -127,7 +127,7 @@ describe('vulnerabiliites count actions', () => {
});
});
describe('receiveVulnerabilitesCountSuccess', () => {
describe('receiveVulnerabilitiesCountSuccess', () => {
it('should commit the success mutation', done => {
const state = initialState;
......@@ -142,7 +142,7 @@ describe('vulnerabiliites count actions', () => {
});
});
describe('receivetVulnerabilitesCountError', () => {
describe('receiveVulnerabilitiesCountError', () => {
it('should commit the error mutation', done => {
const state = initialState;
......@@ -708,12 +708,12 @@ describe('vulnerabilities history actions', () => {
});
});
describe('fetchVulnerabilitesTimeline', () => {
describe('fetchVulnerabilitiesTimeline', () => {
let mock;
const state = initialState;
beforeEach(() => {
state.vulnerabilitiesCountEndpoint = `${TEST_HOST}/vulnerabilitIES_HISTORY.json`;
state.vulnerabilitiesHistoryEndpoint = `${TEST_HOST}/vulnerabilitIES_HISTORY.json`;
mock = new MockAdapter(axios);
});
......@@ -786,7 +786,7 @@ describe('vulnerabilities history actions', () => {
});
});
describe('requestVulnerabilitesTimeline', () => {
describe('requestVulnerabilitiesTimeline', () => {
it('should commit the request mutation', done => {
const state = initialState;
......@@ -801,7 +801,7 @@ describe('vulnerabilities history actions', () => {
});
});
describe('receiveVulnerabilitesTimelineSuccess', () => {
describe('receiveVulnerabilitiesTimelineSuccess', () => {
it('should commit the success mutation', done => {
const state = initialState;
......@@ -816,7 +816,7 @@ describe('vulnerabilities history actions', () => {
});
});
describe('receivetVulnerabilitesTimelineError', () => {
describe('receiveVulnerabilitiesTimelineError', () => {
it('should commit the error mutation', done => {
const state = initialState;
......
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