Commit 46b182e0 authored by Sam Beckham's avatar Sam Beckham Committed by Natalia Tepluhina

Uses graphQL on the vulnerability list page

- Swaps out the REST endpoints for GraphQL data
- Uses the observer component to lazily load extra vulnerabiltiies
- Hooks up the error and loading states
- WIP: Starts work on the tests
parent 0ec581bc
import initProjectSecurityDashboard from 'ee/security_dashboard/project_init'; import initProjectSecurityDashboard from 'ee/security_dashboard/project_init';
import initFirstClassSecurityDashboard from 'ee/security_dashboard/first_class_init';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
document.addEventListener('DOMContentLoaded', initProjectSecurityDashboard); document.addEventListener('DOMContentLoaded', () => {
if (gon.features?.firstClassVulnerabilities) {
initFirstClassSecurityDashboard(
document.getElementById('js-security-report-app'),
DASHBOARD_TYPES.PROJECT,
);
} else {
initProjectSecurityDashboard();
}
});
import Vue from 'vue'; import Vue from 'vue';
import VulnerabilitiesApp from 'ee/vulnerabilities/components/vulnerabilities_app.vue'; import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
import createStore from 'ee/security_dashboard/store'; import createDefaultClient from '~/lib/graphql';
import VueApollo from 'vue-apollo';
Vue.use(VueApollo);
function render() { function render() {
const el = document.getElementById('app'); const el = document.getElementById('app');
...@@ -9,17 +12,21 @@ function render() { ...@@ -9,17 +12,21 @@ function render() {
return false; return false;
} }
const { dashboardDocumentation, emptyStateSvgPath, vulnerabilitiesEndpoint } = el.dataset; const { dashboardDocumentation, emptyStateSvgPath, projectFullPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({ return new Vue({
el, el,
store: createStore(), apolloProvider,
render(createElement) { render(createElement) {
return createElement(VulnerabilitiesApp, { return createElement(ProjectVulnerabilitiesApp, {
props: { props: {
emptyStateSvgPath, emptyStateSvgPath,
dashboardDocumentation, dashboardDocumentation,
vulnerabilitiesEndpoint, projectFullPath,
}, },
}); });
}, },
......
<script>
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
export default {
components: {
SecurityDashboardLayout,
ProjectVulnerabilitiesApp,
},
props: {
dashboardDocumentation: {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
projectFullPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<security-dashboard-layout>
<project-vulnerabilities-app
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:project-full-path="projectFullPath"
/>
</security-dashboard-layout>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import FirstClassProjectDashboard from './components/first_class_project_dashboard.vue';
const isRequired = message => {
throw new Error(message);
};
export default (
/* eslint-disable @gitlab/require-i18n-strings */
el = isRequired('No element was passed to the security dashboard initializer'),
dashboardType = isRequired('No dashboard type was passed to the security dashboard initializer'),
/* eslint-enable @gitlab/require-i18n-strings */
) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { dashboardDocumentation, emptyStateSvgPath } = el.dataset;
const props = {
emptyStateSvgPath,
dashboardDocumentation,
};
let element;
// We'll add more of these for group and instance once we have the components
if (dashboardType === DASHBOARD_TYPES.PROJECT) {
element = FirstClassProjectDashboard;
props.projectFullPath = el.dataset.projectFullPath;
}
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(element, { props });
},
});
};
<script> <script>
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { GlAlert, GlEmptyState } from '@gitlab/ui'; import { GlAlert, GlButton, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue'; import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import vulnerabilitiesQuery from '../graphql/project_vulnerabilities.graphql';
import { VULNERABILITIES_PER_PAGE } from '../constants';
export default { export default {
name: 'VulnerabilitiesApp', name: 'ProjectVulnerabilitiesApp',
components: { components: {
GlAlert, GlAlert,
GlButton,
GlEmptyState, GlEmptyState,
PaginationLinks, GlIntersectionObserver,
VulnerabilityList, VulnerabilityList,
}, },
props: { props: {
vulnerabilitiesEndpoint: { dashboardDocumentation: {
type: String, type: String,
required: true, required: true,
}, },
dashboardDocumentation: { emptyStateSvgPath: {
type: String, type: String,
required: true, required: true,
}, },
emptyStateSvgPath: { projectFullPath: {
type: String, type: String,
required: true, required: true,
}, },
}, },
computed: { data() {
...mapState('vulnerabilities', [ return {
'errorLoadingVulnerabilities', pageInfo: {},
'isLoadingVulnerabilities', vulnerabilities: [],
'pageInfo', errorLoadingVulnerabilities: false,
'vulnerabilities', };
]),
...mapState('filters', ['activeFilters']),
}, },
created() { apollo: {
this.setVulnerabilitiesEndpoint(this.vulnerabilitiesEndpoint); vulnerabilities: {
this.fetchVulnerabilities(); query: vulnerabilitiesQuery,
variables() {
return {
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
};
},
update: ({ project }) => project.vulnerabilities.nodes,
result({ data }) {
this.pageInfo = data.project.vulnerabilities.pageInfo;
},
error() {
this.errorLoadingVulnerabilities = true;
},
},
},
computed: {
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length === 0;
},
}, },
methods: { methods: {
...mapActions('vulnerabilities', ['setVulnerabilitiesEndpoint', 'fetchVulnerabilities']), fetchNextPage() {
fetchPage(page) { if (this.pageInfo.hasNextPage) {
this.fetchVulnerabilities({ ...this.activeFilters, page }); this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
const newResult = { ...fetchMoreResult };
previousResult.project.vulnerabilities.nodes.push(
...fetchMoreResult.project.vulnerabilities.nodes,
);
newResult.project.vulnerabilities.nodes = previousResult.project.vulnerabilities.nodes;
return newResult;
},
});
}
}, },
}, },
emptyStateDescription: s__( emptyStateDescription: s__(
...@@ -63,7 +95,7 @@ export default { ...@@ -63,7 +95,7 @@ export default {
</gl-alert> </gl-alert>
<vulnerability-list <vulnerability-list
v-else v-else
:is-loading="isLoadingVulnerabilities" :is-loading="isLoadingFirstVulnerabilities"
:dashboard-documentation="dashboardDocumentation" :dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath" :empty-state-svg-path="emptyStateSvgPath"
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
...@@ -78,11 +110,17 @@ export default { ...@@ -78,11 +110,17 @@ export default {
/> />
</template> </template>
</vulnerability-list> </vulnerability-list>
<pagination-links <gl-intersection-observer
v-if="pageInfo.total > 1" v-if="pageInfo.hasNextPage"
class="justify-content-center prepend-top-default" class="text-center"
:page-info="pageInfo" @appear="fetchNextPage"
:change="fetchPage" >
/> <gl-button
:loading="isLoadingVulnerabilities"
:disabled="isLoadingVulnerabilities"
@click="fetchNextPage"
>{{ __('Load more vulnerabilities') }}</gl-button
>
</gl-intersection-observer>
</div> </div>
</template> </template>
...@@ -3,6 +3,7 @@ import { s__, __ } from '~/locale'; ...@@ -3,6 +3,7 @@ import { s__, __ } from '~/locale';
import { GlEmptyState, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui'; import { GlEmptyState, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import RemediatedBadge from './remediated_badge.vue'; import RemediatedBadge from './remediated_badge.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { VULNERABILITIES_PER_PAGE } from '../constants';
export default { export default {
name: 'VulnerabilityList', name: 'VulnerabilityList',
...@@ -33,15 +34,6 @@ export default { ...@@ -33,15 +34,6 @@ export default {
default: false, default: false,
}, },
}, },
methods: {
vulnerabilityPath({ id, path }) {
// Since there's no way to get this from the current data, I'll have to make it up.
// It will be available shortly though, so there's a fall-forward to use it once it's available
// We should remove this method once it's fully available.
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25740
return path || `vulnerabilities/${id}`;
},
},
fields: [ fields: [
{ {
key: 'state', key: 'state',
...@@ -58,6 +50,7 @@ export default { ...@@ -58,6 +50,7 @@ export default {
label: __('Description'), label: __('Description'),
}, },
], ],
VULNERABILITIES_PER_PAGE,
}; };
</script> </script>
...@@ -71,7 +64,7 @@ export default { ...@@ -71,7 +64,7 @@ export default {
responsive responsive
> >
<template #cell(state)="{ item }"> <template #cell(state)="{ item }">
<span class="text-capitalize js-status">{{ item.state }}</span> <span class="text-capitalize js-status">{{ item.state.toLowerCase() }}</span>
</template> </template>
<template #cell(severity)="{ item }"> <template #cell(severity)="{ item }">
...@@ -79,14 +72,19 @@ export default { ...@@ -79,14 +72,19 @@ export default {
</template> </template>
<template #cell(title)="{ item }"> <template #cell(title)="{ item }">
<gl-link class="text-body js-description" :href="vulnerabilityPath(item)"> <gl-link class="text-body js-description" :href="item.vulnerabilityPath">
{{ item.title }} {{ item.title }}
</gl-link> </gl-link>
<remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" /> <remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
</template> </template>
<template #table-busy> <template #table-busy>
<gl-skeleton-loading v-for="n in 10" :key="n" class="m-2 js-skeleton-loader" :lines="2" /> <gl-skeleton-loading
v-for="n in $options.VULNERABILITIES_PER_PAGE"
:key="n"
class="m-2 js-skeleton-loader"
:lines="2"
/>
</template> </template>
<template #empty> <template #empty>
......
import { s__ } from '~/locale'; import { s__ } from '~/locale';
// eslint-disable-next-line import/prefer-default-export
export const VULNERABILITY_STATES = { export const VULNERABILITY_STATES = {
dismissed: { dismissed: {
action: 'dismiss', action: 'dismiss',
...@@ -21,3 +20,5 @@ export const VULNERABILITY_STATES = { ...@@ -21,3 +20,5 @@ export const VULNERABILITY_STATES = {
description: s__('VulnerabilityManagement|Verified as fixed or mitigated'), description: s__('VulnerabilityManagement|Verified as fixed or mitigated'),
}, },
}; };
export const VULNERABILITIES_PER_PAGE = 20;
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./vulnerability.fragment.graphql"
query project($fullPath: ID!, $after: String, $first: Int) {
project(fullPath: $fullPath) {
vulnerabilities(after:$after, first:$first){
nodes{
...Vulnerability
}
pageInfo {
...PageInfo
}
}
}
}
fragment Vulnerability on Vulnerability {
id
title
state
severity
vulnerabilityPath
}
...@@ -9,6 +9,7 @@ module Projects ...@@ -9,6 +9,7 @@ module Projects
before_action only: [:index] do before_action only: [:index] do
push_frontend_feature_flag(:hide_dismissed_vulnerabilities) push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:first_class_vulnerabilities, @project)
end end
def index def index
......
...@@ -209,6 +209,7 @@ module EE ...@@ -209,6 +209,7 @@ module EE
else else
{ {
project: { id: project.id, name: project.name }, project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_endpoint: project_security_vulnerability_findings_path(project), vulnerabilities_endpoint: project_security_vulnerability_findings_path(project),
vulnerabilities_summary_endpoint: summary_project_security_vulnerability_findings_path(project), vulnerabilities_summary_endpoint: summary_project_security_vulnerability_findings_path(project),
vulnerability_feedback_help_path: help_page_path("user/application_security/index", anchor: "interacting-with-the-vulnerabilities"), vulnerability_feedback_help_path: help_page_path("user/application_security/index", anchor: "interacting-with-the-vulnerabilities"),
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
#app{ data: { empty_state_svg_path: image_path('illustrations/security-dashboard_empty.svg'), #app{ data: { empty_state_svg_path: image_path('illustrations/security-dashboard_empty.svg'),
vulnerabilities_endpoint: expose_path(api_v4_projects_vulnerabilities_path(id: @project.id)), vulnerabilities_endpoint: expose_path(api_v4_projects_vulnerabilities_path(id: @project.id)),
vulnerability_exports_endpoint: expose_path(api_v4_projects_vulnerability_exports_path(id: @project.id)), vulnerability_exports_endpoint: expose_path(api_v4_projects_vulnerability_exports_path(id: @project.id)),
project_full_path: @project.full_path,
dashboard_documentation: help_page_path('user/application_security/security_dashboard/index') } } dashboard_documentation: help_page_path('user/application_security/security_dashboard/index') } }
-# Display table loading animation while Vue app loads -# Display table loading animation while Vue app loads
%table.table.gl-table %table.table.gl-table
......
import { GlAlert, GlIntersectionObserver } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import { generateVulnerabilities } from './mock_data';
describe('Vulnerabilities app component', () => {
let wrapper;
const apolloMock = {
queries: { vulnerabilities: { loading: true } },
};
const createWrapper = ({ props = {}, $apollo = apolloMock } = {}, options = {}) => {
return shallowMount(ProjectVulnerabilitiesApp, {
propsData: {
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
projectFullPath: '#',
...props,
},
mocks: {
$apollo,
fetchNextPage: () => {},
},
...options,
});
};
const findIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findAlert = () => wrapper.find(GlAlert);
const findVulnerabilityList = () => wrapper.find(VulnerabilityList);
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
describe('when the vulnerabilities are loading', () => {
beforeEach(() => {
createWrapper();
});
it('should be in the loading state', () => {
expect(findVulnerabilityList().props().isLoading).toBe(true);
});
});
describe('with some vulnerabilities', () => {
let vulnerabilities;
beforeEach(() => {
createWrapper();
vulnerabilities = generateVulnerabilities();
wrapper.setData({
vulnerabilities,
});
});
it('should not be in the loading state', () => {
expect(findVulnerabilityList().props().isLoading).toBe(false);
});
it('should pass the vulnerabilities to the vulnerabilities list', () => {
expect(findVulnerabilityList().props().vulnerabilities).toEqual(vulnerabilities);
});
it('should not render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(false);
});
it('should not render the alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('with more than a page of vulnerabilities', () => {
let vulnerabilities;
beforeEach(() => {
createWrapper();
vulnerabilities = generateVulnerabilities();
wrapper.setData({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
});
});
it('should render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
});
describe("when there's a loading error", () => {
beforeEach(() => {
createWrapper();
wrapper.setData({ errorLoadingVulnerabilities: true });
});
it('should render the alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
});
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { waitForMutation } from 'helpers/vue_test_utils_helper';
import createStore from 'ee/security_dashboard/store';
import VulnerabilitiesApp from 'ee/vulnerabilities/components/vulnerabilities_app.vue';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import { vulnerabilities } from './mock_data';
describe('Vulnerabilities app component', () => {
let store;
let wrapper;
const WORKING_ENDPOINT = 'WORKING_ENDPOINT';
const mock = new MockAdapter(axios);
const createWrapper = props => {
store = createStore();
return shallowMount(VulnerabilitiesApp, {
propsData: {
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
vulnerabilitiesEndpoint: '',
...props,
},
store,
});
};
beforeEach(() => {
mock.onGet(WORKING_ENDPOINT).replyOnce(200, vulnerabilities);
wrapper = createWrapper({ vulnerabilitiesEndpoint: WORKING_ENDPOINT });
return waitForMutation(wrapper.vm.$store, `vulnerabilities/RECEIVE_VULNERABILITIES_SUCCESS`);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
it('should pass the vulnerabilties to the vulnerabilites list', () => {
const vulnerabilityList = wrapper.find(VulnerabilityList);
expect(vulnerabilityList.props().vulnerabilities).toEqual(vulnerabilities);
});
});
...@@ -12051,6 +12051,9 @@ msgstr "" ...@@ -12051,6 +12051,9 @@ msgstr ""
msgid "Live preview" msgid "Live preview"
msgstr "" msgstr ""
msgid "Load more vulnerabilities"
msgstr ""
msgid "Loading" msgid "Loading"
msgstr "" 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