Commit f97400aa authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '228743-use-graphql-to-save-a-history-note' into 'master'

Save a comment through graphql

See merge request gitlab-org/gitlab!68995
parents fea18c21 5fdb14b1
...@@ -14,3 +14,4 @@ export const TYPE_SITE_PROFILE = 'DastSiteProfile'; ...@@ -14,3 +14,4 @@ export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User'; export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability'; export const TYPE_VULNERABILITY = 'Vulnerability';
export const TYPE_NOTE = 'Note'; export const TYPE_NOTE = 'Note';
export const TYPE_DISCUSSION = 'Discussion';
fragment SecurityDashboardNote on Note {
id
system
body
bodyHtml
updatedAt
systemNoteIconName
userPermissions {
adminNote
}
author {
id
name
webPath
username
}
}
#import "../fragments/note.fragment.graphql"
mutation securityDashboardCreateNote(
$noteableId: NoteableID!
$discussionId: DiscussionID
$confidential: Boolean
$body: String!
) {
createNote(
input: {
noteableId: $noteableId
body: $body
confidential: $confidential
discussionId: $discussionId
}
) {
errors
note {
...SecurityDashboardNote
}
}
}
mutation($id: ID!) { mutation securityDashboardDestroyNote($id: ID!) {
destroyNote(input: { id: $id }) { destroyNote(input: { id: $id }) {
errors errors
note { note {
......
#import "../fragments/note.fragment.graphql"
mutation securityDashboardUpdateNote($id: NoteID!, $confidential: Boolean, $body: String!) {
updateNote(input: { id: $id, body: $body, confidential: $confidential }) {
errors
note {
...SecurityDashboardNote
}
}
}
...@@ -71,7 +71,7 @@ export default { ...@@ -71,7 +71,7 @@ export default {
this.createNotesPoll(); this.createNotesPoll();
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
this.poll.makeRequest(); this.fetchDiscussions();
} }
Visibility.change(() => { Visibility.change(() => {
...@@ -148,6 +148,9 @@ export default { ...@@ -148,6 +148,9 @@ export default {
} }
}, },
methods: { methods: {
fetchDiscussions() {
return this.poll.makeRequest();
},
findDiscussion(id) { findDiscussion(id) {
return this.discussions.find((d) => d.id === id); return this.discussions.find((d) => d.id === id);
}, },
...@@ -253,7 +256,6 @@ export default { ...@@ -253,7 +256,6 @@ export default {
v-for="discussion in discussions" v-for="discussion in discussions"
:key="discussion.id" :key="discussion.id"
:discussion="discussion" :discussion="discussion"
:notes-url="vulnerability.notesUrl"
/> />
</ul> </ul>
</div> </div>
......
<script> <script>
import { GlButton, GlSafeHtmlDirective as SafeHtml, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlSafeHtmlDirective as SafeHtml, GlLoadingIcon } from '@gitlab/ui';
import deleteNoteMutation from 'ee/security_dashboard/graphql/mutations/note_delete.mutation.graphql'; import createNoteMutation from 'ee/security_dashboard/graphql/mutations/note_create.mutation.graphql';
import destroyNoteMutation from 'ee/security_dashboard/graphql/mutations/note_destroy.mutation.graphql';
import updateNoteMutation from 'ee/security_dashboard/graphql/mutations/note_update.mutation.graphql';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'; import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { TYPE_NOTE } from '~/graphql_shared/constants'; import { TYPE_NOTE, TYPE_DISCUSSION, TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { normalizeGraphQLNote } from '../helpers';
import HistoryCommentEditor from './history_comment_editor.vue'; import HistoryCommentEditor from './history_comment_editor.vue';
export default { export default {
...@@ -21,6 +23,8 @@ export default { ...@@ -21,6 +23,8 @@ export default {
SafeHtml, SafeHtml,
}, },
inject: ['vulnerabilityId'],
props: { props: {
comment: { comment: {
type: Object, type: Object,
...@@ -32,10 +36,6 @@ export default { ...@@ -32,10 +36,6 @@ export default {
required: false, required: false,
default: undefined, default: undefined,
}, },
notesUrl: {
type: String,
required: true,
},
}, },
data() { data() {
...@@ -63,7 +63,7 @@ export default { ...@@ -63,7 +63,7 @@ export default {
]; ];
}, },
initialComment() { initialComment() {
return this.comment && this.comment.note; return this.comment?.note;
}, },
canEditComment() { canEditComment() {
return this.comment.currentUser?.canEdit; return this.comment.currentUser?.canEdit;
...@@ -85,51 +85,79 @@ export default { ...@@ -85,51 +85,79 @@ export default {
showCommentInput() { showCommentInput() {
this.isEditingComment = true; this.isEditingComment = true;
}, },
getSaveConfig(note) { async insertComment(body) {
const isUpdatingComment = Boolean(this.comment); const { data } = await this.$apollo.mutate({
const method = isUpdatingComment ? 'put' : 'post'; mutation: createNoteMutation,
const url = isUpdatingComment ? this.comment.path : this.notesUrl; variables: {
const data = { note: { note } }; noteableId: convertToGraphQLId(TYPE_VULNERABILITY, this.vulnerabilityId),
const emitName = isUpdatingComment ? 'onCommentUpdated' : 'onCommentAdded'; discussionId: convertToGraphQLId(TYPE_DISCUSSION, this.discussionId),
body,
// If we're saving a new comment, use the discussion ID in the request data. },
if (!isUpdatingComment) { });
data.in_reply_to_discussion_id = this.discussionId;
const { note, errors } = data.createNote;
if (errors?.length > 0) {
throw errors;
} }
return { method, url, data, emitName }; this.$emit('onCommentAdded', normalizeGraphQLNote(note));
}, },
saveComment(note) { async updateComment(body) {
const { data } = await this.$apollo.mutate({
mutation: updateNoteMutation,
variables: {
id: convertToGraphQLId(TYPE_NOTE, this.comment.id),
body,
},
});
const { note, errors } = data.updateNote;
if (errors?.length > 0) {
throw errors;
}
this.cancelEditingComment();
this.$emit('onCommentUpdated', normalizeGraphQLNote(note));
},
async saveComment(body) {
this.isSavingComment = true; this.isSavingComment = true;
const { method, url, data, emitName } = this.getSaveConfig(note); const isUpdatingComment = Boolean(this.comment);
// note: this direct API call will be replaced when migrating the vulnerability details page to GraphQL try {
// related epic: https://gitlab.com/groups/gitlab-org/-/epics/3657 if (isUpdatingComment) {
axios({ method, url, data }) await this.updateComment(body);
.then(({ data: responseData }) => { } else {
this.isEditingComment = false; await this.insertComment(body);
this.$emit(emitName, { response: responseData, comment: this.comment }); }
}) } catch {
.catch(() => { createFlash({
createFlash({ message: s__(
message: s__( 'VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later.',
'VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later.', ),
),
});
}); });
}
this.isSavingComment = false;
}, },
async deleteComment() { async deleteComment() {
this.isDeletingComment = true; this.isDeletingComment = true;
try { try {
await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: deleteNoteMutation, mutation: destroyNoteMutation,
variables: { variables: {
id: convertToGraphQLId(TYPE_NOTE, this.comment.id), id: convertToGraphQLId(TYPE_NOTE, this.comment.id),
}, },
}); });
if (data.errors?.length > 0) {
throw data.errors;
}
this.$emit('onCommentDeleted', this.comment); this.$emit('onCommentDeleted', this.comment);
} catch (e) { } catch {
createFlash({ createFlash({
message: s__( message: s__(
'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.', 'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.',
......
<script> <script>
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'; import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import HistoryComment from './history_comment.vue'; import HistoryComment from './history_comment.vue';
export default { export default {
...@@ -10,10 +9,6 @@ export default { ...@@ -10,10 +9,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
notesUrl: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -34,14 +29,14 @@ export default { ...@@ -34,14 +29,14 @@ export default {
}, },
}, },
methods: { methods: {
addComment({ response }) { addComment(note) {
this.notes.push(convertObjectPropsToCamelCase(response)); this.notes.push(note);
}, },
updateComment({ response, comment }) { updateComment(note) {
const index = this.notes.indexOf(comment); const index = this.notes.findIndex((n) => Number(n.id) === note.id);
if (index > -1) { if (index > -1) {
this.notes.splice(index, 1, { ...comment, ...convertObjectPropsToCamelCase(response) }); this.notes.splice(index, 1, note);
} }
}, },
removeComment(comment) { removeComment(comment) {
...@@ -76,7 +71,6 @@ export default { ...@@ -76,7 +71,6 @@ export default {
ref="existingComment" ref="existingComment"
:comment="comment" :comment="comment"
:discussion-id="discussion.replyId" :discussion-id="discussion.replyId"
:notes-url="notesUrl"
@onCommentUpdated="updateComment" @onCommentUpdated="updateComment"
@onCommentDeleted="removeComment" @onCommentDeleted="removeComment"
/> />
...@@ -86,7 +80,6 @@ export default { ...@@ -86,7 +80,6 @@ export default {
v-else v-else
ref="newComment" ref="newComment"
:discussion-id="discussion.replyId" :discussion-id="discussion.replyId"
:notes-url="notesUrl"
@onCommentAdded="addComment" @onCommentAdded="addComment"
/> />
</li> </li>
......
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isAbsolute, isSafeURL } from '~/lib/utils/url_utility'; import { isAbsolute, isSafeURL } from '~/lib/utils/url_utility';
import { REGEXES, gidPrefix, uidPrefix } from './constants'; import { REGEXES, gidPrefix, uidPrefix } from './constants';
...@@ -28,6 +29,27 @@ export const getAddRelatedIssueRequestParams = (reference, defaultProjectId) => ...@@ -28,6 +29,27 @@ export const getAddRelatedIssueRequestParams = (reference, defaultProjectId) =>
return { target_issue_iid: issueId, target_project_id: projectId }; return { target_issue_iid: issueId, target_project_id: projectId };
}; };
export const normalizeGraphQLNote = (note) => {
if (!note) {
return null;
}
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),
path: note.author.webPath,
},
};
};
export const normalizeGraphQLVulnerability = (vulnerability) => { export const normalizeGraphQLVulnerability = (vulnerability) => {
if (!vulnerability) { if (!vulnerability) {
return null; return null;
......
...@@ -141,12 +141,10 @@ describe('Vulnerability Footer', () => { ...@@ -141,12 +141,10 @@ describe('Vulnerability Footer', () => {
expect(findDiscussions().at(0).props()).toEqual({ expect(findDiscussions().at(0).props()).toEqual({
discussion: { ...discussion1, notes: [convertObjectPropsToCamelCase(notes[0])] }, discussion: { ...discussion1, notes: [convertObjectPropsToCamelCase(notes[0])] },
notesUrl: vulnerability.notesUrl,
}); });
expect(findDiscussions().at(1).props()).toEqual({ expect(findDiscussions().at(1).props()).toEqual({
discussion: { ...discussion2, notes: [convertObjectPropsToCamelCase(notes[1])] }, discussion: { ...discussion2, notes: [convertObjectPropsToCamelCase(notes[1])] },
notesUrl: vulnerability.notesUrl,
}); });
}); });
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import deleteNoteMutation from 'ee/security_dashboard/graphql/mutations/note_delete.mutation.graphql'; import createNoteMutation from 'ee/security_dashboard/graphql/mutations/note_create.mutation.graphql';
import destroyNoteMutation from 'ee/security_dashboard/graphql/mutations/note_destroy.mutation.graphql';
import updateNoteMutation from 'ee/security_dashboard/graphql/mutations/note_update.mutation.graphql';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'; import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import HistoryComment from 'ee/vulnerabilities/components/history_comment.vue'; import HistoryComment from 'ee/vulnerabilities/components/history_comment.vue';
import HistoryCommentEditor from 'ee/vulnerabilities/components/history_comment_editor.vue'; import HistoryCommentEditor from 'ee/vulnerabilities/components/history_comment_editor.vue';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import { TYPE_DISCUSSION, TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash'); jest.mock('~/flash');
Vue.use(VueApollo); Vue.use(VueApollo);
const CREATE_NOTE = 'createNote';
const UPDATE_NOTE = 'updateNote';
const DESTROY_NOTE = 'destroyNote';
const TEST_VULNERABILITY_ID = '15';
const TEST_DISCUSSION_ID = '24';
const TEST_VULNERABILITY_GID = convertToGraphQLId(TYPE_VULNERABILITY, TEST_VULNERABILITY_ID);
const TEST_DISCUSSION_GID = convertToGraphQLId(TYPE_DISCUSSION, TEST_DISCUSSION_ID);
describe('History Comment', () => { describe('History Comment', () => {
let wrapper; let wrapper;
let createNoteMutationSpy;
let updateNoteMutationSpy;
let destroyNoteMutationSpy;
const createMutationResponse = ({ note = {}, queryName, errors = [] }) => ({
data: {
[queryName]: {
errors,
note,
},
},
});
const createApolloProvider = (...queries) => { const createWrapper = ({ propsData } = {}) => {
return createMockApollo([...queries]); const apolloProvider = createMockApollo([
}; [createNoteMutation, createNoteMutationSpy],
[updateNoteMutation, updateNoteMutationSpy],
[destroyNoteMutation, destroyNoteMutationSpy],
]);
const createWrapper = ({ comment, apolloProvider } = {}) => {
wrapper = mount(HistoryComment, { wrapper = mount(HistoryComment, {
apolloProvider, apolloProvider,
provide: {
vulnerabilityId: TEST_VULNERABILITY_ID,
},
propsData: { propsData: {
comment, discussionId: TEST_DISCUSSION_ID,
notesUrl: '/notes', ...propsData,
}, },
}); });
}; };
const comment = { const note = {
id: 'id', id: 'gid://gitlab/DiscussionNote/1295',
note: 'note', body: 'Created a note.',
noteHtml: '<p>note</p>', bodyHtml: '\u003cp\u003eCreated a note\u003c/p\u003e',
author: {}, updatedAt: '2021-08-25T16:21:18Z',
updatedAt: new Date().toISOString(), system: false,
currentUser: { systemNoteIconName: null,
canEdit: true, 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 };
beforeEach(() => {
createNoteMutationSpy = jest
.fn()
.mockResolvedValue(createMutationResponse({ queryName: CREATE_NOTE, note }));
destroyNoteMutationSpy = jest
.fn()
.mockResolvedValue(createMutationResponse({ queryName: DESTROY_NOTE, note: null }));
updateNoteMutationSpy = jest
.fn()
.mockResolvedValue(createMutationResponse({ queryName: UPDATE_NOTE, note }));
});
const addCommentButton = () => wrapper.find({ ref: 'addCommentButton' }); const addCommentButton = () => wrapper.find({ ref: 'addCommentButton' });
const commentEditor = () => wrapper.find(HistoryCommentEditor); const commentEditor = () => wrapper.find(HistoryCommentEditor);
const eventItem = () => wrapper.find(EventItem); const eventItem = () => wrapper.find(EventItem);
...@@ -83,9 +134,13 @@ describe('History Comment', () => { ...@@ -83,9 +134,13 @@ describe('History Comment', () => {
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}; };
const editAndSaveNewContent = async (content) => {
await showEditView();
commentEditor().vm.$emit('onSave', content);
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
mockAxios.reset();
createFlash.mockReset(); createFlash.mockReset();
}); });
...@@ -111,57 +166,22 @@ describe('History Comment', () => { ...@@ -111,57 +166,22 @@ describe('History Comment', () => {
}) })
.then(expectAddCommentView); .then(expectAddCommentView);
}); });
it('saves the comment when the save button is clicked on the comment editor', () => {
mockAxios.onPost().replyOnce(200, comment);
return showEditView()
.then(() => {
commentEditor().vm.$emit('onSave', 'new comment');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(commentEditor().props('isSaving')).toBe(true);
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(wrapper.emitted().onCommentAdded[0]).toEqual([
{ response: comment, comment: undefined },
]);
});
});
it('shows an error message and continues showing the comment editor when the comment cannot be saved', () => {
mockAxios.onPost().replyOnce(500);
return showEditView()
.then(() => {
commentEditor().vm.$emit('onSave', 'new comment');
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(commentEditor().exists()).toBe(true);
});
});
}); });
describe(`when there's an existing comment`, () => { describe(`when there's an existing comment`, () => {
beforeEach(() => createWrapper({ comment })); beforeEach(() => createWrapper({ propsData: { comment: note, discussionId: '24' } }));
it('shows the comment with the correct user author and timestamp and the edit/delete buttons', () => { it('shows the comment with the correct user author and timestamp and the edit/delete buttons', () => {
expectExistingCommentView(); expectExistingCommentView();
expect(eventItem().props('author')).toBe(comment.author); expect(eventItem().props('author')).toBe(note.author);
expect(eventItem().props('createdAt')).toBe(comment.updatedAt); expect(eventItem().props('createdAt')).toBe(note.updatedAt);
expect(eventItem().element.innerHTML).toContain(comment.noteHtml); expect(eventItem().element.innerHTML).toContain(note.bodyHtml);
}); });
it('shows the comment editor when the edit button is clicked', () => { it('shows the comment editor when the edit button is clicked', () => {
return showEditView().then(() => { return showEditView().then(() => {
expectEditCommentView(); expectEditCommentView();
expect(commentEditor().props('initialComment')).toBe(comment.note); expect(commentEditor().props('initialComment')).toBe(note.body);
}); });
}); });
...@@ -173,7 +193,7 @@ describe('History Comment', () => { ...@@ -173,7 +193,7 @@ describe('History Comment', () => {
}) })
.then(() => { .then(() => {
expectExistingCommentView(); expectExistingCommentView();
expect(eventItem().element.innerHTML).toContain(comment.noteHtml); expect(eventItem().element.innerHTML).toContain(note.bodyHtml);
}); });
}); });
...@@ -194,61 +214,111 @@ describe('History Comment', () => { ...@@ -194,61 +214,111 @@ describe('History Comment', () => {
}) })
.then(() => { .then(() => {
expectExistingCommentView(); expectExistingCommentView();
expect(eventItem().element.innerHTML).toContain(comment.noteHtml); expect(eventItem().element.innerHTML).toContain(note.bodyHtml);
}); });
}); });
});
it('saves the comment when the save button is clicked on the comment editor', () => { const EXPECTED_CREATE_VARS = {
const responseData = { ...comment, note: 'new comment' }; discussionId: TEST_DISCUSSION_GID,
mockAxios.onPut().replyOnce(200, responseData); noteableId: TEST_VULNERABILITY_GID,
};
const EXPECTED_UPDATE_VARS = {
id: note.id,
};
return showEditView() describe.each`
.then(() => { desc | propsData | expectedEvent | expectedVars | mutationSpyFn | queryName
commentEditor().vm.$emit('onSave', responseData.note); ${'inserting a new note'} | ${{}} | ${'onCommentAdded'} | ${EXPECTED_CREATE_VARS} | ${() => createNoteMutationSpy} | ${CREATE_NOTE}
return wrapper.vm.$nextTick(); ${'updating an existing note'} | ${{ comment: note }} | ${'onCommentUpdated'} | ${EXPECTED_UPDATE_VARS} | ${() => updateNoteMutationSpy} | ${UPDATE_NOTE}
}) `('$desc', ({ propsData, expectedEvent, expectedVars, mutationSpyFn, queryName }) => {
.then(() => { let mutationSpy;
expect(commentEditor().props('isSaving')).toBe(true);
return axios.waitForAll(); beforeEach(() => {
}) mutationSpy = mutationSpyFn();
.then(() => { });
expect(mockAxios.history.put).toHaveLength(1);
expect(wrapper.emitted().onCommentUpdated[0]).toEqual([ it('sends graphql mutation', async () => {
{ response: responseData, comment }, createWrapper({ propsData });
]);
await editAndSaveNewContent('new comment');
expect(mutationSpy).toHaveBeenCalledWith({
...expectedVars,
body: 'new comment',
});
});
it('shows loading', async () => {
createWrapper({ propsData });
await editAndSaveNewContent('new comment');
await wrapper.vm.$nextTick();
expect(commentEditor().props('isSaving')).toBe(true);
});
it('emits event when mutation is successful', async () => {
createWrapper({ propsData });
await editAndSaveNewContent('new comment');
await waitForPromises();
expect(wrapper.emitted(expectedEvent)).toEqual([
[
{
...note,
id: 1295,
author: {
...note.author,
id: 1,
path: note.author.webPath,
},
},
],
]);
});
describe('when mutation has data error', () => {
beforeEach(() => {
mutationSpy.mockResolvedValue({ queryName, errors: ['Some domain specific error'] });
createWrapper({ propsData });
});
it('shows flash', async () => {
await editAndSaveNewContent('new comment');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while trying to save the comment. Please try again later.',
}); });
});
}); });
it('shows an error message when the comment cannot be saved', () => { describe('when mutation has top-level error', () => {
mockAxios.onPut().replyOnce(500); beforeEach(() => {
mutationSpy.mockRejectedValue(new Error('Something top-level happened'));
return showEditView() createWrapper({ propsData });
.then(() => { });
commentEditor().vm.$emit('onSave', 'some comment');
return axios.waitForAll(); it('shows flash', async () => {
}) await editAndSaveNewContent('new comment');
.then(() => { await waitForPromises();
expect(mockAxios.history.put).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while trying to save the comment. Please try again later.',
}); });
expect(commentEditor().exists()).toBe(true);
});
}); });
}); });
describe('deleting a note', () => { 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', async () => {
createWrapper({ createWrapper({
comment, propsData: { comment: note },
apolloProvider: createApolloProvider([
deleteNoteMutation,
jest.fn().mockResolvedValue({
data: {
destroyNote: {
errors: [],
note: null,
},
},
}),
]),
}); });
deleteButton().trigger('click'); deleteButton().trigger('click');
...@@ -260,40 +330,62 @@ describe('History Comment', () => { ...@@ -260,40 +330,62 @@ describe('History Comment', () => {
expect(confirmDeleteButton().props('loading')).toBe(true); expect(confirmDeleteButton().props('loading')).toBe(true);
expect(cancelDeleteButton().props('disabled')).toBe(true); expect(cancelDeleteButton().props('disabled')).toBe(true);
await axios.waitForAll(); await waitForPromises();
expect(wrapper.emitted().onCommentDeleted).toBeTruthy(); expect(wrapper.emitted().onCommentDeleted).toBeTruthy();
expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(comment); expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(note);
}); });
it('shows an error message when the comment cannot be deleted', async () => { it('sends mutation to delete note', async () => {
createWrapper({ createWrapper({ propsData: { comment: note } });
comment,
apolloProvider: createApolloProvider([ deleteButton().trigger('click');
deleteNoteMutation, await wrapper.vm.$nextTick();
jest.fn().mockRejectedValue({
data: { confirmDeleteButton().trigger('click');
destroyNote: { expect(destroyNoteMutationSpy).toHaveBeenCalledWith({
errors: [{ message: 'Something went wrong' }], id: note.id,
note: null,
},
},
}),
]),
}); });
});
it('with data error, shows an error message', async () => {
destroyNoteMutationSpy.mockResolvedValue({ errors: ['Some domain specific error'] });
createWrapper({ propsData: { comment: note } });
deleteButton().trigger('click'); deleteButton().trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
confirmDeleteButton().trigger('click'); confirmDeleteButton().trigger('click');
await axios.waitForAll(); await waitForPromises();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while trying to delete the comment. Please try again later.',
});
});
it('with top-level error, shows an error message', async () => {
destroyNoteMutationSpy.mockRejectedValue(new Error('Some top-level error'));
createWrapper({ propsData: { comment: note } });
deleteButton().trigger('click');
await wrapper.vm.$nextTick();
confirmDeleteButton().trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while trying to delete the comment. Please try again later.',
});
}); });
}); });
describe('no permission to edit existing comment', () => { describe('no permission to edit existing comment', () => {
it('does not show the edit/delete buttons if the current user has no edit permissions', () => { it('does not show the edit/delete buttons if the current user has no edit permissions', () => {
createWrapper({ comment: { ...comment, currentUser: { canEdit: false } } }); createWrapper({
propsData: {
comment: { ...note, userPermissions: undefined, currentUser: { canEdit: false } },
},
});
expect(editButton().exists()).toBe(false); expect(editButton().exists()).toBe(false);
expect(deleteButton().exists()).toBe(false); expect(deleteButton().exists()).toBe(false);
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'; import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue'; import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
describe('History Entry', () => { describe('History Entry', () => {
let wrapper; let wrapper;
...@@ -84,9 +83,7 @@ describe('History Entry', () => { ...@@ -84,9 +83,7 @@ describe('History Entry', () => {
it('adds a new comment correctly', async () => { it('adds a new comment correctly', async () => {
createWrapper(systemNote); createWrapper(systemNote);
newComment().vm.$emit('onCommentAdded', { newComment().vm.$emit('onCommentAdded', commentNote);
response: convertObjectPropsToSnakeCase(commentNote),
});
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
...@@ -96,13 +93,13 @@ describe('History Entry', () => { ...@@ -96,13 +93,13 @@ describe('History Entry', () => {
}); });
it('updates an existing comment correctly', async () => { it('updates an existing comment correctly', async () => {
const response = { note: 'new note' }; const updatedNote = { ...commentNote, note: 'new note' };
createWrapper(systemNote, commentNote); createWrapper(systemNote, commentNote);
commentAt(0).vm.$emit('onCommentUpdated', { response, comment: commentNote }); commentAt(0).vm.$emit('onCommentUpdated', updatedNote);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(commentAt(0).props('comment')).toEqual({ ...commentNote, note: response.note }); expect(commentAt(0).props('comment')).toBe(updatedNote);
}); });
it('deletes an existing comment correctly', async () => { it('deletes an existing comment correctly', async () => {
......
...@@ -4,6 +4,7 @@ import Footer from 'ee/vulnerabilities/components/footer.vue'; ...@@ -4,6 +4,7 @@ import Footer from 'ee/vulnerabilities/components/footer.vue';
import Header from 'ee/vulnerabilities/components/header.vue'; import Header from 'ee/vulnerabilities/components/header.vue';
import Main from 'ee/vulnerabilities/components/vulnerability.vue'; import Main from 'ee/vulnerabilities/components/vulnerability.vue';
import Details from 'ee/vulnerabilities/components/vulnerability_details.vue'; import Details from 'ee/vulnerabilities/components/vulnerability_details.vue';
import { stubComponent } from 'helpers/stub_component';
const mockAxios = new AxiosMockAdapter(); const mockAxios = new AxiosMockAdapter();
...@@ -47,6 +48,10 @@ describe('Vulnerability', () => { ...@@ -47,6 +48,10 @@ describe('Vulnerability', () => {
propsData: { propsData: {
vulnerability, vulnerability,
}, },
stubs: {
VulnerabilityHeader: stubComponent(Header),
VulnerabilityFooter: stubComponent(Footer),
},
}); });
}; };
...@@ -77,28 +82,25 @@ describe('Vulnerability', () => { ...@@ -77,28 +82,25 @@ describe('Vulnerability', () => {
}); });
describe('vulnerability state change event', () => { describe('vulnerability state change event', () => {
let fetchDiscussions; let makeRequest;
let refreshVulnerability; let refreshVulnerability;
beforeEach(() => { beforeEach(() => {
fetchDiscussions = jest.fn(); refreshVulnerability = jest.spyOn(findHeader().vm, 'refreshVulnerability');
refreshVulnerability = jest.fn(); makeRequest = jest.spyOn(findFooter().vm, 'fetchDiscussions');
findHeader().vm.refreshVulnerability = refreshVulnerability;
findFooter().vm.fetchDiscussions = fetchDiscussions;
}); });
it('updates the footer notes when the vulnerbility state was changed', () => { it('updates the footer notes when the vulnerbility state was changed', () => {
findHeader().vm.$emit('vulnerability-state-change'); findHeader().vm.$emit('vulnerability-state-change');
expect(fetchDiscussions).toHaveBeenCalledTimes(1); expect(makeRequest).toHaveBeenCalledTimes(1);
expect(refreshVulnerability).not.toHaveBeenCalled(); expect(refreshVulnerability).not.toHaveBeenCalled();
}); });
it('updates the header when the footer received a state-change note', () => { it('updates the header when the footer received a state-change note', () => {
findFooter().vm.$emit('vulnerability-state-change'); findFooter().vm.$emit('vulnerability-state-change');
expect(fetchDiscussions).not.toHaveBeenCalled(); expect(makeRequest).not.toHaveBeenCalled();
expect(refreshVulnerability).toHaveBeenCalledTimes(1); expect(refreshVulnerability).toHaveBeenCalledTimes(1);
}); });
}); });
......
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