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 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 VulnerabilitiesApp from 'ee/vulnerabilities/components/vulnerabilities_app.vue';
import createStore from 'ee/security_dashboard/store';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
import createDefaultClient from '~/lib/graphql';
import VueApollo from 'vue-apollo';
Vue.use(VueApollo);
function render() {
const el = document.getElementById('app');
......@@ -9,17 +12,21 @@ function render() {
return false;
}
const { dashboardDocumentation, emptyStateSvgPath, vulnerabilitiesEndpoint } = el.dataset;
const { dashboardDocumentation, emptyStateSvgPath, projectFullPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
store: createStore(),
apolloProvider,
render(createElement) {
return createElement(VulnerabilitiesApp, {
return createElement(ProjectVulnerabilitiesApp, {
props: {
emptyStateSvgPath,
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>
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import { GlAlert, GlEmptyState } from '@gitlab/ui';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { GlAlert, GlButton, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import vulnerabilitiesQuery from '../graphql/project_vulnerabilities.graphql';
import { VULNERABILITIES_PER_PAGE } from '../constants';
export default {
name: 'VulnerabilitiesApp',
name: 'ProjectVulnerabilitiesApp',
components: {
GlAlert,
GlButton,
GlEmptyState,
PaginationLinks,
GlIntersectionObserver,
VulnerabilityList,
},
props: {
vulnerabilitiesEndpoint: {
dashboardDocumentation: {
type: String,
required: true,
},
dashboardDocumentation: {
emptyStateSvgPath: {
type: String,
required: true,
},
emptyStateSvgPath: {
projectFullPath: {
type: String,
required: true,
},
},
computed: {
...mapState('vulnerabilities', [
'errorLoadingVulnerabilities',
'isLoadingVulnerabilities',
'pageInfo',
'vulnerabilities',
]),
...mapState('filters', ['activeFilters']),
data() {
return {
pageInfo: {},
vulnerabilities: [],
errorLoadingVulnerabilities: false,
};
},
created() {
this.setVulnerabilitiesEndpoint(this.vulnerabilitiesEndpoint);
this.fetchVulnerabilities();
apollo: {
vulnerabilities: {
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: {
...mapActions('vulnerabilities', ['setVulnerabilitiesEndpoint', 'fetchVulnerabilities']),
fetchPage(page) {
this.fetchVulnerabilities({ ...this.activeFilters, page });
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
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__(
......@@ -63,7 +95,7 @@ export default {
</gl-alert>
<vulnerability-list
v-else
:is-loading="isLoadingVulnerabilities"
:is-loading="isLoadingFirstVulnerabilities"
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:vulnerabilities="vulnerabilities"
......@@ -78,11 +110,17 @@ export default {
/>
</template>
</vulnerability-list>
<pagination-links
v-if="pageInfo.total > 1"
class="justify-content-center prepend-top-default"
:page-info="pageInfo"
:change="fetchPage"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-button
:loading="isLoadingVulnerabilities"
:disabled="isLoadingVulnerabilities"
@click="fetchNextPage"
>{{ __('Load more vulnerabilities') }}</gl-button
>
</gl-intersection-observer>
</div>
</template>
......@@ -3,6 +3,7 @@ import { s__, __ } from '~/locale';
import { GlEmptyState, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import RemediatedBadge from './remediated_badge.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { VULNERABILITIES_PER_PAGE } from '../constants';
export default {
name: 'VulnerabilityList',
......@@ -33,15 +34,6 @@ export default {
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: [
{
key: 'state',
......@@ -58,6 +50,7 @@ export default {
label: __('Description'),
},
],
VULNERABILITIES_PER_PAGE,
};
</script>
......@@ -71,7 +64,7 @@ export default {
responsive
>
<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 #cell(severity)="{ item }">
......@@ -79,14 +72,19 @@ export default {
</template>
<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 }}
</gl-link>
<remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
</template>
<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 #empty>
......
import { s__ } from '~/locale';
// eslint-disable-next-line import/prefer-default-export
export const VULNERABILITY_STATES = {
dismissed: {
action: 'dismiss',
......@@ -21,3 +20,5 @@ export const VULNERABILITY_STATES = {
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
before_action only: [:index] do
push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:first_class_vulnerabilities, @project)
end
def index
......
......@@ -209,6 +209,7 @@ module EE
else
{
project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_endpoint: 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"),
......
......@@ -8,6 +8,7 @@
#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)),
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') } }
-# Display table loading animation while Vue app loads
%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 ""
msgid "Live preview"
msgstr ""
msgid "Load more vulnerabilities"
msgstr ""
msgid "Loading"
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