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

Add vulnerable dependency list

This adds a second dependency list which only displays dependencies with
vulnerabilities. The user is able to switch between the two lists via
the associated tabs.

This contributes towards the larger [feature][1] for adding dependency
scanning results to the Dependency List, behind the
`dependency_list_vulnerabilities` feature flag.

[1]: https://gitlab.com/gitlab-org/gitlab-ee/issues/10077
parent 7ed07685
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlBadge, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import DependenciesActions from './dependencies_actions.vue';
import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
......@@ -14,10 +14,18 @@ export default {
GlBadge,
GlEmptyState,
GlLoadingIcon,
GlTab,
GlTabs,
DependencyListIncompleteAlert,
DependencyListJobFailedAlert,
PaginatedDependenciesTable,
},
inject: {
dependencyListVulnerabilities: {
from: 'dependencyListVulnerabilities',
default: false,
},
},
props: {
endpoint: {
type: String,
......@@ -39,28 +47,47 @@ export default {
};
},
computed: {
...mapState(['currentList']),
...mapGetters(DEPENDENCY_LIST_TYPES.all, ['isJobNotSetUp', 'isJobFailed', 'isIncomplete']),
...mapState(DEPENDENCY_LIST_TYPES.all, ['initialized', 'pageInfo', 'reportInfo']),
...mapState(['currentList', 'listTypes']),
...mapGetters([
'isInitialized',
'isJobNotSetUp',
'isJobFailed',
'isIncomplete',
'reportInfo',
'totals',
]),
...mapState(DEPENDENCY_LIST_TYPES.all.namespace, ['pageInfo']),
currentListIndex: {
get() {
return this.listTypes.map(({ namespace }) => namespace).indexOf(this.currentList);
},
set(index) {
const { namespace } = this.listTypes[index] || {};
this.setCurrentList(namespace);
},
},
},
created() {
this.setDependenciesEndpoint(this.endpoint);
this.fetchDependencies();
},
methods: {
...mapActions(DEPENDENCY_LIST_TYPES.all, ['setDependenciesEndpoint', 'fetchDependencies']),
...mapActions(['setDependenciesEndpoint', 'fetchDependencies', 'setCurrentList']),
dismissIncompleteListAlert() {
this.isIncompleteAlertDismissed = true;
},
dismissJobFailedAlert() {
this.isJobFailedAlertDismissed = true;
},
isTabDisabled(namespace) {
return this.totals[namespace] <= 0;
},
},
};
</script>
<template>
<gl-loading-icon v-if="!initialized" size="md" class="mt-4" />
<gl-loading-icon v-if="!isInitialized" size="md" class="mt-4" />
<gl-empty-state
v-else-if="isJobNotSetUp"
......@@ -85,15 +112,41 @@ export default {
@close="dismissJobFailedAlert"
/>
<div class="d-sm-flex justify-content-between align-items-baseline my-2">
<h4 class="h5">
{{ __('Dependencies') }}
<gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge>
</h4>
<template v-if="dependencyListVulnerabilities">
<h3 class="h5">{{ __('Dependencies') }}</h3>
<gl-tabs v-model="currentListIndex">
<gl-tab
v-for="listType in listTypes"
:key="listType.namespace"
:disabled="isTabDisabled(listType.namespace)"
title-link-class="js-tab"
>
<template v-slot:title>
{{ listType.label }}
<gl-badge pill>{{ totals[listType.namespace] }}</gl-badge>
</template>
<paginated-dependencies-table :namespace="listType.namespace" />
</gl-tab>
<template v-slot:tabs>
<li class="d-flex align-items-center ml-sm-auto">
<dependencies-actions :namespace="currentList" class="my-2 my-sm-0" />
</li>
</template>
</gl-tabs>
</template>
<template v-else>
<div class="d-sm-flex justify-content-between align-items-baseline my-2">
<h3 class="h5">
{{ __('Dependencies') }}
<gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge>
</h3>
<dependencies-actions :namespace="currentList" />
</div>
<dependencies-actions :namespace="currentList" />
</div>
<paginated-dependencies-table :namespace="currentList" />
<paginated-dependencies-table :namespace="currentList" />
</template>
</div>
</template>
......@@ -30,7 +30,8 @@ export default {
namespace: {
type: String,
required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value),
validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
},
},
data() {
......
......@@ -14,7 +14,8 @@ export default {
namespace: {
type: String,
required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value),
validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
},
},
computed: {
......
import Vue from 'vue';
import DependenciesApp from './components/app.vue';
import createStore from './store';
import { DEPENDENCY_LIST_TYPES } from './store/constants';
import { addListType } from './store/utils';
export default () => {
const el = document.querySelector('#js-dependencies-app');
......@@ -9,6 +11,10 @@ export default () => {
const store = createStore();
if (dependencyListVulnerabilities) {
addListType(store, DEPENDENCY_LIST_TYPES.vulnerable);
}
return new Vue({
el,
store,
......
import * as types from './mutation_types';
export const addListType = ({ commit }, payload) => commit(types.ADD_LIST_TYPE, payload);
export const setDependenciesEndpoint = ({ state, dispatch }, endpoint) =>
Promise.all(
state.listTypes.map(({ namespace }) =>
dispatch(`${namespace}/setDependenciesEndpoint`, endpoint),
),
);
export const fetchDependencies = ({ state, dispatch }, payload) =>
Promise.all(
state.listTypes.map(({ namespace }) => dispatch(`${namespace}/fetchDependencies`, payload)),
);
export const setCurrentList = ({ state, commit }, payload) => {
if (state.listTypes.map(({ namespace }) => namespace).includes(payload)) {
commit(types.SET_CURRENT_LIST, payload);
}
};
import { s__ } from '~/locale';
import { FILTER } from './modules/list/constants';
// eslint-disable-next-line import/prefer-default-export
export const DEPENDENCY_LIST_TYPES = {
all: 'allDependencies',
all: {
namespace: 'allDependencies',
label: s__('Dependencies|All'),
initialState: {
filter: FILTER.all,
},
},
vulnerable: {
namespace: 'vulnerableDependencies',
label: s__('Dependencies|Vulnerable components'),
initialState: {
filter: FILTER.vulnerable,
},
},
};
export const isInitialized = ({ currentList, ...state }) => state[currentList].initialized;
export const reportInfo = ({ currentList, ...state }) => state[currentList].reportInfo;
export const isJobNotSetUp = ({ currentList }, getters) => getters[`${currentList}/isJobNotSetUp`];
export const isJobFailed = ({ currentList }, getters) => getters[`${currentList}/isJobFailed`];
export const isIncomplete = ({ currentList }, getters) => getters[`${currentList}/isIncomplete`];
export const totals = state =>
state.listTypes.reduce(
(acc, { namespace }) => ({
...acc,
[namespace]: state[namespace].pageInfo.total || 0,
}),
{},
);
import Vue from 'vue';
import Vuex from 'vuex';
import listModule from './modules/list';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
import { DEPENDENCY_LIST_TYPES } from './constants';
Vue.use(Vuex);
export default () => {
const allDependencies = listModule();
return new Vuex.Store({
export default () =>
new Vuex.Store({
modules: {
allDependencies,
[DEPENDENCY_LIST_TYPES.all.namespace]: listModule(),
},
actions,
getters,
mutations,
state,
});
};
......@@ -9,6 +9,8 @@ import { __ } from '~/locale';
export const setDependenciesEndpoint = ({ commit }, endpoint) =>
commit(types.SET_DEPENDENCIES_ENDPOINT, endpoint);
export const setInitialState = ({ commit }, payload) => commit(types.SET_INITIAL_STATE, payload);
export const requestDependencies = ({ commit }) => commit(types.REQUEST_DEPENDENCIES);
export const receiveDependenciesSuccess = ({ commit }, { headers, data }) => {
......@@ -35,6 +37,7 @@ export const fetchDependencies = ({ state, dispatch }, params = {}) => {
sort_by: state.sortField,
sort: state.sortOrder,
page: state.pageInfo.page || 1,
filter: state.filter,
...params,
},
})
......
......@@ -23,6 +23,11 @@ export const REPORT_STATUS = {
incomplete: 'no_dependency_files',
};
export const FILTER = {
all: 'all',
vulnerable: 'vulnerable',
};
export const FETCH_ERROR_MESSAGE = __(
'Error fetching the dependency list. Please check your network connection and try again.',
);
export const SET_DEPENDENCIES_ENDPOINT = 'SET_DEPENDENCIES_ENDPOINT';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_DEPENDENCIES = 'REQUEST_DEPENDENCIES';
export const RECEIVE_DEPENDENCIES_SUCCESS = 'RECEIVE_DEPENDENCIES_SUCCESS';
......
......@@ -5,6 +5,9 @@ export default {
[types.SET_DEPENDENCIES_ENDPOINT](state, payload) {
state.endpoint = payload;
},
[types.SET_INITIAL_STATE](state, payload) {
Object.assign(state, payload);
},
[types.REQUEST_DEPENDENCIES](state) {
state.isLoading = true;
state.errorLoading = false;
......
import { REPORT_STATUS, SORT_ORDER } from './constants';
import { FILTER, REPORT_STATUS, SORT_ORDER } from './constants';
export default () => ({
endpoint: '',
......@@ -6,11 +6,14 @@ export default () => ({
isLoading: false,
errorLoading: false,
dependencies: [],
pageInfo: {},
pageInfo: {
total: 0,
},
reportInfo: {
status: REPORT_STATUS.ok,
jobPath: '',
},
filter: FILTER.all,
sortField: 'name',
sortOrder: SORT_ORDER.ascending,
});
export const ADD_LIST_TYPE = 'ADD_LIST_TYPE';
export const SET_CURRENT_LIST = 'SET_CURRENT_LIST';
import * as types from './mutation_types';
export default {
[types.ADD_LIST_TYPE](state, payload) {
state.listTypes.push(payload);
},
[types.SET_CURRENT_LIST](state, payload) {
state.currentList = payload;
},
};
import { DEPENDENCY_LIST_TYPES } from './constants';
export default () => ({
currentList: DEPENDENCY_LIST_TYPES.all,
listTypes: [DEPENDENCY_LIST_TYPES.all],
currentList: DEPENDENCY_LIST_TYPES.all.namespace,
});
import listModule from './modules/list';
// eslint-disable-next-line import/prefer-default-export
export const addListType = (store, listType) => {
const { initialState, namespace } = listType;
store.registerModule(namespace, listModule());
store.dispatch('addListType', listType);
store.dispatch(`${namespace}/setInitialState`, initialState);
};
......@@ -13,8 +13,8 @@ exports[`DependenciesApp component on creation given a dependency list which is
class="h5"
>
Dependencies
Dependencies
<glbadge-stub
pill=""
>
......@@ -46,8 +46,8 @@ exports[`DependenciesApp component on creation given a fetch error matches the s
class="h5"
>
Dependencies
Dependencies
<!---->
</h4>
......@@ -75,8 +75,8 @@ exports[`DependenciesApp component on creation given a list of dependencies and
class="h5"
>
Dependencies
Dependencies
<glbadge-stub
pill=""
>
......@@ -110,8 +110,8 @@ exports[`DependenciesApp component on creation given the dependency list job fai
class="h5"
>
Dependencies
Dependencies
<!---->
</h4>
......
......@@ -16,7 +16,7 @@ describe.each`
`('DependenciesActions component$context', ({ isFeatureFlagEnabled, sortFields }) => {
let store;
let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all;
const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = ({ propsData, ...options } = {}) => {
const localVue = createLocalVue();
......@@ -35,10 +35,10 @@ describe.each`
beforeEach(() => {
factory({
propsData: { namespace: listType },
propsData: { namespace },
provide: { dependencyListVulnerabilities: isFeatureFlagEnabled },
});
store.state[listType].endpoint = `${TEST_HOST}/dependencies`;
store.state[namespace].endpoint = `${TEST_HOST}/dependencies`;
return wrapper.vm.$nextTick();
});
......@@ -61,7 +61,7 @@ describe.each`
expect(store.dispatch.mock.calls).toEqual(
expect.arrayContaining(
Object.keys(sortFields).map(field => [`${listType}/setSortField`, field]),
Object.keys(sortFields).map(field => [`${namespace}/setSortField`, field]),
),
);
});
......@@ -69,14 +69,14 @@ describe.each`
it('dispatches the toggleSortOrder action on clicking the sort order button', () => {
const sortButton = wrapper.find('.js-sort-order');
sortButton.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith(`${listType}/toggleSortOrder`);
expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/toggleSortOrder`);
});
it('has a button to export the dependency list', () => {
const download = wrapper.find('.js-download');
expect(download.attributes()).toEqual(
expect.objectContaining({
href: store.getters[`${listType}/downloadEndpoint`],
href: store.getters[`${namespace}/downloadEndpoint`],
download: expect.any(String),
}),
);
......
......@@ -9,7 +9,7 @@ import mockDependenciesResponse from '../store/modules/list/data/mock_dependenci
describe('PaginatedDependenciesTable component', () => {
let store;
let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all;
const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = (props = {}) => {
const localVue = createLocalVue();
......@@ -31,11 +31,11 @@ describe('PaginatedDependenciesTable component', () => {
};
beforeEach(() => {
factory({ namespace: listType });
factory({ namespace });
const originalDispatch = store.dispatch;
jest.spyOn(store, 'dispatch').mockImplementation();
originalDispatch(`${listType}/receiveDependenciesSuccess`, {
originalDispatch(`${namespace}/receiveDependenciesSuccess`, {
data: mockDependenciesResponse,
headers: { 'X-Total': mockDependenciesResponse.dependencies.length },
});
......@@ -50,14 +50,14 @@ describe('PaginatedDependenciesTable component', () => {
it('passes the correct props to the dependencies table', () => {
expectComponentWithProps(DependenciesTable, {
dependencies: mockDependenciesResponse.dependencies,
isLoading: store.state[listType].isLoading,
isLoading: store.state[namespace].isLoading,
});
});
it('passes the correct props to the pagination', () => {
expectComponentWithProps(Pagination, {
change: wrapper.vm.fetchPage,
pageInfo: store.state[listType].pageInfo,
pageInfo: store.state[namespace].pageInfo,
});
});
......@@ -65,7 +65,7 @@ describe('PaginatedDependenciesTable component', () => {
const page = 2;
wrapper.vm.fetchPage(page);
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(`${listType}/fetchDependencies`, { page });
expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/fetchDependencies`, { page });
});
describe.each`
......@@ -77,7 +77,7 @@ describe('PaginatedDependenciesTable component', () => {
let module;
beforeEach(() => {
module = store.state[listType];
module = store.state[namespace];
if (isListEmpty) {
module.dependencies = [];
module.pageInfo.total = 0;
......
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/dependencies/store/actions';
import * as types from 'ee/dependencies/store/mutation_types';
import createState from 'ee/dependencies/store/state';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
describe('Dependencies actions', () => {
describe('addListType', () => {
it('commits the ADD_LIST_TYPE mutation', () => {
const payload = DEPENDENCY_LIST_TYPES.vulnerable;
return testAction(
actions.addListType,
payload,
createState(),
[
{
type: types.ADD_LIST_TYPE,
payload,
},
],
[],
);
});
});
describe.each`
actionName | payload
${'setDependenciesEndpoint'} | ${TEST_HOST}
${'fetchDependencies'} | ${undefined}
`('$actionName', ({ actionName, payload }) => {
it(`dispatches the ${actionName} action on each list module`, () => {
const state = createState();
state.listTypes.push({ namespace: 'foo' });
return testAction(
actions[actionName],
payload,
state,
[],
[
{
type: `allDependencies/${actionName}`,
payload,
},
{
type: `foo/${actionName}`,
payload,
},
],
);
});
});
describe('setCurrentList', () => {
let payload;
let state;
beforeEach(() => {
state = {
listTypes: [{ namespace: 'foo' }, { namespace: 'bar' }],
};
});
describe('given an existing namespace', () => {
beforeEach(() => {
payload = 'bar';
});
it('commits the SET_CURRENT_LIST mutation if given a valid list', () =>
testAction(
actions.setCurrentList,
payload,
state,
[
{
type: types.SET_CURRENT_LIST,
payload: 'bar',
},
],
[],
));
});
describe('given a non-existent namespace', () => {
beforeEach(() => {
payload = 'qux';
});
it('does nothing', () => testAction(actions.setCurrentList, payload, state, [], []));
});
});
});
import * as getters from 'ee/dependencies/store/getters';
describe('Dependencies getters', () => {
describe.each`
getterName | propertyName
${'isInitialized'} | ${'initialized'}
${'reportInfo'} | ${'reportInfo'}
`('$getterName', ({ getterName, propertyName }) => {
it(`returns the value from the current list module's state`, () => {
const mockValue = {};
const state = {
listFoo: {
[propertyName]: mockValue,
},
currentList: 'listFoo',
};
expect(getters[getterName](state)).toBe(mockValue);
});
});
describe.each`
getterName
${'isJobNotSetUp'}
${'isJobFailed'}
${'isIncomplete'}
`('$getterName', ({ getterName }) => {
it(`delegates to the current list module's ${getterName} getter`, () => {
const mockValue = {};
const currentList = 'fooList';
const state = { currentList };
const rootGetters = {
[`${currentList}/${getterName}`]: mockValue,
};
expect(getters[getterName](state, rootGetters)).toBe(mockValue);
});
});
describe('totals', () => {
it('returns a map of list module namespaces to total counts', () => {
const state = {
listTypes: [
{ namespace: 'foo' },
{ namespace: 'bar' },
{ namespace: 'qux' },
{ namespace: 'foobar' },
],
foo: { pageInfo: { total: 1 } },
bar: { pageInfo: { total: 2 } },
qux: { pageInfo: { total: NaN } },
foobar: { pageInfo: {} },
};
expect(getters.totals(state)).toEqual({
foo: 1,
bar: 2,
qux: 0,
foobar: 0,
});
});
});
});
......@@ -6,7 +6,11 @@ import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/dependencies/store/modules/list/actions';
import * as types from 'ee/dependencies/store/modules/list/mutation_types';
import getInitialState from 'ee/dependencies/store/modules/list/state';
import { SORT_ORDER, FETCH_ERROR_MESSAGE } from 'ee/dependencies/store/modules/list/constants';
import {
FILTER,
SORT_ORDER,
FETCH_ERROR_MESSAGE,
} from 'ee/dependencies/store/modules/list/constants';
import createFlash from '~/flash';
import mockDependenciesResponse from './data/mock_dependencies';
......@@ -52,6 +56,25 @@ describe('Dependencies actions', () => {
));
});
describe('setInitialState', () => {
it('commits the SET_INITIAL_STATE mutation', () => {
const payload = { filter: 'foo' };
return testAction(
actions.setInitialState,
payload,
getInitialState(),
[
{
type: types.SET_INITIAL_STATE,
payload,
},
],
[],
);
});
});
describe('requestDependencies', () => {
it('commits the REQUEST_DEPENDENCIES mutation', () =>
testAction(
......@@ -141,6 +164,7 @@ describe('Dependencies actions', () => {
sort_by: state.sortField,
sort: state.sortOrder,
page: state.pageInfo.page,
filter: state.filter,
};
mock
......@@ -167,7 +191,12 @@ describe('Dependencies actions', () => {
});
describe('given params', () => {
const paramsGiven = { sort_by: 'packager', sort: SORT_ORDER.descending, page: 4 };
const paramsGiven = {
sort_by: 'packager',
sort: SORT_ORDER.descending,
page: 4,
filter: FILTER.vulnerable,
};
beforeEach(() => {
mock
......
......@@ -19,6 +19,15 @@ describe('Dependencies mutations', () => {
});
});
describe(types.SET_INITIAL_STATE, () => {
it('correctly mutates the state', () => {
const filter = 'foo';
mutations[types.SET_INITIAL_STATE](state, { filter });
expect(state.filter).toBe(filter);
});
});
describe(types.REQUEST_DEPENDENCIES, () => {
beforeEach(() => {
mutations[types.REQUEST_DEPENDENCIES](state);
......
import * as types from 'ee/dependencies/store/mutation_types';
import mutations from 'ee/dependencies/store/mutations';
import getInitialState from 'ee/dependencies/store/state';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
describe('Dependencies mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(types.ADD_LIST_TYPE, () => {
it('adds the given module type to the list of modules', () => {
const typeToAdd = DEPENDENCY_LIST_TYPES.vulnerable;
mutations[types.ADD_LIST_TYPE](state, typeToAdd);
const lastAdded = state.listTypes[state.listTypes.length - 1];
expect(lastAdded).toEqual(typeToAdd);
});
});
describe(types.SET_CURRENT_LIST, () => {
it('sets the current list namespace', () => {
const namespace = 'foobar';
mutations[types.SET_CURRENT_LIST](state, namespace);
expect(state.currentList).toBe(namespace);
});
});
});
import { addListType } from 'ee/dependencies/store/utils';
import listModule from 'ee/dependencies/store/modules/list';
const mockModule = { mock: true };
jest.mock('ee/dependencies/store/modules/list', () => ({
// `__esModule: true` is required when mocking modules with default exports:
// https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options
__esModule: true,
default: jest.fn(() => mockModule),
}));
describe('Dependencies store utils', () => {
describe('addListType', () => {
it('calls the correct store methods', () => {
const store = {
dispatch: jest.fn(),
registerModule: jest.fn(),
};
const listType = {
namespace: 'foo',
initialState: { bar: true },
};
addListType(store, listType);
expect(listModule).toHaveBeenCalled();
expect(store.registerModule.mock.calls).toEqual([[listType.namespace, mockModule]]);
expect(store.dispatch.mock.calls).toEqual([
['addListType', listType],
[`${listType.namespace}/setInitialState`, listType.initialState],
]);
});
});
});
......@@ -4513,6 +4513,9 @@ msgid_plural "Dependencies|%d vulnerabilities"
msgstr[0] ""
msgstr[1] ""
msgid "Dependencies|All"
msgstr ""
msgid "Dependencies|Component"
msgstr ""
......@@ -4546,6 +4549,9 @@ msgstr ""
msgid "Dependencies|Version"
msgstr ""
msgid "Dependencies|Vulnerable components"
msgstr ""
msgid "Dependency List"
msgstr ""
......
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