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 { 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