Commit ff7b705a authored by David Pisek's avatar David Pisek Committed by Jose Ivan Vargas

Add generic report section to vuln details

* Adds component with collapsible header
* Adds rows, and items components
* Adds "url" component
parent a3f68244
...@@ -12,6 +12,7 @@ import Poll from '~/lib/utils/poll'; ...@@ -12,6 +12,7 @@ import Poll from '~/lib/utils/poll';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GenericReportSection from './generic_report/report_section.vue';
import HistoryEntry from './history_entry.vue'; import HistoryEntry from './history_entry.vue';
import RelatedIssues from './related_issues.vue'; import RelatedIssues from './related_issues.vue';
import RelatedJiraIssues from './related_jira_issues.vue'; import RelatedJiraIssues from './related_jira_issues.vue';
...@@ -20,6 +21,7 @@ import StatusDescription from './status_description.vue'; ...@@ -20,6 +21,7 @@ import StatusDescription from './status_description.vue';
export default { export default {
name: 'VulnerabilityFooter', name: 'VulnerabilityFooter',
components: { components: {
GenericReportSection,
SolutionCard, SolutionCard,
MergeRequestNote, MergeRequestNote,
HistoryEntry, HistoryEntry,
...@@ -206,6 +208,11 @@ export default { ...@@ -206,6 +208,11 @@ export default {
<template> <template>
<div data-qa-selector="vulnerability_footer"> <div data-qa-selector="vulnerability_footer">
<solution-card v-if="hasSolution" v-bind="solutionInfo" /> <solution-card v-if="hasSolution" v-bind="solutionInfo" />
<generic-report-section
v-if="vulnerability.details"
class="md gl-mt-6"
:details="vulnerability.details"
/>
<div v-if="vulnerability.mergeRequestFeedback" class="card gl-mt-5"> <div v-if="vulnerability.mergeRequestFeedback" class="card gl-mt-5">
<merge-request-note <merge-request-note
:feedback="vulnerability.mergeRequestFeedback" :feedback="vulnerability.mergeRequestFeedback"
......
<script>
import Url from './types/url.vue';
export default {
components: {
Url,
},
props: {
item: {
type: Object,
required: true,
},
},
};
</script>
<template>
<component :is="item.type" v-bind="item" data-testid="reportComponent" />
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
class="generic-report-row gl-display-grid gl-px-3 gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<strong>{{ label }}</strong>
<div data-testid="reportContent">
<slot></slot>
</div>
</div>
</template>
<script>
import { GlCollapse, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import ReportItem from './report_item.vue';
import ReportRow from './report_row.vue';
import { isValidReportType } from './types/utils';
export default {
i18n: {
heading: s__('Vulnerability|Evidence'),
},
components: {
GlCollapse,
GlIcon,
ReportItem,
ReportRow,
},
props: {
details: {
type: Object,
required: true,
},
},
data() {
return {
showSection: true,
};
},
computed: {
detailsEntries() {
return Object.entries(this.details).filter(([, item]) => isValidReportType(item.type));
},
hasDetails() {
return this.detailsEntries.length > 0;
},
},
methods: {
toggleShowSection() {
this.showSection = !this.showSection;
},
},
};
</script>
<template>
<section v-if="hasDetails">
<header class="gl-display-flex gl-align-items-center">
<gl-icon name="angle-right" class="gl-mr-2" :class="{ 'gl-rotate-90': showSection }" />
<h3 class="gl-display-inline gl-my-0! gl-cursor-pointer" @click="toggleShowSection">
{{ $options.i18n.heading }}
</h3>
</header>
<gl-collapse :visible="showSection">
<div data-testid="reports">
<template v-for="[label, item] in detailsEntries">
<report-row :key="label" :label="item.name" :data-testid="`report-row-${label}`">
<report-item :item="item" />
</report-row>
</template>
</div>
</gl-collapse>
</section>
</template>
export const REPORT_TYPE_URL = 'url';
export const REPORT_TYPES = [REPORT_TYPE_URL];
<script>
import { GlLink } from '@gitlab/ui';
export default {
components: {
GlLink,
},
props: {
href: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-link :href="href">{{ href }}</gl-link>
</template>
import { REPORT_TYPES } from './constants';
/**
* Check if a given type is supported (i.e, is mapped to a component and can be rendered)
*
* @param string type
* @returns boolean
*/
export const isValidReportType = (type) => REPORT_TYPES.includes(type);
...@@ -119,3 +119,12 @@ $selection-summary-with-error-height: 118px; ...@@ -119,3 +119,12 @@ $selection-summary-with-error-height: 118px;
@include sticky-top-positioning($security-filter-height + $selection-summary-with-error-height); @include sticky-top-positioning($security-filter-height + $selection-summary-with-error-height);
} }
} }
.generic-report-row {
grid-template-columns: minmax(150px, 1fr) 3fr;
grid-column-gap: $gl-spacing-scale-5;
&:last-child {
@include gl-border-b-0;
}
}
---
title: Add generic reports section and 'url' type to vulnerability details page
merge_request: 56635
author:
type: added
...@@ -4,6 +4,7 @@ import Api from 'ee/api'; ...@@ -4,6 +4,7 @@ import Api from 'ee/api';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue'; import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue'; import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue'; import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue'; import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue'; import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue';
...@@ -330,4 +331,36 @@ describe('Vulnerability Footer', () => { ...@@ -330,4 +331,36 @@ describe('Vulnerability Footer', () => {
}, },
); );
}); });
describe('generic report', () => {
const mockDetails = { foo: { type: 'bar' } };
const genericReportSection = () => wrapper.findComponent(GenericReportSection);
describe('when a vulnerability contains a details property', () => {
beforeEach(() => {
createWrapper({ details: mockDetails });
});
it('renders the report section', () => {
expect(genericReportSection().exists()).toBe(true);
});
it('passes the correct props to the report section', () => {
expect(genericReportSection().props()).toMatchObject({
details: mockDetails,
});
});
});
describe('when a vulnerability does not contain a details property', () => {
beforeEach(() => {
createWrapper();
});
it('does not render the report section', () => {
expect(genericReportSection().exists()).toBe(false);
});
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import ReportItem from 'ee/vulnerabilities/components/generic_report/report_item.vue';
import {
REPORT_TYPES,
REPORT_TYPE_URL,
} from 'ee/vulnerabilities/components/generic_report/types/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const TEST_DATA = {
[REPORT_TYPE_URL]: {
href: 'http://foo.com',
},
};
describe('ee/vulnerabilities/components/generic_report/report_item.vue', () => {
let wrapper;
const createWrapper = ({ props } = {}) =>
extendedWrapper(
shallowMount(ReportItem, {
propsData: {
item: {},
...props,
},
}),
);
const findReportComponent = () => wrapper.findByTestId('reportComponent');
describe.each(REPORT_TYPES)('with report type "%s"', (reportType) => {
const reportItem = { type: reportType, ...TEST_DATA[reportType] };
beforeEach(() => {
wrapper = createWrapper({ props: { item: reportItem } });
});
it('renders the corresponding component', () => {
expect(findReportComponent().exists()).toBe(true);
});
it('passes the report data as props', () => {
expect(findReportComponent().props()).toMatchObject({
item: reportItem,
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import ReportRow from 'ee/vulnerabilities/components/generic_report/report_row.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('ee/vulnerabilities/components/generic_report/report_row.vue', () => {
let wrapper;
const createWrapper = ({ ...options } = {}) =>
extendedWrapper(
shallowMount(ReportRow, {
propsData: {
label: 'Foo',
},
...options,
}),
);
it('renders the default slot', () => {
const slotContent = 'foo bar';
wrapper = createWrapper({ slots: { default: slotContent } });
expect(wrapper.findByTestId('reportContent').text()).toBe(slotContent);
});
});
import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ReportItem from 'ee/vulnerabilities/components/generic_report/report_item.vue';
import ReportRow from 'ee/vulnerabilities/components/generic_report/report_row.vue';
import ReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { REPORT_TYPE_URL } from 'ee/vulnerabilities/components/generic_report/types/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const TEST_DATA = {
supportedTypes: {
one: {
name: 'one',
type: REPORT_TYPE_URL,
href: 'http://foo.com',
},
two: {
name: 'two',
type: REPORT_TYPE_URL,
href: 'http://bar.com',
},
},
unsupportedTypes: {
three: {
name: 'three',
type: 'not-supported',
},
},
};
describe('ee/vulnerabilities/components/generic_report/report_section.vue', () => {
let wrapper;
const createWrapper = (options) =>
extendedWrapper(
mount(ReportSection, {
propsData: {
details: { ...TEST_DATA.supportedTypes },
},
...options,
}),
);
const withinWrapper = () => within(wrapper.element);
const findHeading = () =>
withinWrapper().getByRole('heading', {
name: /evidence/i,
});
const findReportsSection = () => wrapper.findByTestId('reports');
const findAllReportRows = () => wrapper.findAllComponents(ReportRow);
const findReportRowByLabel = (label) => wrapper.findByTestId(`report-row-${label}`);
const findItemWithinRow = (row) => row.findComponent(ReportItem);
const supportedReportTypesLabels = Object.keys(TEST_DATA.supportedTypes);
describe('with supported report types', () => {
beforeEach(() => {
wrapper = createWrapper();
});
describe('reports section', () => {
it('contains a heading', () => {
expect(findHeading()).toBeInstanceOf(HTMLElement);
});
it('collapses when the heading is clicked', async () => {
expect(findReportsSection().isVisible()).toBe(true);
fireEvent.click(findHeading());
await nextTick();
expect(findReportsSection().isVisible()).toBe(false);
});
});
describe('report rows', () => {
it('shows a row for each report item', () => {
expect(findAllReportRows()).toHaveLength(supportedReportTypesLabels.length);
});
it.each(supportedReportTypesLabels)('passes the correct props to report row: %s', (label) => {
expect(findReportRowByLabel(label).props()).toMatchObject({
label: TEST_DATA.supportedTypes[label].name,
});
});
});
describe('report items', () => {
it.each(supportedReportTypesLabels)(
'passes the correct props to item for row: %s',
(label) => {
const row = findReportRowByLabel(label);
expect(findItemWithinRow(row).props()).toMatchObject({
item: TEST_DATA.supportedTypes[label],
});
},
);
});
});
describe('with unsupported report types', () => {
it('only renders valid report types', () => {
wrapper = createWrapper({
propsData: {
details: {
...TEST_DATA.supportedTypes,
...TEST_DATA.unsupportedTypes,
},
},
});
expect(findAllReportRows()).toHaveLength(supportedReportTypesLabels.length);
});
it('does not render the section if the details only contain non-supported types', () => {
wrapper = createWrapper({
propsData: {
details: {
...TEST_DATA.unsupportedTypes,
},
},
});
expect(findReportsSection().exists()).toBe(false);
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Url from 'ee/vulnerabilities/components/generic_report/types/url.vue';
const TEST_DATA = {
href: 'http://gitlab.com',
};
describe('ee/vulnerabilities/components/generic_report/types/url.vue', () => {
let wrapper;
const createWrapper = () => {
return shallowMount(Url, {
propsData: {
...TEST_DATA,
},
});
};
const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => {
wrapper = createWrapper();
});
it('renders a link', () => {
expect(findLink().exists()).toBe(true);
});
it('passes the href to the link', () => {
expect(findLink().attributes('href')).toBe(TEST_DATA.href);
});
it('shows the href as the link-text', () => {
expect(findLink().text()).toBe(TEST_DATA.href);
});
});
import { REPORT_TYPES } from 'ee/vulnerabilities/components/generic_report/types/constants';
import { isValidReportType } from 'ee/vulnerabilities/components/generic_report/types/utils';
describe('ee/vulnerabilities/components/generic_report/types/utils', () => {
describe('isValidReportType', () => {
it.each(REPORT_TYPES)('returns "true" if the given type is a "%s"', (reportType) => {
expect(isValidReportType(reportType)).toBe(true);
});
it('returns "false" if the given type is not supported', () => {
expect(isValidReportType('this-type-does-not-exist')).toBe(false);
});
});
});
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