Commit e9af13e9 authored by Nathan Friend's avatar Nathan Friend

Merge branch '214122-instance-dashboard-edit-button' into 'master'

Allow configuring the Instance Level Security Dashboard

See merge request gitlab-org/gitlab!29377
parents ebb87c96 86060510
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from './project_manager.vue';
export default {
components: {
ProjectManager,
SecurityDashboardLayout,
InstanceSecurityVulnerabilities,
VulnerabilitySeverity,
Filters,
GlEmptyState,
GlLoadingIcon,
GlButton,
GlLink,
},
props: {
dashboardDocumentation: {
......@@ -24,16 +33,60 @@ export default {
type: String,
required: true,
},
projectAddEndpoint: {
type: String,
required: true,
},
projectListEndpoint: {
type: String,
required: true,
},
},
data() {
return {
filters: {},
showProjectSelector: false,
};
},
computed: {
...mapState('projectSelector', ['projects']),
...mapGetters('projectSelector', ['isUpdatingProjects']),
hasProjectsData() {
return !this.isUpdatingProjects && this.projects.length > 0;
},
shouldShowDashboard() {
return this.hasProjectsData && !this.showProjectSelector;
},
shouldShowEmptyState() {
return !this.hasProjectsData && !this.showProjectSelector && !this.isUpdatingProjects;
},
toggleButtonProps() {
return this.showProjectSelector
? {
variant: 'success',
text: s__('SecurityDashboard|Return to dashboard'),
}
: {
text: s__('SecurityDashboard|Edit dashboard'),
};
},
},
created() {
this.setProjectEndpoints({
add: this.projectAddEndpoint,
list: this.projectListEndpoint,
});
this.fetchProjects();
},
methods: {
...mapActions('projectSelector', ['setProjectEndpoints', 'fetchProjects']),
handleFilterChange(filters) {
this.filters = filters;
},
toggleProjectSelector() {
this.showProjectSelector = !this.showProjectSelector;
},
},
};
</script>
......@@ -43,16 +96,49 @@ export default {
<template #header>
<header class="page-title-holder flex-fill d-flex align-items-center">
<h2 class="page-title">{{ s__('SecurityDashboard|Security Dashboard') }}</h2>
<gl-button
class="page-title-controls js-project-selector-toggle"
:variant="toggleButtonProps.variant"
@click="toggleProjectSelector"
>{{ toggleButtonProps.text }}</gl-button
>
</header>
<filters @filterChange="handleFilterChange" />
<filters v-if="shouldShowDashboard" @filterChange="handleFilterChange" />
</template>
<instance-security-vulnerabilities
v-if="shouldShowDashboard"
:projects="projects"
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:filters="filters"
/>
<gl-empty-state
v-else-if="shouldShowEmptyState"
:title="s__('SecurityDashboard|Add a project to your dashboard')"
:svg-path="emptyStateSvgPath"
>
<template #description>
{{
s__(
'SecurityDashboard|The security dashboard displays the latest security findings for projects you wish to monitor. Select "Edit dashboard" to add and remove projects.',
)
}}
<gl-link :href="dashboardDocumentation">{{
s__('SecurityDashboard|More information')
}}</gl-link>
</template>
<template #actions>
<gl-button variant="success" @click="toggleProjectSelector">
{{ s__('SecurityDashboard|Add projects') }}
</gl-button>
</template>
</gl-empty-state>
<div v-else class="d-flex justify-content-center">
<project-manager v-if="showProjectSelector" />
<gl-loading-icon v-else size="lg" class="mt-4" />
</div>
<template #aside>
<vulnerability-severity :endpoint="vulnerableProjectsEndpoint" />
<vulnerability-severity v-if="shouldShowDashboard" :endpoint="vulnerableProjectsEndpoint" />
</template>
</security-dashboard-layout>
</template>
<script>
import { GlAlert, GlButton, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import { GlAlert, GlButton, GlEmptyState, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import vulnerabilitiesQuery from '../graphql/instance_vulnerabilities.graphql';
import { VULNERABILITIES_PER_PAGE } from 'ee/vulnerabilities/constants';
......@@ -11,6 +12,7 @@ export default {
GlButton,
GlEmptyState,
GlIntersectionObserver,
GlLoadingIcon,
VulnerabilityList,
},
props: {
......@@ -31,13 +33,20 @@ export default {
data() {
return {
pageInfo: {},
isFirstResultLoading: true,
vulnerabilities: [],
errorLoadingVulnerabilities: false,
};
},
computed: {
isQueryLoading() {
return this.$apollo.queries.vulnerabilities.loading;
},
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
first: VULNERABILITIES_PER_PAGE,
......@@ -45,7 +54,8 @@ export default {
};
},
update: ({ vulnerabilities }) => vulnerabilities.nodes,
result({ data }) {
result({ data, loading }) {
this.isFirstResultLoading = loading;
this.pageInfo = data.vulnerabilities.pageInfo;
},
error() {
......@@ -53,14 +63,6 @@ export default {
},
},
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.vulnerabilities.length === 0;
},
},
methods: {
onErrorDismiss() {
this.errorLoadingVulnerabilities = false;
......@@ -99,7 +101,7 @@ export default {
</gl-alert>
<vulnerability-list
v-else
:is-loading="isLoadingFirstResult"
:is-loading="isFirstResultLoading"
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:vulnerabilities="vulnerabilities"
......@@ -119,9 +121,10 @@ export default {
class="text-center"
@appear="fetchNextPage"
>
<gl-button :loading="isLoadingQuery" :disabled="isLoadingQuery" @click="fetchNextPage">{{
__('Load more vulnerabilities')
}}</gl-button>
<gl-button :disabled="isFirstResultLoading" @click="fetchNextPage">
<gl-loading-icon v-if="isQueryLoading" size="md" />
<template v-else>{{ __('Load more vulnerabilities') }}</template>
</gl-button>
</gl-intersection-observer>
</div>
</template>
......@@ -8,6 +8,7 @@ import FirstClassInstanceSecurityDashboard from './components/first_class_instan
import createStore from './store';
import createRouter from './store/router';
import projectsPlugin from './store/plugins/projects';
import projectSelector from './store/plugins/project_selector';
import syncWithRouter from './store/plugins/sync_with_router';
const isRequired = message => {
......@@ -30,12 +31,16 @@ export default (
emptyStateSvgPath,
hasPipelineData,
securityDashboardHelpPath,
projectAddEndpoint,
projectListEndpoint,
} = el.dataset;
const props = {
emptyStateSvgPath,
dashboardDocumentation,
hasPipelineData: Boolean(hasPipelineData),
securityDashboardHelpPath,
projectAddEndpoint,
projectListEndpoint,
};
let component;
......@@ -53,7 +58,10 @@ export default (
}
const router = createRouter();
const store = createStore({ dashboardType, plugins: [projectsPlugin, syncWithRouter(router)] });
const store = createStore({
dashboardType,
plugins: [projectSelector, projectsPlugin, syncWithRouter(router)],
});
return new Vue({
el,
......
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState, GlButton } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_class_instance_security_dashboard.vue';
import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('First Class Instance Dashboard Component', () => {
let wrapper;
let store;
const dashboardDocumentation = 'dashboard-documentation';
const emptyStateSvgPath = 'empty-state-path';
const vulnerableProjectsEndpoint = '/vulnerable/projects';
const projectAddEndpoint = 'projectAddEndpoint';
const projectListEndpoint = 'projectListEndpoint';
const findInstanceVulnerabilities = () => wrapper.find(FirstClassInstanceVulnerabilities);
const findVulnerabilitySeverity = () => wrapper.find(VulnerabilitySeverity);
const findProjectManager = () => wrapper.find(ProjectManager);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findFilters = () => wrapper.find(Filters);
const createWrapper = () => {
const createWrapper = ({ isUpdatingProjects = false, projects = [], stubs }) => {
store = new Vuex.Store({
modules: {
projectSelector: {
namespaced: true,
actions: {
fetchProjects() {},
setProjectEndpoints() {},
},
getters: {
isUpdatingProjects: jest.fn().mockReturnValue(isUpdatingProjects),
},
state: {
projects,
},
},
},
});
return shallowMount(FirstClassInstanceDashboard, {
localVue,
store,
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
projectAddEndpoint,
projectListEndpoint,
vulnerableProjectsEndpoint,
},
stubs: {
...stubs,
SecurityDashboardLayout,
},
});
};
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('should render correctly', () => {
expect(findInstanceVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: {},
describe('when initialized', () => {
beforeEach(() => {
wrapper = createWrapper({
isUpdatingProjects: false,
projects: [{ id: 1 }, { id: 2 }],
});
});
it('should render the vulnerabilities', () => {
expect(findInstanceVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: {},
});
});
it('has filters', () => {
expect(findFilters().exists()).toBe(true);
});
it('it responds to the filterChange event', () => {
const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.filters).toEqual(filters);
expect(findInstanceVulnerabilities().props('filters')).toEqual(filters);
});
});
});
it('has filters', () => {
expect(findFilters().exists()).toBe(true);
it('displays the vulnerability severity in an aside', () => {
expect(findVulnerabilitySeverity().exists()).toBe(true);
});
});
it('it responds to the filterChange event', () => {
const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.filters).toEqual(filters);
expect(findInstanceVulnerabilities().props('filters')).toEqual(filters);
describe('when uninitialized', () => {
beforeEach(() => {
wrapper = createWrapper({
isUpdatingProjects: false,
stubs: {
GlEmptyState,
GlButton,
},
});
});
it('renders the empty state', () => {
expect(findEmptyState().props('title')).toBe('Add a project to your dashboard');
});
it('does not render the vulnerability list', () => {
expect(findInstanceVulnerabilities().exists()).toBe(false);
});
it('has no filters', () => {
expect(findFilters().exists()).toBe(false);
});
it('does not display the vulnerability severity in an aside', () => {
expect(findVulnerabilitySeverity().exists()).toBe(false);
});
it('displays the project manager when the button in empty state is clicked', () => {
expect(findProjectManager().exists()).toBe(false);
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(findProjectManager().exists()).toBe(true);
});
});
});
it('displays the vulnerability severity in an aside', () => {
expect(findVulnerabilitySeverity().exists()).toBe(true);
describe('always', () => {
beforeEach(() => {
wrapper = createWrapper({});
});
it('has the security dashboard title', () => {
expect(wrapper.find('.page-title').text()).toBe('Security Dashboard');
});
it('displays the project manager when the edit dashboard button is clicked', () => {
expect(findProjectManager().exists()).toBe(false);
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(findProjectManager().exists()).toBe(true);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert, GlTable, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import { generateVulnerabilities } from '../../vulnerabilities/mock_data';
describe('First Class Group Dashboard Vulnerabilities Component', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
describe('First Class Instance Dashboard Vulnerabilities Component', () => {
let wrapper;
let store;
const dashboardDocumentation = 'dashboard-documentation';
const emptyStateSvgPath = 'empty-state-path';
......@@ -17,12 +22,32 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
const findEmptyState = () => wrapper.find(GlEmptyState);
const findAlert = () => wrapper.find(GlAlert);
const createWrapper = ({ stubs, loading = false } = {}) => {
const createWrapper = ({ stubs, loading = false, isUpdatingProjects, data } = {}) => {
store = new Vuex.Store({
modules: {
projectSelector: {
namespaced: true,
actions: {
fetchProjects() {},
setProjectEndpoints() {},
},
getters: {
isUpdatingProjects: jest.fn().mockReturnValue(isUpdatingProjects),
},
state: {
projects: [],
},
},
},
});
return shallowMount(FirstClassInstanceVulnerabilities, {
localVue,
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
},
store,
stubs,
mocks: {
$apollo: {
......@@ -30,11 +55,13 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
},
fetchNextPage: () => {},
},
data,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when the query is loading', () => {
......@@ -62,10 +89,10 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
stubs: {
GlAlert,
},
});
wrapper.setData({
errorLoadingVulnerabilities: true,
data: () => ({
isFirstResultLoading: false,
errorLoadingVulnerabilities: true,
}),
});
});
......@@ -114,10 +141,10 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
GlTable,
GlEmptyState,
},
});
wrapper.setData({
vulnerabilities,
data: () => ({
vulnerabilities,
isFirstResultLoading: false,
}),
});
});
......@@ -141,13 +168,13 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper();
wrapper.setData({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
wrapper = createWrapper({
data: () => ({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
}),
});
});
......
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