Skip to content
Snippets Groups Projects
Commit 66b7962f authored by Natalia Tepluhina's avatar Natalia Tepluhina
Browse files

Merge branch 'anti-abuse/168-add-comment-form' into 'master'

Add comment form to abuse report notes

See merge request gitlab-org/gitlab!135533



Merged-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Approved-by: default avatarSerena Fang <sfang@gitlab.com>
Approved-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Reviewed-by: default avatarNatalia Tepluhina <ntepluhina@gitlab.com>
Reviewed-by: default avatarHinam Mehra <hmehra@gitlab.com>
Reviewed-by: Deepika Guliani's avatarDeepika Guliani <dguliani@gitlab.com>
Co-authored-by: default avatarHinam Mehra <hmehra@gitlab.com>
parents 5bc31a32 8a8ab9b7
No related branches found
No related tags found
1 merge request!135533Add comment form to abuse report notes
Pipeline #1081320750 passed
Showing
with 882 additions and 33 deletions
......@@ -6,6 +6,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_not
import { SKELETON_NOTES_COUNT } from '~/admin/abuse_report/constants';
import abuseReportNotesQuery from '../graphql/notes/abuse_report_notes.query.graphql';
import AbuseReportDiscussion from './notes/abuse_report_discussion.vue';
import AbuseReportAddNote from './notes/abuse_report_add_note.vue';
export default {
name: 'AbuseReportNotes',
......@@ -16,6 +17,7 @@ export default {
components: {
SkeletonLoadingContainer,
AbuseReportDiscussion,
AbuseReportAddNote,
},
props: {
abuseReportId: {
......@@ -60,6 +62,9 @@ export default {
const discussionId = discussion.notes.nodes[0].id;
return discussionId.split('/')[discussionId.split('/').length - 1];
},
updateKey() {
this.addNoteKey = uniqueId(`abuse-report-add-note-${this.abuseReportId}`);
},
},
};
</script>
......@@ -86,6 +91,16 @@ export default {
:abuse-report-id="abuseReportId"
/>
</ul>
<div class="js-comment-form">
<ul class="notes notes-form timeline">
<abuse-report-add-note
:key="addNoteKey"
:is-new-discussion="true"
:abuse-report-id="abuseReportId"
@cancelEditing="updateKey"
/>
</ul>
</div>
</template>
</div>
</div>
......
<script>
import { sprintf, __ } from '~/locale';
import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
import createNoteMutation from '../../graphql/notes/create_abuse_report_note.mutation.graphql';
import AbuseReportCommentForm from './abuse_report_comment_form.vue';
export default {
name: 'AbuseReportAddNote',
i18n: {
reply: __('Reply'),
replyToComment: __('Reply to comment'),
commentError: __('Your comment could not be submitted because %{reason}.'),
genericError: __(
'Your comment could not be submitted! Please check your network connection and try again.',
),
},
components: {
AbuseReportCommentForm,
},
props: {
abuseReportId: {
type: String,
required: true,
},
discussionId: {
type: String,
required: false,
default: '',
},
isNewDiscussion: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isEditing: this.isNewDiscussion,
isSubmitting: false,
};
},
computed: {
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return this.discussionId ? `${this.discussionId}-comment` : `${this.abuseReportId}-comment`;
},
timelineEntryClasses() {
return this.isNewDiscussion
? 'timeline-entry note-form'
: // eslint-disable-next-line @gitlab/require-i18n-strings
'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix';
},
timelineEntryInnerClasses() {
return this.isNewDiscussion ? 'timeline-entry-inner' : '';
},
commentFormWrapperClasses() {
return !this.isEditing
? 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap'
: '';
},
},
methods: {
async addNote({ commentText }) {
this.isSubmitting = true;
this.$apollo
.mutate({
mutation: createNoteMutation,
variables: {
input: {
noteableId: this.abuseReportId,
body: commentText,
discussionId: this.discussionId || null,
},
},
})
.then(() => {
clearDraft(this.autosaveKey);
this.cancelEditing();
})
.catch((error) => {
const errorMessage = error?.message
? sprintf(this.$options.i18n.commentError, { reason: error.message.toLowerCase() })
: this.$options.i18n.genericError;
createAlert({
message: errorMessage,
parent: this.$el,
captureError: true,
});
})
.finally(() => {
this.isSubmitting = false;
});
},
cancelEditing() {
this.isEditing = this.isNewDiscussion;
this.$emit('cancelEditing');
},
showReplyForm() {
this.isEditing = true;
},
},
};
</script>
<template>
<li :class="timelineEntryClasses" data-testid="abuse-report-note-timeline-entry">
<div :class="timelineEntryInnerClasses" data-testid="abuse-report-note-timeline-entry-inner">
<div class="timeline-content">
<div class="flash-container"></div>
<div :class="commentFormWrapperClasses" data-testid="abuse-report-comment-form-wrapper">
<abuse-report-comment-form
v-if="isEditing"
:abuse-report-id="abuseReportId"
:is-submitting="isSubmitting"
:autosave-key="autosaveKey"
:is-new-discussion="isNewDiscussion"
@submitForm="addNote"
@cancelEditing="cancelEditing"
/>
<textarea
v-else
ref="textarea"
rows="1"
class="reply-placeholder-text-field gl-font-regular!"
data-testid="abuse-report-note-reply-textarea"
:placeholder="$options.i18n.reply"
:aria-label="$options.i18n.replyToComment"
@focus="showReplyForm"
@click="showReplyForm"
></textarea>
</div>
</div>
</div>
</li>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
export default {
name: 'AbuseReportCommentForm',
i18n: {
addReplyText: __('Add a reply'),
placeholderText: __('Write a comment or drag your files here…'),
cancelButtonText: __('Cancel'),
confirmText: s__('Notes|Are you sure you want to cancel creating this comment?'),
discardText: __('Discard changes'),
continueEditingText: __('Continue editing'),
},
components: {
GlButton,
MarkdownEditor,
},
inject: ['uploadNoteAttachmentPath'],
props: {
abuseReportId: {
type: String,
required: true,
},
isSubmitting: {
type: Boolean,
required: false,
default: false,
},
autosaveKey: {
type: String,
required: true,
},
isNewDiscussion: {
type: Boolean,
required: false,
default: false,
},
initialValue: {
type: String,
required: false,
default: '',
},
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
};
},
computed: {
formFieldProps() {
return {
'aria-label': this.$options.i18n.addReplyText,
placeholder: this.$options.i18n.placeholderText,
id: 'abuse-report-add-or-edit-comment',
name: 'abuse-report-add-or-edit-comment',
};
},
markdownDocsPath() {
return helpPagePath('user/markdown');
},
commentButtonText() {
return this.isNewDiscussion ? __('Comment') : __('Reply');
},
},
methods: {
setCommentText(newText) {
if (!this.isSubmitting) {
this.commentText = newText;
updateDraft(this.autosaveKey, this.commentText);
}
},
async cancelEditing() {
if (this.commentText && this.commentText !== this.initialValue) {
const confirmed = await confirmAction(this.$options.i18n.confirmText, {
primaryBtnText: this.$options.i18n.discardText,
cancelBtnText: this.$options.i18n.continueEditingText,
primaryBtnVariant: 'danger',
});
if (!confirmed) {
return;
}
}
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
},
};
</script>
<template>
<div class="timeline-discussion-body gl-overflow-visible!">
<div class="note-body gl-p-0! gl-overflow-visible!">
<form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1 new-note">
<markdown-editor
:value="commentText"
:enable-content-editor="false"
render-markdown-path=""
:uploads-path="uploadNoteAttachmentPath"
:markdown-docs-path="markdownDocsPath"
:form-field-props="formFieldProps"
:autofocus="true"
@input="setCommentText"
@keydown.meta.enter="$emit('submitForm', { commentText })"
@keydown.ctrl.enter="$emit('submitForm', { commentText })"
@keydown.esc.stop="cancelEditing"
/>
<div class="note-form-actions">
<gl-button
category="primary"
variant="confirm"
data-testid="comment-button"
:disabled="!commentText.length"
:loading="isSubmitting"
@click="$emit('submitForm', { commentText })"
>
{{ commentButtonText }}
</gl-button>
<gl-button
data-testid="cancel-button"
category="primary"
class="gl-ml-3"
@click="cancelEditing"
>{{ $options.i18n.cancelButtonText }}
</gl-button>
</div>
</form>
</div>
</div>
</template>
......@@ -4,6 +4,7 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item
import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import AbuseReportNote from './abuse_report_note.vue';
import AbuseReportAddNote from './abuse_report_add_note.vue';
export default {
name: 'AbuseReportDiscussion',
......@@ -12,6 +13,7 @@ export default {
DiscussionNotesRepliesWrapper,
ToggleRepliesWidget,
AbuseReportNote,
AbuseReportAddNote,
},
props: {
abuseReportId: {
......@@ -92,6 +94,11 @@ export default {
:abuse-report-id="abuseReportId"
/>
</template>
<abuse-report-add-note
:discussion-id="discussionId"
:is-new-discussion="false"
:abuse-report-id="abuseReportId"
/>
</template>
</discussion-notes-replies-wrapper>
</ul>
......
......@@ -30,6 +30,7 @@ export const initAbuseReportApp = () => {
allowScopedLabels: false,
updatePath: abuseReport.report.updatePath,
listPath: abuseReportsListPath,
uploadNoteAttachmentPath: abuseReport.uploadNoteAttachmentPath,
labelsManagePath: '',
allowLabelCreate: true,
},
......
......@@ -124,7 +124,7 @@ def screenshot_path
return screenshot.url unless screenshot.upload
asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url
local_path = Gitlab::Routing.url_helpers.abuse_report_upload_path(
local_path = Gitlab::Routing.url_helpers.abuse_report_screenshot_path(
filename: screenshot.filename,
id: screenshot.upload.model_id,
model: 'abuse_report',
......
......@@ -76,5 +76,9 @@ class AbuseReportDetailsEntity < Grape::Entity
expose :report do |report|
ReportedContentEntity.represent(report)
end
expose :upload_note_attachment_path do |report|
upload_path('abuse_report', id: report.id)
end
end
end
......@@ -9,7 +9,7 @@
# show uploads for models, snippets (notes) available for now
get '-/system/:model/:id/:secret/:filename',
to: 'uploads#show',
constraints: { model: /personal_snippet|user/, id: /\d+/, filename: %r{[^/]+} }
constraints: { model: /personal_snippet|user|abuse_report/, id: /\d+/, filename: %r{[^/]+} }
# show temporary uploads
get '-/system/temp/:secret/:filename',
......@@ -25,12 +25,12 @@
# create uploads for models, snippets (notes) available for now
post ':model',
to: 'uploads#create',
constraints: { model: /personal_snippet|user/, id: /\d+/ },
constraints: { model: /personal_snippet|user|abuse_report/, id: /\d+/ },
as: 'upload'
post ':model/authorize',
to: 'uploads#authorize',
constraints: { model: /personal_snippet|user/ }
constraints: { model: /personal_snippet|user|abuse_report/ }
# Alert Metric Images
get "-/system/:model/:mounted_as/:id/:filename",
......@@ -38,11 +38,11 @@
constraints: { model: /alert_management_metric_image/, mounted_as: /file/, filename: %r{[^/]+} },
as: 'alert_metric_image_upload'
# Abuse Reports Images
# screenshots uploaded by users when reporting abuse
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /abuse_report/, mounted_as: /screenshot/, filename: %r{[^/]+} },
as: 'abuse_report_upload'
as: 'abuse_report_screenshot'
end
# Redirect old note attachments path to new uploads path.
......
......@@ -8,6 +8,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_not
import abuseReportNotesQuery from '~/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql';
import AbuseReportNotes from '~/admin/abuse_report/components/abuse_report_notes.vue';
import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue';
import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
import { mockAbuseReport, mockNotesByIdResponse } from '../mock_data';
......@@ -24,6 +25,7 @@ describe('Abuse Report Notes', () => {
const findSkeletonLoaders = () => wrapper.findAllComponents(SkeletonLoadingContainer);
const findAbuseReportDiscussions = () => wrapper.findAllComponents(AbuseReportDiscussion);
const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote);
const createComponent = ({
queryHandler = notesQueryHandler,
......@@ -78,6 +80,16 @@ describe('Abuse Report Notes', () => {
discussion: discussions[1].notes.nodes,
});
});
it('should show the comment form', () => {
expect(findAbuseReportAddNote().exists()).toBe(true);
expect(findAbuseReportAddNote().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
discussionId: '',
isNewDiscussion: true,
});
});
});
describe('When there is an error fetching the notes', () => {
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
import waitForPromises from 'helpers/wait_for_promises';
import createNoteMutation from '~/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql';
import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue';
import { mockAbuseReport, createAbuseReportNoteResponse } from '../../mock_data';
jest.mock('~/alert');
jest.mock('~/lib/utils/autosave');
Vue.use(VueApollo);
describe('Abuse Report Add Note', () => {
let wrapper;
const mockAbuseReportId = mockAbuseReport.report.globalId;
const mutationSuccessHandler = jest.fn().mockResolvedValue(createAbuseReportNoteResponse);
const findTimelineEntry = () => wrapper.findByTestId('abuse-report-note-timeline-entry');
const findTimelineEntryInner = () =>
wrapper.findByTestId('abuse-report-note-timeline-entry-inner');
const findCommentFormWrapper = () => wrapper.findByTestId('abuse-report-comment-form-wrapper');
const findAbuseReportCommentForm = () => wrapper.findComponent(AbuseReportCommentForm);
const findReplyTextarea = () => wrapper.findByTestId('abuse-report-note-reply-textarea');
const createComponent = ({
mutationHandler = mutationSuccessHandler,
abuseReportId = mockAbuseReportId,
discussionId = '',
isNewDiscussion = true,
} = {}) => {
wrapper = shallowMountExtended(AbuseReportAddNote, {
apolloProvider: createMockApollo([[createNoteMutation, mutationHandler]]),
propsData: {
abuseReportId,
discussionId,
isNewDiscussion,
},
});
};
describe('Default', () => {
beforeEach(() => {
createComponent();
});
it('should show the comment form', () => {
expect(findAbuseReportCommentForm().exists()).toBe(true);
expect(findAbuseReportCommentForm().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
isSubmitting: false,
autosaveKey: `${mockAbuseReportId}-comment`,
isNewDiscussion: true,
initialValue: '',
});
});
it('should not show the reply textarea', () => {
expect(findReplyTextarea().exists()).toBe(false);
});
it('should add the correct classList to timeline-entry', () => {
expect(findTimelineEntry().classes()).toEqual(
expect.arrayContaining(['timeline-entry', 'note-form']),
);
expect(findTimelineEntryInner().classes()).toEqual(['timeline-entry-inner']);
});
});
describe('When the main comments has replies', () => {
beforeEach(() => {
createComponent({
discussionId: 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a',
isNewDiscussion: false,
});
});
it('should add the correct classLists', () => {
expect(findTimelineEntry().classes()).toEqual(
expect.arrayContaining([
'note',
'note-wrapper',
'note-comment',
'discussion-reply-holder',
'gl-border-t-0!',
'clearfix',
]),
);
expect(findTimelineEntryInner().classes()).toEqual([]);
expect(findCommentFormWrapper().classes()).toEqual(
expect.arrayContaining([
'gl-relative',
'gl-display-flex',
'gl-align-items-flex-start',
'gl-flex-nowrap',
]),
);
});
it('should show not the comment form', () => {
expect(findAbuseReportCommentForm().exists()).toBe(false);
});
it('should show the reply textarea', () => {
expect(findReplyTextarea().exists()).toBe(true);
expect(findReplyTextarea().attributes()).toMatchObject({
rows: '1',
placeholder: 'Reply',
'aria-label': 'Reply to comment',
});
});
});
describe('Adding a comment', () => {
const noteText = 'mock note';
beforeEach(() => {
createComponent();
findAbuseReportCommentForm().vm.$emit('submitForm', {
commentText: noteText,
});
});
it('should call the mutation with provided noteText', async () => {
expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(true);
expect(mutationSuccessHandler).toHaveBeenCalledWith({
input: {
noteableId: mockAbuseReportId,
body: noteText,
discussionId: null,
},
});
await waitForPromises();
expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(false);
});
it('should add the correct classList to comment-form wrapper', () => {
expect(findCommentFormWrapper().classes()).toEqual([]);
});
it('should clear draft from local storage', async () => {
await waitForPromises();
expect(clearDraft).toHaveBeenCalledWith(`${mockAbuseReportId}-comment`);
});
it('should emit `cancelEditing` event', async () => {
await waitForPromises();
expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
});
it.each`
description | errorResponse
${'with an error response'} | ${new Error('The discussion could not be found')}
${'without an error ressponse'} | ${null}
`('should show an error when mutation fails $description', async ({ errorResponse }) => {
createComponent({
mutationHandler: jest.fn().mockRejectedValue(errorResponse),
});
findAbuseReportCommentForm().vm.$emit('submitForm', {
commentText: noteText,
});
await waitForPromises();
const errorMessage = errorResponse
? 'Your comment could not be submitted because the discussion could not be found.'
: 'Your comment could not be submitted! Please check your network connection and try again.';
expect(createAlert).toHaveBeenCalledWith({
message: errorMessage,
captureError: true,
parent: expect.anything(),
});
});
});
describe('Replying to a comment', () => {
beforeEach(() => {
createComponent({
discussionId: 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a',
isNewDiscussion: false,
});
});
it('should show comment form when reply textarea is clicked on', async () => {
await findReplyTextarea().trigger('click');
expect(findAbuseReportCommentForm().exists()).toBe(true);
});
});
});
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
import * as autosave from '~/lib/utils/autosave';
import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { mockAbuseReport } from '../../mock_data';
jest.mock('~/lib/utils/autosave', () => ({
updateDraft: jest.fn(),
clearDraft: jest.fn(),
getDraft: jest.fn().mockReturnValue(''),
}));
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({
confirmAction: jest.fn().mockResolvedValue(true),
}));
describe('Abuse Report Comment Form', () => {
let wrapper;
const mockAbuseReportId = mockAbuseReport.report.globalId;
const mockAutosaveKey = `${mockAbuseReportId}-comment`;
const mockInitialValue = 'note text';
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
const createComponent = ({
abuseReportId = mockAbuseReportId,
isSubmitting = false,
initialValue = mockInitialValue,
autosaveKey = mockAutosaveKey,
isNewDiscussion = true,
} = {}) => {
wrapper = shallowMount(AbuseReportCommentForm, {
propsData: {
abuseReportId,
isSubmitting,
initialValue,
autosaveKey,
isNewDiscussion,
},
provide: {
uploadNoteAttachmentPath: 'test-upload-path',
},
});
};
describe('Markdown editor', () => {
it('should show markdown editor', () => {
createComponent();
expect(findMarkdownEditor().exists()).toBe(true);
expect(findMarkdownEditor().props()).toMatchObject({
value: mockInitialValue,
renderMarkdownPath: '',
uploadsPath: 'test-upload-path',
enableContentEditor: false,
formFieldProps: {
'aria-label': 'Add a reply',
placeholder: 'Write a comment or drag your files here…',
id: 'abuse-report-add-or-edit-comment',
name: 'abuse-report-add-or-edit-comment',
},
markdownDocsPath: '/help/user/markdown',
});
});
it('should pass the draft from local storage if it exists', () => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
createComponent();
expect(findMarkdownEditor().props('value')).toBe('draft comment');
});
it('should pass an empty string if both draft & initialValue are empty', () => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => '');
createComponent({ initialValue: '' });
expect(findMarkdownEditor().props('value')).toBe('');
});
});
describe('Markdown Editor input', () => {
beforeEach(() => {
createComponent();
});
it('should set the correct comment text value', async () => {
findMarkdownEditor().vm.$emit('input', 'new comment');
await nextTick();
expect(findMarkdownEditor().props('value')).toBe('new comment');
});
it('should call `updateDraft` with correct parameters', () => {
findMarkdownEditor().vm.$emit('input', 'new comment');
expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment');
});
});
describe('Submitting a comment', () => {
beforeEach(() => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
createComponent();
});
it('should show comment button', () => {
expect(findCommentButton().exists()).toBe(true);
expect(findCommentButton().text()).toBe('Comment');
});
it('should show `Reply` button if its not a new discussion', () => {
createComponent({ isNewDiscussion: false });
expect(findCommentButton().text()).toBe('Reply');
});
describe('when enter with meta key is pressed', () => {
beforeEach(() => {
findMarkdownEditor().vm.$emit(
'keydown',
new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }),
);
});
it('should emit `submitForm` event with correct parameters', () => {
expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
});
});
describe('when ctrl+enter is pressed', () => {
beforeEach(() => {
findMarkdownEditor().vm.$emit(
'keydown',
new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }),
);
});
it('should emit `submitForm` event with correct parameters', () => {
expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
});
});
describe('when comment button is clicked', () => {
beforeEach(() => {
findCommentButton().vm.$emit('click');
});
it('should emit `submitForm` event with correct parameters', () => {
expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
});
});
});
describe('Cancel editing', () => {
beforeEach(() => {
jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
createComponent();
});
it('should show cancel button', () => {
expect(findCancelButton().exists()).toBe(true);
expect(findCancelButton().text()).toBe('Cancel');
});
describe('when escape key is pressed', () => {
beforeEach(() => {
findMarkdownEditor().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY }));
return waitForPromises();
});
it('should confirm a user action if comment text is not empty', () => {
expect(confirmViaGlModal.confirmAction).toHaveBeenCalled();
});
it('should clear draft from local storage', () => {
expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
});
it('should emit `cancelEditing` event', () => {
expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
});
});
describe('when cancel button is clicked', () => {
beforeEach(() => {
findCancelButton().vm.$emit('click');
return waitForPromises();
});
it('should confirm a user action if comment text is not empty', () => {
expect(confirmViaGlModal.confirmAction).toHaveBeenCalled();
});
it('should clear draft from local storage', () => {
expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
});
it('should emit `cancelEditing` event', () => {
expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
});
});
});
});
......@@ -4,6 +4,7 @@ import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue';
import AbuseReportNote from '~/admin/abuse_report/components/notes/abuse_report_note.vue';
import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
import {
mockAbuseReport,
......@@ -19,6 +20,7 @@ describe('Abuse Report Discussion', () => {
const findAbuseReportNotes = () => wrapper.findAllComponents(AbuseReportNote);
const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget);
const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote);
const createComponent = ({
discussion = mockDiscussionWithNoReplies,
......@@ -50,9 +52,13 @@ describe('Abuse Report Discussion', () => {
expect(findTimelineEntryItem().exists()).toBe(false);
});
it('should not show the the toggle replies widget wrapper when no replies', () => {
it('should not show the toggle replies widget wrapper when there are no replies', () => {
expect(findToggleRepliesWidget().exists()).toBe(false);
});
it('should not show the comment form there are no replies', () => {
expect(findAbuseReportAddNote().exists()).toBe(false);
});
});
describe('When the main comments has replies', () => {
......@@ -75,5 +81,15 @@ describe('Abuse Report Discussion', () => {
await nextTick();
expect(findAbuseReportNotes()).toHaveLength(1);
});
it('should show the comment form', () => {
expect(findAbuseReportAddNote().exists()).toBe(true);
expect(findAbuseReportAddNote().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
discussionId: mockDiscussionWithReplies[0].discussion.id,
isNewDiscussion: false,
});
});
});
});
......@@ -340,3 +340,52 @@ export const mockNotesByIdResponse = {
},
},
};
export const createAbuseReportNoteResponse = {
data: {
createNote: {
note: {
id: 'gid://gitlab/Note/6',
discussion: {
id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d',
notes: {
nodes: [
{
id: 'gid://gitlab/Note/6',
body: 'Another comment',
bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Another comment</p>',
createdAt: '2023-11-02T02:45:46Z',
lastEditedAt: '2023-11-02T02:45:46Z',
url: 'http://127.0.0.1:3000/admin/abuse_reports/20#note_6',
resolved: false,
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
name: 'Administrator',
username: 'root',
webUrl: 'http://127.0.0.1:3000/root',
},
lastEditedBy: null,
userPermissions: {
adminNote: true,
},
discussion: {
id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d',
notes: {
nodes: [
{
id: 'gid://gitlab/Note/6',
},
],
},
},
},
],
},
},
},
errors: [],
},
},
};
......@@ -3,37 +3,84 @@
require 'spec_helper'
RSpec.describe 'Uploads', 'routing' do
it 'allows creating uploads for personal snippets' do
expect(post('/uploads/personal_snippet?id=1')).to route_to(
controller: 'uploads',
action: 'create',
model: 'personal_snippet',
id: '1'
)
context 'for personal snippets' do
it 'allows creating uploads for personal snippets' do
expect(post('/uploads/personal_snippet?id=1')).to route_to(
controller: 'uploads',
action: 'create',
model: 'personal_snippet',
id: '1'
)
end
end
context 'for users' do
it 'allows creating uploads for users' do
expect(post('/uploads/user?id=1')).to route_to(
controller: 'uploads',
action: 'create',
model: 'user',
id: '1'
)
end
end
it 'allows creating uploads for users' do
expect(post('/uploads/user?id=1')).to route_to(
controller: 'uploads',
action: 'create',
model: 'user',
id: '1'
)
context 'for abuse reports' do
it 'allows fetching uploaded files for abuse reports' do
expect(get('/uploads/-/system/abuse_report/1/secret/test.png')).to route_to(
controller: 'uploads',
action: 'show',
model: 'abuse_report',
id: '1',
secret: 'secret',
filename: 'test.png'
)
end
it 'allows creating uploads for abuse reports' do
expect(post('/uploads/abuse_report?id=1')).to route_to(
controller: 'uploads',
action: 'create',
model: 'abuse_report',
id: '1'
)
end
it 'allows authorizing uploads for abuse reports' do
expect(post('/uploads/abuse_report/authorize')).to route_to(
controller: 'uploads',
action: 'authorize',
model: 'abuse_report'
)
end
it 'allows fetching abuse report screenshots' do
expect(get('/uploads/-/system/abuse_report/screenshot/1/test.jpg')).to route_to(
controller: 'uploads',
action: 'show',
model: 'abuse_report',
id: '1',
filename: 'test.jpg',
mounted_as: 'screenshot'
)
end
end
it 'allows fetching alert metric metric images' do
expect(get('/uploads/-/system/alert_management_metric_image/file/1/test.jpg')).to route_to(
controller: 'uploads',
action: 'show',
model: 'alert_management_metric_image',
id: '1',
filename: 'test.jpg',
mounted_as: 'file'
)
context 'for alert management' do
it 'allows fetching alert metric metric images' do
expect(get('/uploads/-/system/alert_management_metric_image/file/1/test.jpg')).to route_to(
controller: 'uploads',
action: 'show',
model: 'alert_management_metric_image',
id: '1',
filename: 'test.jpg',
mounted_as: 'file'
)
end
end
it 'does not allow creating uploads for other models' do
unroutable_models = UploadsController::MODEL_CLASSES.keys.compact - %w[personal_snippet user]
unroutable_models = UploadsController::MODEL_CLASSES.keys.compact - %w[personal_snippet user abuse_report]
unroutable_models.each do |model|
expect(post("/uploads/#{model}?id=1")).not_to be_routable
......
......@@ -21,7 +21,8 @@
it 'exposes correct attributes' do
expect(entity_hash.keys).to match_array([
:user,
:report
:report,
:upload_note_attachment_path
])
end
......
......@@ -11,7 +11,8 @@
it 'serializes an abuse report' do
is_expected.to match_array([
:user,
:report
:report,
:upload_note_attachment_path
])
end
end
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment