Commit 2f8cec33 authored by Phil Hughes's avatar Phil Hughes

Merge branch '209990-display-history-of-vulnerability-state-changes' into 'master'

Add vulnerability state history list

See merge request gitlab-org/gitlab!28898
parents 2b79f4fa 5d0c545e
<script>
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import HistoryEntry from './history_entry.vue';
export default {
name: 'VulnerabilityFooter',
components: { IssueNote, SolutionCard },
components: { IssueNote, SolutionCard, HistoryEntry },
props: {
feedback: {
type: Object,
......@@ -20,6 +25,11 @@ export default {
required: true,
},
},
data: () => ({
discussions: [],
}),
computed: {
hasIssue() {
return Boolean(this.feedback?.issue_iid);
......@@ -28,6 +38,21 @@ export default {
return this.solutionInfo.solution || this.solutionInfo.hasRemediation;
},
},
created() {
axios
.get(joinPaths(window.location.href, 'discussions'))
.then(({ data }) => {
this.discussions = data;
})
.catch(() => {
createFlash(
s__(
'VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
),
);
});
},
};
</script>
<template>
......@@ -37,5 +62,13 @@ export default {
<issue-note :feedback="feedback" :project="project" class="card-body" />
</div>
<hr />
<ul v-if="discussions.length" ref="historyList" class="notes">
<history-entry
v-for="discussion in discussions"
:key="discussion.id"
:discussion="discussion"
/>
</ul>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: { Icon, TimeAgoTooltip },
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
systemNote() {
return this.discussion.notes.find(x => x.system === true);
},
},
};
</script>
<template>
<li v-if="systemNote" class="card border-bottom system-note p-0">
<div class="note-header-info mx-3 my-4">
<div class="timeline-icon mr-0">
<icon ref="icon" :name="systemNote.system_note_icon_name" />
</div>
<a
:href="systemNote.author.path"
class="js-user-link ml-3"
:data-user-id="systemNote.author.id"
>
<strong ref="authorName" class="note-header-author-name">
{{ systemNote.author.name }}
</strong>
<span
v-if="systemNote.author.status_tooltip_html"
ref="authorStatus"
v-html="systemNote.author.status_tooltip_html"
></span>
<span ref="authorUsername" class="note-headline-light">
@{{ systemNote.author.username }}
</span>
</a>
<span ref="stateChangeMessage" class="note-headline-light">
{{ systemNote.note }}
<time-ago-tooltip :time="systemNote.created_at" />
</span>
</div>
</li>
</template>
......@@ -81,7 +81,7 @@ export default {
:img-src="user.avatar_url"
:img-size="24"
:username="user.name"
:data-user="user.id"
:data-user-id="user.id"
class="font-weight-bold js-user-link"
img-css-classes="avatar-inline"
/>
......
import { shallowMount } from '@vue/test-utils';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
import { TEST_HOST } from 'helpers/test_constants';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
describe('Vulnerability Footer', () => {
let wrapper;
......@@ -56,6 +63,7 @@ describe('Vulnerability Footer', () => {
afterEach(() => {
wrapper.destroy();
mockAxios.reset();
});
describe('solution card', () => {
......@@ -86,4 +94,43 @@ describe('Vulnerability Footer', () => {
expect(wrapper.contains(IssueNote)).toBe(false);
});
});
describe('state history', () => {
const discussionUrl = 'http://localhost/discussions';
const historyList = () => wrapper.find({ ref: 'historyList' });
const historyEntries = () => wrapper.findAll(HistoryEntry);
it('does not render the history list if there are no history items', () => {
mockAxios.onGet(discussionUrl).replyOnce(200, []);
createWrapper();
expect(historyList().exists()).toBe(false);
});
it('does render the history list if there are history items', () => {
// The shape of this object doesn't matter for this test, we just need to verify that it's passed to the history
// entry.
const historyItems = [{ id: 1, note: 'some note' }, { id: 2, note: 'another note' }];
mockAxios.onGet(discussionUrl).replyOnce(200, historyItems);
createWrapper();
return axios.waitForAll().then(() => {
expect(historyList().exists()).toBe(true);
expect(historyEntries().length).toBe(2);
const entry1 = historyEntries().at(0);
const entry2 = historyEntries().at(1);
expect(entry1.props('discussion')).toEqual(historyItems[0]);
expect(entry2.props('discussion')).toEqual(historyItems[1]);
});
});
it('shows an error the history list could not be retrieved', () => {
mockAxios.onGet(discussionUrl).replyOnce(500);
createWrapper();
return axios.waitForAll().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
describe('History Entry', () => {
let wrapper;
const note = {
system: true,
note: 'changed vulnerability status to dismissed',
system_note_icon_name: 'cancel',
created_at: 'created_at_timestamp',
author: {
name: 'author name',
username: 'author username',
status_tooltip_html: '<span class="status">status_tooltip_html</span>',
},
};
const createWrapper = options => {
wrapper = shallowMount(HistoryEntry, {
propsData: {
discussion: {
notes: [{ ...note, ...options }],
},
},
});
};
const icon = () => wrapper.find(Icon);
const authorName = () => wrapper.find({ ref: 'authorName' });
const authorUsername = () => wrapper.find({ ref: 'authorUsername' });
const authorStatus = () => wrapper.find({ ref: 'authorStatus' });
const stateChangeMessage = () => wrapper.find({ ref: 'stateChangeMessage' });
const timeAgoTooltip = () => wrapper.find(TimeAgoTooltip);
afterEach(() => wrapper.destroy());
describe('default wrapper tests', () => {
beforeEach(() => createWrapper());
it('shows the correct icon', () => {
expect(icon().exists()).toBe(true);
expect(icon().attributes('name')).toBe(note.system_note_icon_name);
});
it('shows the correct user', () => {
expect(authorName().text()).toBe(note.author.name);
expect(authorUsername().text()).toBe(`@${note.author.username}`);
});
it('shows the correct status if the user has a status set', () => {
expect(authorStatus().exists()).toBe(true);
expect(authorStatus().element.innerHTML).toBe(note.author.status_tooltip_html);
});
it('shows the state change message', () => {
expect(stateChangeMessage().text()).toBe(note.note);
});
it('shows the time ago tooltip', () => {
expect(timeAgoTooltip().exists()).toBe(true);
expect(timeAgoTooltip().attributes('time')).toBe(note.created_at);
});
});
describe('custom wrapper tests', () => {
it('does not show the user status if user has no status set', () => {
createWrapper({ author: { status_tooltip_html: undefined } });
expect(authorStatus().exists()).toBe(false);
});
it('does not render anything if there is no system note', () => {
createWrapper({ system: false });
expect(wrapper.html()).toBeFalsy();
});
});
});
......@@ -23023,6 +23023,9 @@ msgstr ""
msgid "VulnerabilityManagement|Resolved %{timeago} by %{user}"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not create an issue."
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