Commit 968d2b9d authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '209993-add-commenting-ability' into 'master'

Add ability to add comments to vulnerability state history entries

See merge request gitlab-org/gitlab!29126
parents fcb67f6b 276333b5
...@@ -65,7 +65,7 @@ export default { ...@@ -65,7 +65,7 @@ export default {
</div> </div>
<hr /> <hr />
<ul v-if="discussions.length" ref="historyList" class="notes"> <ul v-if="discussions.length" ref="historyList" class="notes discussion-body">
<history-entry <history-entry
v-for="discussion in discussions" v-for="discussion in discussions"
:key="discussion.id" :key="discussion.id"
......
<script>
import { GlDeprecatedButton, GlNewButton, GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import { __, s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import HistoryCommentEditor from './history_comment_editor.vue';
export default {
components: {
GlDeprecatedButton,
GlNewButton,
EventItem,
HistoryCommentEditor,
GlLoadingIcon,
},
props: {
comment: {
type: Object,
required: false,
default: undefined,
},
discussionId: {
type: String,
required: false,
default: undefined,
},
},
data() {
return {
isEditingComment: false,
isSavingComment: false,
isDeletingComment: false,
isConfirmingDeletion: false,
};
},
computed: {
commentNote() {
return this.comment?.note;
},
actionButtons() {
return [
{
iconName: 'pencil',
onClick: this.showCommentInput,
title: __('Edit Comment'),
},
{
iconName: 'remove',
onClick: this.showDeleteConfirmation,
title: __('Delete Comment'),
},
];
},
},
methods: {
showCommentInput() {
this.isEditingComment = true;
},
getSaveConfig(note) {
const isUpdatingComment = Boolean(this.comment);
const method = isUpdatingComment ? 'put' : 'post';
let url = joinPaths(window.location.pathname, 'notes');
const data = { note: { note } };
const emitName = isUpdatingComment ? 'onCommentUpdated' : 'onCommentAdded';
// If we're updating the comment, use the comment ID in the URL. Otherwise, use the discussion ID in the request data.
if (isUpdatingComment) {
url = joinPaths(url, this.comment.id);
} else {
data.in_reply_to_discussion_id = this.discussionId;
}
return { method, url, data, emitName };
},
saveComment(note) {
this.isSavingComment = true;
const { method, url, data, emitName } = this.getSaveConfig(note);
axios({ method, url, data })
.then(({ data: responseData }) => {
this.isEditingComment = false;
this.$emit(emitName, responseData, this.comment);
})
.catch(() => {
createFlash(
s__(
'VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later.',
),
);
})
.finally(() => {
this.isSavingComment = false;
});
},
deleteComment() {
this.isDeletingComment = true;
const deleteUrl = joinPaths(window.location.pathname, 'notes', this.comment.id);
axios
.delete(deleteUrl)
.then(() => {
this.$emit('onCommentDeleted', this.comment);
})
.catch(() =>
createFlash(
s__(
'VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later.',
),
),
)
.finally(() => {
this.isDeletingComment = false;
});
},
cancelEditingComment() {
this.isEditingComment = false;
},
showDeleteConfirmation() {
this.isConfirmingDeletion = true;
},
cancelDeleteConfirmation() {
this.isConfirmingDeletion = false;
},
},
};
</script>
<template>
<history-comment-editor
v-if="isEditingComment"
class="discussion-reply-holder m-3"
:initial-comment="commentNote"
:is-saving="isSavingComment"
@onSave="saveComment"
@onCancel="cancelEditingComment"
/>
<event-item
v-else-if="comment"
:id="comment.id"
:author="comment.author"
:created-at="comment.updated_at"
:show-action-buttons="comment.current_user.can_edit"
:show-right-slot="isConfirmingDeletion"
:action-buttons="actionButtons"
icon-name="comment"
icon-class="timeline-icon m-0"
class="m-3"
>
<div v-html="comment.note"></div>
<template #right-content>
<gl-new-button
ref="confirmDeleteButton"
variant="danger"
:disabled="isDeletingComment"
@click="deleteComment"
>
<gl-loading-icon v-if="isDeletingComment" class="mr-1" />
{{ __('Delete') }}
</gl-new-button>
<gl-new-button
ref="cancelDeleteButton"
class="ml-2"
:disabled="isDeletingComment"
@click="cancelDeleteConfirmation"
>
{{ __('Cancel') }}
</gl-new-button>
</template>
</event-item>
<div v-else class="discussion-reply-holder">
<gl-deprecated-button ref="addCommentButton" class="btn-text-field" @click="showCommentInput">
{{ s__('vulnerability|Add a comment') }}
</gl-deprecated-button>
</div>
</template>
<script>
import { GlFormTextarea, GlNewButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlFormTextarea, GlNewButton, GlLoadingIcon },
props: {
initialComment: {
type: String,
required: false,
default: '',
},
isSaving: {
type: Boolean,
required: true,
},
},
data() {
return {
comment: this.initialComment.trim(),
};
},
computed: {
isSaveButtonDisabled() {
return this.isSaving || !this.trimmedComment.length;
},
trimmedComment() {
return this.comment.trim();
},
},
};
</script>
<template>
<div>
<gl-form-textarea
v-model="comment"
:placeholder="s__('vulnerability|Add a comment')"
:disabled="isSaving"
autofocus
/>
<div class="mt-3">
<gl-new-button
ref="saveButton"
variant="success"
:disabled="isSaveButtonDisabled"
@click="$emit('onSave', trimmedComment)"
>
<gl-loading-icon v-if="isSaving" class="mr-1" />
{{ __('Save comment') }}
</gl-new-button>
<gl-new-button
ref="cancelButton"
class="ml-1"
:disabled="isSaving"
@click="$emit('onCancel')"
>
{{ __('Cancel') }}
</gl-new-button>
</div>
</div>
</template>
<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 HistoryComment from './history_comment.vue';
export default { export default {
components: { EventItem }, components: { EventItem, HistoryComment },
props: { props: {
discussion: { discussion: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
data() {
return {
notes: this.discussion.notes,
};
},
computed: { computed: {
systemNote() { systemNote() {
return this.discussion.notes.find(x => x.system === true); return this.notes.find(x => x.system === true);
},
comments() {
return this.notes.filter(x => x !== this.systemNote);
},
},
methods: {
addComment(comment) {
this.notes.push(comment);
},
updateComment(data, comment) {
const index = this.notes.indexOf(comment);
if (index > -1) {
this.notes.splice(index, 1, Object.assign({}, comment, data));
}
},
removeComment(comment) {
const index = this.notes.indexOf(comment);
if (index > -1) {
this.notes.splice(index, 1);
}
}, },
}, },
}; };
...@@ -22,12 +50,32 @@ export default { ...@@ -22,12 +50,32 @@ export default {
<event-item <event-item
:id="systemNote.id" :id="systemNote.id"
:author="systemNote.author" :author="systemNote.author"
:created-at="systemNote.created_at" :created-at="systemNote.updated_at"
:icon-name="systemNote.system_note_icon_name" :icon-name="systemNote.system_note_icon_name"
icon-class="timeline-icon m-0" icon-class="timeline-icon m-0"
class="m-3" class="m-3"
> >
<template #header-message>{{ systemNote.note }}</template> <template #header-message>{{ systemNote.note }}</template>
</event-item> </event-item>
<template v-if="comments.length" ref="existingComments">
<hr class="m-3" />
<history-comment
v-for="comment in comments"
:key="comment.id"
ref="existingComment"
:comment="comment"
:discussion-id="discussion.reply_id"
@onCommentUpdated="updateComment"
@onCommentDeleted="removeComment"
/>
</template>
<history-comment
v-else
ref="newComment"
:discussion-id="discussion.reply_id"
@onCommentAdded="addComment"
/>
</li> </li>
</template> </template>
import { shallowMount } from '@vue/test-utils';
import { GlFormTextarea } from '@gitlab/ui';
import HistoryCommentEditor from 'ee/vulnerabilities/components/history_comment_editor.vue';
describe('History Comment Editor', () => {
let wrapper;
const createWrapper = props => {
wrapper = shallowMount(HistoryCommentEditor, {
propsData: { isSaving: false, ...props },
});
};
const textarea = () => wrapper.find(GlFormTextarea);
const saveButton = () => wrapper.find({ ref: 'saveButton' });
const cancelButton = () => wrapper.find({ ref: 'cancelButton' });
afterEach(() => wrapper.destroy());
it('shows the placeholder text when there is no comment', () => {
createWrapper();
expect(textarea().props('value')).toBeFalsy();
});
it('shows the comment when one is passed in', () => {
const initialComment = 'some comment';
createWrapper({ initialComment });
expect(textarea().props('value')).toBe(initialComment);
});
it('trims the comment when there are extra spaces', () => {
const initialComment = ' some comment ';
createWrapper({ initialComment });
expect(textarea().props('value')).toBe(initialComment.trim());
});
it('emits the save event with the new comment when the save button is clicked', () => {
createWrapper();
const comment = 'new comment';
textarea().vm.$emit('input', comment);
saveButton().vm.$emit('click');
expect(wrapper.emitted().onSave.length).toBe(1);
expect(wrapper.emitted().onSave[0][0]).toBe(comment);
});
it('emits the cancel event when the cancel button is clicked', () => {
createWrapper();
cancelButton().vm.$emit('click');
expect(wrapper.emitted().onCancel.length).toBe(1);
});
it('disables the save button when there is no text or only whitespace in the textarea', () => {
createWrapper({ initialComment: 'some comment' });
textarea().vm.$emit('input', ' ');
return wrapper.vm.$nextTick().then(() => {
expect(saveButton().attributes('disabled')).toBeTruthy();
});
});
it('disables all elements when the isSaving prop is true', () => {
createWrapper({ isSaving: true });
expect(textarea().attributes('disabled')).toBeTruthy();
expect(saveButton().attributes('disabled')).toBeTruthy();
expect(cancelButton().attributes('disabled')).toBeTruthy();
});
});
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import HistoryComment from 'ee/vulnerabilities/components/history_comment.vue';
import HistoryCommentEditor from 'ee/vulnerabilities/components/history_comment_editor.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
describe('History Comment', () => {
let wrapper;
const createWrapper = comment => {
wrapper = mount(HistoryComment, {
propsData: { comment },
});
};
const comment = {
id: 'id',
note: 'note',
author: {},
updated_at: new Date().toISOString(),
current_user: {
can_edit: true,
},
};
const addCommentButton = () => wrapper.find({ ref: 'addCommentButton' });
const commentEditor = () => wrapper.find(HistoryCommentEditor);
const eventItem = () => wrapper.find(EventItem);
const editButton = () => wrapper.find('[title="Edit Comment"]');
const deleteButton = () => wrapper.find('[title="Delete Comment"]');
const confirmDeleteButton = () => wrapper.find({ ref: 'confirmDeleteButton' });
const cancelDeleteButton = () => wrapper.find({ ref: 'cancelDeleteButton' });
// Check that the passed-in elements exist, and that everything else does not exist.
const expectExists = (...expectedElements) => {
const set = new Set(expectedElements);
expect(addCommentButton().exists()).toBe(set.has(addCommentButton));
expect(commentEditor().exists()).toBe(set.has(commentEditor));
expect(eventItem().exists()).toBe(set.has(eventItem));
expect(editButton().exists()).toBe(set.has(editButton));
expect(deleteButton().exists()).toBe(set.has(deleteButton));
expect(confirmDeleteButton().exists()).toBe(set.has(confirmDeleteButton));
expect(cancelDeleteButton().exists()).toBe(set.has(cancelDeleteButton));
};
const expectAddCommentView = () => expectExists(addCommentButton);
const expectExistingCommentView = () => expectExists(eventItem, editButton, deleteButton);
const expectEditCommentView = () => expectExists(commentEditor);
const expectDeleteConfirmView = () => {
expectExists(eventItem, confirmDeleteButton, cancelDeleteButton);
};
// Either the add comment button or the edit button will exist, but not both at the same time, so we'll just find
// whichever one exists and click it to show the editor.
const showEditView = () => {
if (addCommentButton().exists()) {
addCommentButton().vm.$emit('click');
} else {
editButton().vm.$emit('click');
}
return wrapper.vm.$nextTick();
};
afterEach(() => {
wrapper.destroy();
mockAxios.reset();
createFlash.mockReset();
});
describe(`when there's no existing comment`, () => {
beforeEach(() => createWrapper());
it('shows the add comment button', () => {
expectAddCommentView();
});
it('shows the comment editor when the add comment button is clicked', () => {
return showEditView().then(() => {
expectEditCommentView();
expect(commentEditor().props('initialComment')).toBeFalsy();
});
});
it('shows the add comment button when the cancel button is clicked in the comment editor', () => {
return showEditView()
.then(() => {
commentEditor().vm.$emit('onCancel');
return wrapper.vm.$nextTick();
})
.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.length).toBe(1);
expect(wrapper.emitted().onCommentAdded).toBeTruthy();
expect(wrapper.emitted().onCommentAdded[0][0]).toEqual(comment);
});
});
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.length).toBe(1);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(commentEditor().exists()).toBe(true);
});
});
});
describe(`when there's an existing comment`, () => {
beforeEach(() => createWrapper(comment));
it('shows the comment with the correct user author and timestamp and the edit/delete buttons', () => {
expectExistingCommentView();
expect(eventItem().props('author')).toBe(comment.author);
expect(eventItem().props('createdAt')).toBe(comment.updated_at);
expect(eventItem().element.innerHTML).toContain(comment.note);
});
it('shows the comment editor when the edit button is clicked', () => {
return showEditView().then(() => {
expectEditCommentView();
expect(commentEditor().props('initialComment')).toBe(comment.note);
});
});
it('shows the comment when the cancel button is clicked in the comment editor', () => {
return showEditView()
.then(() => {
commentEditor().vm.$emit('onCancel');
return wrapper.vm.$nextTick();
})
.then(() => {
expectExistingCommentView();
expect(eventItem().element.innerHTML).toContain(comment.note);
});
});
it('shows the delete confirmation buttons when the delete button is clicked', () => {
deleteButton().trigger('click');
return wrapper.vm.$nextTick().then(expectDeleteConfirmView);
});
it('shows the comment when the cancel button is clicked on the delete confirmation', () => {
deleteButton().trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
cancelDeleteButton().trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expectExistingCommentView();
expect(eventItem().element.innerHTML).toContain(comment.note);
});
});
it('deletes the comment when the confirm delete button is clicked', () => {
mockAxios.onDelete().replyOnce(200);
deleteButton().trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
confirmDeleteButton().trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(confirmDeleteButton().attributes('disabled')).toBeTruthy();
expect(cancelDeleteButton().attributes('disabled')).toBeTruthy();
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.delete.length).toBe(1);
expect(wrapper.emitted().onCommentDeleted).toBeTruthy();
expect(wrapper.emitted().onCommentDeleted[0][0]).toEqual(comment);
});
});
it('shows an error message when the comment cannot be deleted', () => {
mockAxios.onDelete().replyOnce(500);
deleteButton().trigger('click');
return wrapper.vm
.$nextTick()
.then(() => {
confirmDeleteButton().trigger('click');
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.delete.length).toBe(1);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
it('saves the comment when the save button is clicked on the comment editor', () => {
const responseData = { ...comment, note: 'new comment' };
mockAxios.onPut().replyOnce(200, responseData);
return showEditView()
.then(() => {
commentEditor().vm.$emit('onSave', responseData.note);
return wrapper.vm.$nextTick();
})
.then(() => {
expect(commentEditor().props('isSaving')).toBe(true);
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.put.length).toBe(1);
expect(wrapper.emitted().onCommentUpdated).toBeTruthy();
expect(wrapper.emitted().onCommentUpdated[0][0]).toEqual(responseData);
expect(wrapper.emitted().onCommentUpdated[0][1]).toEqual(comment);
});
});
it('shows an error message when the comment cannot be saved', () => {
mockAxios.onPut().replyOnce(500);
return showEditView()
.then(() => {
commentEditor().vm.$emit('onSave', 'some comment');
return axios.waitForAll();
})
.then(() => {
expect(mockAxios.history.put.length).toBe(1);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
describe('no permission to edit existing comment', () => {
it('does not show the edit/delete buttons if the current user has no edit permissions', () => {
createWrapper({ ...comment, current_user: { can_edit: false } });
expect(editButton().exists()).toBe(false);
expect(deleteButton().exists()).toBe(false);
});
});
});
...@@ -5,12 +5,12 @@ import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue' ...@@ -5,12 +5,12 @@ import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue'
describe('History Entry', () => { describe('History Entry', () => {
let wrapper; let wrapper;
const note = { const systemNote = {
system: true, system: true,
id: 123, id: 1,
note: 'changed vulnerability status to dismissed', note: 'changed vulnerability status to dismissed',
system_note_icon_name: 'cancel', system_note_icon_name: 'cancel',
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
author: { author: {
name: 'author name', name: 'author name',
username: 'author username', username: 'author username',
...@@ -18,34 +18,95 @@ describe('History Entry', () => { ...@@ -18,34 +18,95 @@ describe('History Entry', () => {
}, },
}; };
const createWrapper = options => { const commentNote = {
id: 2,
note: 'some note',
author: {},
current_user: {
can_edit: true,
},
};
const createWrapper = (...notes) => {
const discussion = { notes };
wrapper = mount(HistoryEntry, { wrapper = mount(HistoryEntry, {
propsData: { propsData: {
discussion: { discussion,
notes: [{ ...note, ...options }],
},
}, },
}); });
}; };
const eventItem = () => wrapper.find(EventItem); const eventItem = () => wrapper.find(EventItem);
const newComment = () => wrapper.find({ ref: 'newComment' });
const existingComments = () => wrapper.findAll({ ref: 'existingComment' });
const commentAt = index => existingComments().at(index);
afterEach(() => wrapper.destroy()); afterEach(() => wrapper.destroy());
it('passes the expected values to the event item component', () => { it('passes the expected values to the event item component', () => {
createWrapper(); createWrapper(systemNote);
expect(eventItem().text()).toContain(note.note); expect(eventItem().text()).toContain(systemNote.note);
expect(eventItem().props()).toMatchObject({ expect(eventItem().props()).toMatchObject({
id: note.id, id: systemNote.id,
author: note.author, author: systemNote.author,
createdAt: note.created_at, createdAt: systemNote.updated_at,
iconName: note.system_note_icon_name, iconName: systemNote.system_note_icon_name,
}); });
}); });
it('does not render anything if there is no system note', () => { it('does not show anything if there is no system note', () => {
createWrapper({ system: false }); createWrapper();
expect(wrapper.html()).toBeFalsy(); expect(wrapper.html()).toBeFalsy();
}); });
it('shows the add comment button where there are no comments', () => {
createWrapper(systemNote);
expect(newComment().exists()).toBe(true);
expect(existingComments().length).toBe(0);
});
it('displays comments when there are comments', () => {
const commentNoteClone = { ...commentNote, id: 3, note: 'different note' };
createWrapper(systemNote, commentNote, commentNoteClone);
expect(newComment().exists()).toBe(false);
expect(existingComments().length).toBe(2);
expect(commentAt(0).props('comment')).toEqual(commentNote);
expect(commentAt(1).props('comment')).toEqual(commentNoteClone);
});
it('adds a new comment correctly', () => {
createWrapper(systemNote);
newComment().vm.$emit('onCommentAdded', commentNote);
return wrapper.vm.$nextTick().then(() => {
expect(newComment().exists()).toBe(false);
expect(existingComments().length).toBe(1);
expect(commentAt(0).props('comment')).toEqual(commentNote);
});
});
it('updates an existing comment correctly', () => {
const note = 'new note';
createWrapper(systemNote, commentNote);
commentAt(0).vm.$emit('onCommentUpdated', { note }, commentNote);
return wrapper.vm.$nextTick().then(() => {
expect(commentAt(0).props('comment').note).toBe(note);
});
});
it('deletes an existing comment correctly', () => {
createWrapper(systemNote, commentNote);
commentAt(0).vm.$emit('onCommentDeleted', commentNote);
return wrapper.vm.$nextTick().then(() => {
expect(newComment().exists()).toBe(true);
expect(existingComments().length).toBe(0);
});
});
}); });
...@@ -23023,9 +23023,15 @@ msgstr "" ...@@ -23023,9 +23023,15 @@ msgstr ""
msgid "VulnerabilityManagement|Resolved %{timeago} by %{user}" msgid "VulnerabilityManagement|Resolved %{timeago} by %{user}"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later." msgid "VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later."
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not create an issue." msgid "VulnerabilityManagement|Something went wrong, could not create an issue."
msgstr "" msgstr ""
...@@ -25329,6 +25335,9 @@ msgstr "" ...@@ -25329,6 +25335,9 @@ msgstr ""
msgid "view the blob" msgid "view the blob"
msgstr "" msgstr ""
msgid "vulnerability|Add a comment"
msgstr ""
msgid "vulnerability|Add a comment or reason for dismissal" msgid "vulnerability|Add a comment or reason for dismissal"
msgstr "" 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