Commit b567ab3c authored by Savas Vedova's avatar Savas Vedova

Load the GraphQL based vulnerabilities dashboard

Create a new dashboard for groups which loads data from GQL
parent 0aff7751
import initGroupSecurityDashboard from 'ee/security_dashboard/group_init';
import initFirstClassSecurityDashboard from 'ee/security_dashboard/first_class_init';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
document.addEventListener('DOMContentLoaded', initGroupSecurityDashboard);
document.addEventListener('DOMContentLoaded', () => {
if (gon.features?.firstClassVulnerabilities) {
initFirstClassSecurityDashboard(
document.getElementById('js-group-security-dashboard'),
DASHBOARD_TYPES.GROUP,
);
} else {
initGroupSecurityDashboard();
}
});
<script>
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import GroupSecurityVulnerabilities from './first_class_group_security_dashboard_vulnerabilities.vue';
export default {
components: {
SecurityDashboardLayout,
GroupSecurityVulnerabilities,
},
props: {
dashboardDocumentation: {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
groupFullPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<security-dashboard-layout>
<group-security-vulnerabilities
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:group-full-path="groupFullPath"
/>
</security-dashboard-layout>
</template>
<script>
import { s__ } from '~/locale';
import { GlAlert, GlNewButton, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import vulnerabilitiesQuery from '../graphql/group_vulnerabilities.graphql';
import { VULNERABILITIES_PER_PAGE } from 'ee/vulnerabilities/constants';
export default {
components: {
GlAlert,
GlNewButton,
GlEmptyState,
GlIntersectionObserver,
VulnerabilityList,
},
props: {
dashboardDocumentation: {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
groupFullPath: {
type: String,
required: true,
},
},
data() {
return {
pageInfo: {},
vulnerabilities: [],
errorLoadingVulnerabilities: false,
};
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
variables() {
return {
fullPath: this.groupFullPath,
first: VULNERABILITIES_PER_PAGE,
};
},
update: ({ group }) => group.vulnerabilities.nodes,
result({ data }) {
this.pageInfo = data.group.vulnerabilities.pageInfo;
},
error() {
this.errorLoadingVulnerabilities = true;
},
},
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.vulnerabilities.length === 0;
},
},
methods: {
onErrorDismiss() {
this.errorLoadingVulnerabilities = false;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
fetchMoreResult.group.vulnerabilities.nodes.unshift(
...previousResult.group.vulnerabilities.nodes,
);
return fetchMoreResult;
},
});
}
},
},
emptyStateDescription: s__(
`While it's rare to have no vulnerabilities for your group, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly.`,
),
};
</script>
<template>
<div>
<gl-alert
v-if="errorLoadingVulnerabilities"
class="mb-4"
variant="danger"
@dismiss="onErrorDismiss"
>
{{
s__(
'Security Dashboard|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<vulnerability-list
v-else
:is-loading="isLoadingFirstResult"
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:vulnerabilities="vulnerabilities"
>
<template #emptyState>
<gl-empty-state
:title="s__(`No vulnerabilities found for this group`)"
:svg-path="emptyStateSvgPath"
:description="$options.emptyStateDescription"
:primary-button-link="dashboardDocumentation"
:primary-button-text="s__('Security Reports|Learn more about setting up your dashboard')"
/>
</template>
</vulnerability-list>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-new-button :loading="isLoadingQuery" :disabled="isLoadingQuery" @click="fetchNextPage">{{
__('Load more vulnerabilities')
}}</gl-new-button>
</gl-intersection-observer>
</div>
</template>
......@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import FirstClassProjectSecurityDashboard from './components/first_class_project_security_dashboard.vue';
import FirstClassGroupSecurityDashboard from './components/first_class_group_security_dashboard.vue';
const isRequired = message => {
throw new Error(message);
......@@ -24,19 +25,21 @@ export default (
emptyStateSvgPath,
dashboardDocumentation,
};
let element;
let component;
// We'll add more of these for group and instance once we have the components
if (dashboardType === DASHBOARD_TYPES.PROJECT) {
element = FirstClassProjectSecurityDashboard;
component = FirstClassProjectSecurityDashboard;
props.projectFullPath = el.dataset.projectFullPath;
} else if (dashboardType === DASHBOARD_TYPES.GROUP) {
component = FirstClassGroupSecurityDashboard;
props.groupFullPath = el.dataset.groupFullPath;
}
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(element, { props });
return createElement(component, { props });
},
});
};
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql"
query group($fullPath: ID!, $after: String, $first: Int) {
group(fullPath: $fullPath) {
vulnerabilities(after:$after, first:$first){
nodes{
...Vulnerability
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -2,6 +2,10 @@
class Groups::Security::DashboardController < Groups::ApplicationController
layout 'group'
before_action only: [:show] do
push_frontend_feature_flag(:first_class_vulnerabilities, default: false)
end
def show
render :unavailable unless dashboard_available?
end
......
......@@ -4,6 +4,7 @@
#js-group-security-dashboard{ data: { vulnerabilities_endpoint: group_security_vulnerability_findings_path(@group),
vulnerabilities_history_endpoint: history_group_security_vulnerability_findings_path(@group),
projects_endpoint: expose_url(api_v4_groups_projects_path(id: @group.id)),
group_full_path: @group.full_path,
vulnerability_feedback_help_path: help_page_path("user/application_security/index", anchor: "interacting-with-the-vulnerabilities"),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
dashboard_documentation: help_page_path('user/application_security/security_dashboard/index'),
......
......@@ -8,6 +8,7 @@ describe 'Group overview', :js do
let(:empty_project) { create(:project, namespace: group) }
before do
stub_feature_flags(first_class_vulnerabilities: false)
group.add_owner(user)
sign_in(user)
end
......
import { shallowMount } from '@vue/test-utils';
import FirstClassGroupDashboard from 'ee/security_dashboard/components/first_class_group_security_dashboard.vue';
import FirstClassGroupVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue';
describe('First Class Group Dashboard Component', () => {
let wrapper;
const dashboardDocumentation = 'dashboard-documentation';
const emptyStateSvgPath = 'empty-state-path';
const groupFullPath = 'group-full-path';
const findGroupVulnerabilities = () => wrapper.find(FirstClassGroupVulnerabilities);
const createWrapper = () => {
return shallowMount(FirstClassGroupDashboard, {
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
groupFullPath,
},
});
};
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('should render correctly', () => {
expect(findGroupVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
groupFullPath,
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlTable, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import FirstClassGroupVulnerabilities from 'ee/security_dashboard/components/first_class_group_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', () => {
let wrapper;
const dashboardDocumentation = 'dashboard-documentation';
const emptyStateSvgPath = 'empty-state-path';
const groupFullPath = 'group-full-path';
const emptyStateDescription =
"While it's rare to have no vulnerabilities for your group, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly.";
const findIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findVulnerabilities = () => wrapper.find(VulnerabilityList);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findAlert = () => wrapper.find(GlAlert);
const createWrapper = ({ $apollo, stubs }) => {
return shallowMount(FirstClassGroupVulnerabilities, {
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
groupFullPath,
},
stubs,
mocks: {
$apollo,
fetchNextPage: () => {},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when the query is loading', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: true } },
},
});
});
it('passes down isLoading correctly', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
isLoading: true,
vulnerabilities: [],
});
});
});
describe('when the query returned an error status', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
stubs: {
GlAlert,
},
});
wrapper.setData({
errorLoadingVulnerabilities: true,
});
});
it('displays the alert', () => {
expect(findAlert().text()).toBe(
'Error fetching the vulnerability list. Please check your network connection and try again.',
);
});
it('should have an alert that is dismissable', () => {
const alert = findAlert();
alert.find('button').trigger('click');
return wrapper.vm.$nextTick(() => {
expect(alert.exists()).toBe(false);
});
});
it('does not display the vulnerabilities', () => {
expect(findVulnerabilities().exists()).toBe(false);
});
});
describe('when the query returned an empty vulnerability list', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
stubs: {
VulnerabilityList,
GlTable,
GlEmptyState,
},
});
});
it('displays the empty state', () => {
expect(findEmptyState().text()).toContain(emptyStateDescription);
});
});
describe('when the query is loaded and we have results', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
stubs: {
VulnerabilityList,
GlTable,
GlEmptyState,
},
});
wrapper.setData({
vulnerabilities,
});
});
it('does not have an empty state', () => {
expect(wrapper.html()).not.toContain(emptyStateDescription);
});
it('passes down properties correctly', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
isLoading: false,
vulnerabilities,
});
});
});
describe('when there is more than a page of vulnerabilities', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
});
wrapper.setData({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
});
});
it('should render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(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