Commit ef947c73 authored by Savas Vedova's avatar Savas Vedova

Merge branch '228742-use-graphql-to-fetch-notes' into 'master'

Fetch notes along discussions using GraphQL

See merge request gitlab-org/gitlab!69353
parents 1526d0da c67db405
#import "../fragments/note.fragment.graphql"
query vulnerabilityDiscussions(
$id: VulnerabilityID!
$after: String
......@@ -11,6 +13,11 @@ query vulnerabilityDiscussions(
nodes {
id
replyId
notes {
nodes {
...SecurityDashboardNote
}
}
}
}
}
......
......@@ -9,18 +9,18 @@ import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import createFlash from '~/flash';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import { s__, __ } from '~/locale';
import { s__ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { normalizeGraphQLNote } from '../helpers';
import GenericReportSection from './generic_report/report_section.vue';
import HistoryEntry from './history_entry.vue';
import RelatedIssues from './related_issues.vue';
import RelatedJiraIssues from './related_jira_issues.vue';
import StatusDescription from './status_description.vue';
const TEN_SECONDS = 10000;
export default {
name: 'VulnerabilityFooter',
components: {
......@@ -48,9 +48,9 @@ export default {
},
data() {
return {
notesLoading: true,
discussionsLoading: true,
discussions: [],
lastFetchedAt: null,
lastFetchedDiscussionIndex: -1,
};
},
apollo: {
......@@ -60,49 +60,24 @@ export default {
return { id: convertToGraphQLId(TYPE_VULNERABILITY, this.vulnerability.id) };
},
update: ({ vulnerability }) => {
if (!vulnerability) {
return [];
}
return vulnerability.discussions.nodes.map((d) => ({ ...d, notes: [] }));
return (
vulnerability?.discussions?.nodes.map((discussion) => ({
...discussion,
notes: discussion.notes.nodes.map(normalizeGraphQLNote),
})) || []
);
},
result({ error }) {
if (!this.poll && !error) {
this.createNotesPoll();
if (!Visibility.hidden()) {
this.fetchDiscussions();
}
Visibility.change(() => {
if (Visibility.hidden()) {
this.poll.stop();
} else {
this.poll.restart();
}
});
}
result() {
this.discussionsLoading = false;
this.notifyHeaderForStateChangeIfRequired();
this.startPolling();
},
error() {
this.notesLoading = false;
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
),
});
this.showGraphQLError();
},
},
},
computed: {
noteDictionary() {
return this.discussions
.flatMap((x) => x.notes)
.reduce((acc, note) => {
acc[note.id] = note;
return acc;
}, {});
},
project() {
return {
url: this.vulnerability.project.fullPath,
......@@ -137,78 +112,73 @@ export default {
};
},
},
beforeDestroy() {
this.stopPolling();
},
updated() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
beforeDestroy() {
if (this.poll) {
this.poll.stop();
}
},
methods: {
fetchDiscussions() {
return this.poll.makeRequest();
},
findDiscussion(id) {
return this.discussions.find((d) => d.id === id);
},
createNotesPoll() {
// note: this polling call will be replaced when migrating the vulnerability details page to GraphQL
// related epic: https://gitlab.com/groups/gitlab-org/-/epics/3657
this.poll = new Poll({
resource: {
fetchNotes: () =>
axios.get(this.vulnerability.notesUrl, {
headers: { 'X-Last-Fetched-At': this.lastFetchedAt },
}),
startPolling() {
if (this.pollInterval) {
return;
}
if (!Visibility.hidden()) {
this.pollInterval = setInterval(this.fetchDiscussions, TEN_SECONDS);
}
this.visibilityListener = Visibility.change(() => {
if (Visibility.hidden()) {
this.stopPolling();
} else {
this.startPolling();
}
});
},
method: 'fetchNotes',
successCallback: ({ data: { notes, last_fetched_at: lastFetchedAt } }) => {
this.updateNotes(convertObjectPropsToCamelCase(notes, { deep: true }));
this.lastFetchedAt = lastFetchedAt;
this.notesLoading = false;
stopPolling() {
if (typeof this.pollInterval !== 'undefined') {
clearInterval(this.pollInterval);
this.pollInterval = undefined;
}
if (typeof this.visibilityListener !== 'undefined') {
Visibility.unbind(this.visibilityListener);
this.visibilityListener = undefined;
}
},
errorCallback: () => {
this.notesLoading = false;
showGraphQLError() {
createFlash({
message: __('Something went wrong while fetching latest comments.'),
});
},
message: s__(
'VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
),
});
},
updateNotes(notes) {
let shallEmitVulnerabilityChangedEvent;
notifyHeaderForStateChangeIfRequired() {
const lastItemIndex = this.discussions.length - 1;
notes.forEach((note) => {
const discussion = this.findDiscussion(note.discussionId);
// If the note exists, update it.
if (this.noteDictionary[note.id]) {
discussion.notes = discussion.notes.map((curr) => (curr.id === note.id ? note : curr));
}
// If the note doesn't exist, but the discussion does, add the note to the discussion.
else if (discussion) {
discussion.notes.push(note);
if (this.lastFetchedDiscussionIndex === lastItemIndex) {
return;
}
// If the discussion doesn't exist, create it.
else {
this.discussions.push({
id: note.discussionId,
replyId: note.discussionId,
notes: [note],
});
// If the vulnerability status has changed, the note will be a system note.
// Emit an event that tells the header to refresh the vulnerability.
if (note.system === true) {
shallEmitVulnerabilityChangedEvent = true;
}
// Do not notify on page load, or first mount.
if (this.lastFetchedDiscussionIndex !== -1) {
this.$emit('vulnerability-state-change');
}
});
if (shallEmitVulnerabilityChangedEvent) {
this.$emit('vulnerability-state-change');
this.lastFetchedDiscussionIndex = lastItemIndex;
},
async fetchDiscussions(callback) {
try {
await this.$apollo.queries.discussions.refetch();
if (typeof callback === 'function') {
callback();
}
} catch {
this.showGraphQLError();
}
},
},
......@@ -250,13 +220,14 @@ export default {
</div>
</div>
<hr />
<gl-loading-icon v-if="notesLoading" />
<ul v-else-if="discussions.length" class="notes discussion-body">
<gl-loading-icon v-if="discussionsLoading" />
<div v-else-if="discussions.length" class="notes discussion-body">
<history-entry
v-for="discussion in discussions"
:key="discussion.id"
:discussion="discussion"
@onCommentUpdated="fetchDiscussions"
/>
</ul>
</div>
</div>
</template>
......@@ -8,7 +8,6 @@ import createFlash from '~/flash';
import { TYPE_NOTE, TYPE_DISCUSSION, TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import { normalizeGraphQLNote } from '../helpers';
import HistoryCommentEditor from './history_comment_editor.vue';
export default {
......@@ -63,21 +62,13 @@ export default {
];
},
initialComment() {
return this.comment?.note;
return this.comment?.body;
},
canEditComment() {
return this.comment.currentUser?.canEdit;
return this.comment.userPermissions?.adminNote;
},
noteHtml() {
return this.isSavingComment ? undefined : this.comment.noteHtml;
},
},
watch: {
'comment.updatedAt': {
handler() {
this.isSavingComment = false;
},
return this.isSavingComment ? undefined : this.comment.bodyHtml;
},
},
......@@ -95,13 +86,11 @@ export default {
},
});
const { note, errors } = data.createNote;
const { errors } = data.createNote;
if (errors?.length > 0) {
throw errors;
}
this.$emit('onCommentAdded', normalizeGraphQLNote(note));
},
async updateComment(body) {
const { data } = await this.$apollo.mutate({
......@@ -112,14 +101,11 @@ export default {
},
});
const { note, errors } = data.updateNote;
const { errors } = data.updateNote;
if (errors?.length > 0) {
throw errors;
}
this.cancelEditingComment();
this.$emit('onCommentUpdated', normalizeGraphQLNote(note));
},
async saveComment(body) {
this.isSavingComment = true;
......@@ -131,15 +117,20 @@ export default {
} else {
await this.insertComment(body);
}
this.$emit('onCommentUpdated', () => {
this.isSavingComment = false;
this.cancelEditingComment();
});
} catch {
this.isSavingComment = false;
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later.',
),
});
}
this.isSavingComment = false;
},
async deleteComment() {
this.isDeletingComment = true;
......@@ -156,16 +147,18 @@ export default {
throw data.errors;
}
this.$emit('onCommentDeleted', this.comment);
this.$emit('onCommentUpdated', () => {
this.isDeletingComment = false;
});
} catch {
this.isDeletingComment = false;
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.',
),
});
}
this.isDeletingComment = false;
},
cancelEditingComment() {
this.isEditingComment = false;
......
......@@ -3,19 +3,20 @@ import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'
import HistoryComment from './history_comment.vue';
export default {
components: { EventItem, HistoryComment },
components: {
EventItem,
HistoryComment,
},
props: {
discussion: {
type: Object,
required: true,
},
},
data() {
return {
notes: this.discussion.notes,
};
},
computed: {
notes() {
return this.discussion.notes;
},
systemNote() {
return this.notes.find((x) => x.system === true);
},
......@@ -23,35 +24,11 @@ export default {
return this.notes.filter((x) => x !== this.systemNote);
},
},
watch: {
discussion(newDiscussion) {
this.notes = newDiscussion.notes;
},
},
methods: {
addComment(note) {
this.notes.push(note);
},
updateComment(note) {
const index = this.notes.findIndex((n) => Number(n.id) === note.id);
if (index > -1) {
this.notes.splice(index, 1, note);
}
},
removeComment(comment) {
const index = this.notes.indexOf(comment);
if (index > -1) {
this.notes.splice(index, 1);
}
},
},
};
</script>
<template>
<li v-if="systemNote" class="card border-bottom system-note p-0">
<div v-if="systemNote" class="card border-bottom system-note p-0">
<event-item
:id="systemNote.id"
:author="systemNote.author"
......@@ -60,27 +37,22 @@ export default {
icon-class="timeline-icon m-0"
class="m-3"
>
<template #header-message>{{ systemNote.note }}</template>
<template #header-message>{{ systemNote.body }}</template>
</event-item>
<template v-if="comments.length" ref="existingComments">
<hr class="m-3" />
<hr v-if="comments.length" class="gl-m-0" />
<history-comment
v-for="comment in comments"
:key="comment.id"
ref="existingComment"
:key="comment.id"
:comment="comment"
:discussion-id="discussion.replyId"
@onCommentUpdated="updateComment"
@onCommentDeleted="removeComment"
v-on="$listeners"
/>
</template>
<history-comment
v-else
v-if="!comments.length"
ref="newComment"
:discussion-id="discussion.replyId"
@onCommentAdded="addComment"
v-on="$listeners"
/>
</li>
</div>
</template>
......@@ -37,11 +37,6 @@ export const normalizeGraphQLNote = (note) => {
return {
...note,
id: getIdFromGraphQLId(note.id),
note: note.body,
noteHtml: note.bodyHtml,
currentUser: {
canEdit: note.userPermissions?.adminNote,
},
author: {
...note.author,
id: getIdFromGraphQLId(note.author.id),
......
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Api from 'ee/api';
......@@ -13,15 +13,14 @@ import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue';
import StatusDescription from 'ee/vulnerabilities/components/status_description.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { normalizeGraphQLNote } from 'ee/vulnerabilities/helpers';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import initUserPopovers from '~/user_popovers';
import { generateNote } from './mock_data';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
jest.mock('~/user_popovers');
......@@ -45,11 +44,16 @@ describe('Vulnerability Footer', () => {
let discussion1;
let discussion2;
let notes;
const discussionsSuccessHandler = (nodes) =>
jest.fn().mockResolvedValue({
const discussionsHandler = ({
discussions: nodes,
errors = [],
success = true,
handler = jest.fn(),
}) =>
handler[success ? 'mockResolvedValueOnce' : 'mockRejectedValueOnce']({
data: {
errors,
vulnerability: {
id: `gid://gitlab/Vulnerability/${vulnerability.id}`,
discussions: {
......@@ -59,34 +63,14 @@ describe('Vulnerability Footer', () => {
},
});
const discussionsErrorHandler = () =>
jest.fn().mockRejectedValue({
errors: [{ message: 'Something went wrong' }],
});
const createNotesRequest = (notesArray, statusCode = 200) => {
return mockAxios
.onGet(vulnerability.notesUrl)
.replyOnce(statusCode, { notes: notesArray }, { date: Date.now() });
};
const createWrapper = ({ properties, discussionsHandler, mountOptions } = {}) => {
createNotesRequest(notes);
const createWrapper = ({ properties, queryHandler, mountOptions } = {}) => {
wrapper = shallowMountExtended(VulnerabilityFooter, {
propsData: { vulnerability: { ...vulnerability, ...properties } },
apolloProvider: createMockApollo([[vulnerabilityDiscussionsQuery, discussionsHandler]]),
apolloProvider: createMockApollo([[vulnerabilityDiscussionsQuery, queryHandler]]),
...mountOptions,
});
};
const createWrapperWithDiscussions = (props) => {
createWrapper({
...props,
discussionsHandler: discussionsSuccessHandler([discussion1, discussion2]),
});
};
const findDiscussions = () => wrapper.findAllComponents(HistoryEntry);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findMergeRequestNote = () => wrapper.findComponent(MergeRequestNote);
......@@ -97,143 +81,133 @@ describe('Vulnerability Footer', () => {
discussion1 = {
id: 'gid://gitlab/Discussion/7b4aa2d000ec81ba374a29b3ca3ee4c5f274f9ab',
replyId: 'gid://gitlab/Discussion/7b4aa2d000ec81ba374a29b3ca3ee4c5f274f9ab',
notes: {
nodes: [generateNote({ id: 100 })],
},
};
discussion2 = {
id: 'gid://gitlab/Discussion/0656f86109dc755c99c288c54d154b9705aaa796',
replyId: 'gid://gitlab/Discussion/0656f86109dc755c99c288c54d154b9705aaa796',
notes: {
nodes: [generateNote({ id: 200 })],
},
};
notes = [
{ id: 100, note: 'some note', discussion_id: discussion1.id },
{ id: 200, note: 'another note', discussion_id: discussion2.id },
];
});
afterEach(() => {
wrapper.destroy();
mockAxios.reset();
});
describe('discussions and notes', () => {
const createWrapperAndFetchNotes = async () => {
createWrapperWithDiscussions();
await axios.waitForAll();
expect(findDiscussions()).toHaveLength(2);
expect(findDiscussions().at(0).props('discussion').notes).toHaveLength(1);
describe('discussions', () => {
const createWrapperAndFetchDiscussions = async ({ discussions, errors, success }) => {
createWrapper({ queryHandler: discussionsHandler({ discussions, errors, success }) });
await waitForPromises();
};
const makePollRequest = async () => {
wrapper.vm.poll.makeRequest();
await axios.waitForAll();
const refetchDiscussions = async () => {
findDiscussions().at(0).vm.$emit('onCommentUpdated');
await waitForPromises();
};
it('displays a loading spinner while fetching discussions', async () => {
createWrapperWithDiscussions();
createWrapper({ queryHandler: discussionsHandler({ discussions: [] }) });
expect(findDiscussions().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(true);
await axios.waitForAll();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
it('fetches discussions and notes on mount', async () => {
await createWrapperAndFetchNotes();
await createWrapperAndFetchDiscussions({ discussions: [discussion1, discussion2] });
expect(findDiscussions().at(0).props()).toEqual({
discussion: { ...discussion1, notes: [convertObjectPropsToCamelCase(notes[0])] },
expect(findDiscussions().at(0).props()).toStrictEqual({
discussion: {
...discussion1,
notes: [normalizeGraphQLNote(discussion1.notes.nodes[0])],
},
});
expect(findDiscussions().at(1).props()).toEqual({
discussion: { ...discussion2, notes: [convertObjectPropsToCamelCase(notes[1])] },
expect(findDiscussions().at(1).props()).toStrictEqual({
discussion: {
...discussion2,
notes: [normalizeGraphQLNote(discussion2.notes.nodes[0])],
},
});
});
it('calls initUserPopovers when the component is updated', async () => {
createWrapperWithDiscussions();
createWrapper({ queryHandler: discussionsHandler({ discussions: [] }) });
expect(initUserPopovers).not.toHaveBeenCalled();
await axios.waitForAll();
await waitForPromises();
expect(initUserPopovers).toHaveBeenCalled();
});
it('shows an error the discussions could not be retrieved', async () => {
createWrapper({ discussionsHandler: discussionsErrorHandler() });
await waitForPromises();
await createWrapperAndFetchDiscussions({
errors: [{ message: 'Something went wrong' }],
success: false,
});
expect(createFlash).toHaveBeenCalledWith({
message:
'Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
});
});
it('adds a new note to an existing discussion if the note does not exist', async () => {
await createWrapperAndFetchNotes();
// Fetch a new note
const note = { id: 101, note: 'new note', discussion_id: discussion1.id };
createNotesRequest([note]);
await makePollRequest();
expect(findDiscussions()).toHaveLength(2);
expect(findDiscussions().at(0).props('discussion').notes[1].note).toBe(note.note);
it('polls the server every 10 seconds for new discussions and attaches a listener to visibility-js', async () => {
const setIntervalSpy = jest.spyOn(window, 'setInterval');
const visibilityChangeSpy = jest.spyOn(Visibility, 'change');
createWrapper({ queryHandler: discussionsHandler({ discussions: [] }) });
expect(setIntervalSpy).not.toHaveBeenCalled();
expect(visibilityChangeSpy).not.toHaveBeenCalled();
await waitForPromises();
expect(setIntervalSpy).toHaveBeenCalledWith(wrapper.vm.fetchDiscussions, 10000);
expect(visibilityChangeSpy).toHaveBeenCalled();
});
it('updates an existing note if it already exists', async () => {
await createWrapperAndFetchNotes();
const note = { ...notes[0], note: 'updated note' };
createNotesRequest([note]);
await makePollRequest();
expect(findDiscussions()).toHaveLength(2);
expect(findDiscussions().at(0).props('discussion').notes).toHaveLength(1);
expect(findDiscussions().at(0).props('discussion').notes[0].note).toBe(note.note);
it('removes the poll and visibility listener when unmounted', async () => {
const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
const visibilityUnbindSpy = jest.spyOn(Visibility, 'unbind');
await createWrapperAndFetchDiscussions({ discussions: [] });
wrapper.destroy();
expect(clearIntervalSpy).toHaveBeenCalled();
expect(visibilityUnbindSpy).toHaveBeenCalled();
});
it('creates a new discussion with a new note if the discussion does not exist', async () => {
await createWrapperAndFetchNotes();
const note = {
id: 300,
note: 'new note on a new discussion',
discussion_id: 'new-discussion-id',
};
createNotesRequest([note]);
await makePollRequest();
it('emits the vulnerability-state-change event when the system note is new', async () => {
const eventName = 'vulnerability-state-change';
const queryHandler = discussionsHandler({ discussions: [discussion1] }); // first call
discussionsHandler({ discussions: [discussion1], handler: queryHandler }); // second call is the same
discussionsHandler({ discussions: [discussion1, discussion2], handler: queryHandler }); // for the third call we change the number of discussions
expect(findDiscussions()).toHaveLength(3);
expect(findDiscussions().at(2).props('discussion').notes).toHaveLength(1);
expect(findDiscussions().at(2).props('discussion').notes[0].note).toBe(note.note);
createWrapper({
queryHandler,
});
it('shows an error if the notes poll fails', async () => {
await createWrapperAndFetchNotes();
// Wait that the initial call has been made
await waitForPromises();
createNotesRequest([], 500);
await makePollRequest();
// At this stage the handler should not be called because it's the first call (so page load).
expect(wrapper.emitted(eventName)).toBeUndefined();
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while fetching latest comments.',
});
});
it('emits the vulnerability-state-change event when the system note is new', async () => {
await createWrapperAndFetchNotes();
// Fetch again with same data
await refetchDiscussions();
const handler = jest.fn();
wrapper.vm.$on('vulnerability-state-change', handler);
// There should be no new discussions so the event should be emitted once
expect(wrapper.emitted(eventName)).toBeUndefined();
const note = { system: true, id: 1, discussion_id: 'some-new-discussion-id' };
createNotesRequest([note]);
await makePollRequest();
// Fetch again with different data
await refetchDiscussions();
expect(handler).toHaveBeenCalledTimes(1);
// The event should be emitted once as there is a new discussion
expect(wrapper.emitted(eventName)).toHaveLength(1);
});
});
describe('solution card', () => {
it('does show solution card when there is one', () => {
const properties = { remediations: [{ diff: [{}] }], solution: 'some solution' };
createWrapper({ properties, discussionsHandler: discussionsSuccessHandler([]) });
createWrapper({ properties, discussionsHandler: discussionsHandler({ discussions: [] }) });
expect(wrapper.find(SolutionCard).exists()).toBe(true);
expect(wrapper.find(SolutionCard).props()).toEqual({
......
......@@ -12,6 +12,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { TYPE_DISCUSSION, TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { generateNote } from './mock_data';
jest.mock('~/flash');
Vue.use(VueApollo);
......@@ -59,28 +60,7 @@ describe('History Comment', () => {
});
};
const note = {
id: 'gid://gitlab/DiscussionNote/1295',
body: 'Created a note.',
bodyHtml: '\u003cp\u003eCreated a note\u003c/p\u003e',
updatedAt: '2021-08-25T16:21:18Z',
system: false,
systemNoteIconName: null,
userPermissions: {
adminNote: true,
},
author: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
webPath: '/root',
},
};
// Needed for now. Will be removed when fetching notes will be done through GraphQL.
note.note = note.body;
note.noteHtml = note.bodyHtml;
note.currentUser = { canEdit: note.userPermissions.adminNote };
const note = generateNote();
beforeEach(() => {
createNoteMutationSpy = jest
......@@ -95,8 +75,8 @@ describe('History Comment', () => {
});
const addCommentButton = () => wrapper.find({ ref: 'addCommentButton' });
const commentEditor = () => wrapper.find(HistoryCommentEditor);
const eventItem = () => wrapper.find(EventItem);
const commentEditor = () => wrapper.findComponent(HistoryCommentEditor);
const eventItem = () => wrapper.findComponent(EventItem);
const editButton = () => wrapper.find('[title="Edit Comment"]');
const deleteButton = () => wrapper.find('[title="Delete Comment"]');
const confirmDeleteButton = () => wrapper.find({ ref: 'confirmDeleteButton' });
......@@ -228,10 +208,10 @@ describe('History Comment', () => {
};
describe.each`
desc | propsData | expectedEvent | expectedVars | mutationSpyFn | queryName
${'inserting a new note'} | ${{}} | ${'onCommentAdded'} | ${EXPECTED_CREATE_VARS} | ${() => createNoteMutationSpy} | ${CREATE_NOTE}
${'updating an existing note'} | ${{ comment: note }} | ${'onCommentUpdated'} | ${EXPECTED_UPDATE_VARS} | ${() => updateNoteMutationSpy} | ${UPDATE_NOTE}
`('$desc', ({ propsData, expectedEvent, expectedVars, mutationSpyFn, queryName }) => {
desc | propsData | expectedVars | mutationSpyFn | queryName
${'inserting a new note'} | ${{}} | ${EXPECTED_CREATE_VARS} | ${() => createNoteMutationSpy} | ${CREATE_NOTE}
${'updating an existing note'} | ${{ comment: note }} | ${EXPECTED_UPDATE_VARS} | ${() => updateNoteMutationSpy} | ${UPDATE_NOTE}
`('$desc', ({ propsData, expectedVars, mutationSpyFn, queryName }) => {
let mutationSpy;
beforeEach(() => {
......@@ -258,25 +238,19 @@ describe('History Comment', () => {
expect(commentEditor().props('isSaving')).toBe(true);
});
it('emits event when mutation is successful', async () => {
it('emits event when mutation is successful with a callback function that resets the state', async () => {
createWrapper({ propsData });
const listener = jest.fn().mockImplementation((callback) => callback());
wrapper.vm.$on('onCommentUpdated', listener);
await editAndSaveNewContent('new comment');
expect(commentEditor().props('isSaving')).toBe(true);
await waitForPromises();
expect(wrapper.emitted(expectedEvent)).toEqual([
[
{
...note,
id: 1295,
author: {
...note.author,
id: 1,
path: note.author.webPath,
},
},
],
]);
expect(wrapper.emitted('onCommentUpdated')).toEqual([[expect.any(Function)]]);
expect(listener).toHaveBeenCalled();
expect(commentEditor().exists()).toBe(false);
});
describe('when mutation has data error', () => {
......@@ -316,7 +290,7 @@ describe('History Comment', () => {
});
describe('deleting a note', () => {
it('deletes the comment when the confirm delete button is clicked', async () => {
it('deletes the comment when the confirm delete button is clicked and submits an event to refect the discussions', async () => {
createWrapper({
propsData: { comment: note },
});
......@@ -331,8 +305,8 @@ describe('History Comment', () => {
expect(cancelDeleteButton().props('disabled')).toBe(true);
await waitForPromises();
expect(wrapper.emitted().onCommentDeleted).toBeTruthy();
expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(note);
expect(wrapper.emitted().onCommentUpdated).toBeTruthy();
expect(wrapper.emitted().onCommentUpdated[0][0]).toEqual(expect.any(Function));
});
it('sends mutation to delete note', async () => {
......@@ -383,7 +357,7 @@ describe('History Comment', () => {
it('does not show the edit/delete buttons if the current user has no edit permissions', () => {
createWrapper({
propsData: {
comment: { ...note, userPermissions: undefined, currentUser: { canEdit: false } },
comment: { ...note, userPermissions: { adminNote: false } },
},
});
......
......@@ -8,7 +8,7 @@ describe('History Entry', () => {
const systemNote = {
system: true,
id: 1,
note: 'changed vulnerability status to dismissed',
body: 'changed vulnerability status to dismissed',
systemNoteIconName: 'cancel',
updatedAt: new Date().toISOString(),
author: {
......@@ -20,11 +20,8 @@ describe('History Entry', () => {
const commentNote = {
id: 2,
note: 'some note',
body: 'some note',
author: {},
currentUser: {
canEdit: true,
},
};
const createWrapper = (...notes) => {
......@@ -33,7 +30,6 @@ describe('History Entry', () => {
wrapper = shallowMount(HistoryEntry, {
propsData: {
discussion,
notesUrl: '/notes',
},
stubs: { EventItem },
});
......@@ -49,7 +45,7 @@ describe('History Entry', () => {
it('passes the expected values to the event item component', () => {
createWrapper(systemNote);
expect(eventItem().text()).toContain(systemNote.note);
expect(eventItem().text()).toContain(systemNote.body);
expect(eventItem().props()).toMatchObject({
id: systemNote.id,
author: systemNote.author,
......@@ -80,33 +76,4 @@ describe('History Entry', () => {
expect(commentAt(0).props('comment')).toEqual(commentNote);
expect(commentAt(1).props('comment')).toEqual(commentNoteClone);
});
it('adds a new comment correctly', async () => {
createWrapper(systemNote);
newComment().vm.$emit('onCommentAdded', commentNote);
await wrapper.vm.$nextTick();
expect(newComment().exists()).toBe(false);
expect(existingComments()).toHaveLength(1);
expect(commentAt(0).props('comment')).toEqual(commentNote);
});
it('updates an existing comment correctly', async () => {
const updatedNote = { ...commentNote, note: 'new note' };
createWrapper(systemNote, commentNote);
commentAt(0).vm.$emit('onCommentUpdated', updatedNote);
await wrapper.vm.$nextTick();
expect(commentAt(0).props('comment')).toBe(updatedNote);
});
it('deletes an existing comment correctly', async () => {
createWrapper(systemNote, commentNote);
await commentAt(0).vm.$emit('onCommentDeleted', commentNote);
expect(newComment().exists()).toBe(true);
expect(existingComments()).toHaveLength(0);
});
});
export const generateNote = ({ id = 1295 } = {}) => ({
id: `gid://gitlab/DiscussionNote/${id}`,
body: 'Created a note.',
bodyHtml: '\u003cp\u003eCreated a note\u003c/p\u003e',
updatedAt: '2021-08-25T16:21:18Z',
system: false,
systemNoteIconName: null,
userPermissions: {
adminNote: true,
},
author: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
webPath: '/root',
},
});
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