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

Merge branch 'anti-abuse/168-display-abuse-report-notes' into 'master'

Display abuse report notes (frontend)

See merge request gitlab-org/gitlab!134730



Merged-by: default avatarNatalia Tepluhina <ntepluhina@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 5d049f0d a0dac122
No related branches found
No related tags found
1 merge request!134730Display abuse report notes (frontend)
Pipeline #1065110975 passed
Showing
with 596 additions and 33 deletions
......@@ -7,6 +7,7 @@ import ReportDetails from './report_details.vue';
import ReportedContent from './reported_content.vue';
import ActivityEventsList from './activity_events_list.vue';
import ActivityHistoryItem from './activity_history_item.vue';
import AbuseReportNotes from './abuse_report_notes.vue';
const alertDefaults = {
visible: false,
......@@ -24,6 +25,7 @@ export default {
ReportedContent,
ActivityEventsList,
ActivityHistoryItem,
AbuseReportNotes,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -96,5 +98,10 @@ export default {
/>
</template>
</activity-events-list>
<abuse-report-notes
v-if="glFeatures.abuseReportNotes"
:abuse-report-id="abuseReport.report.globalId"
/>
</section>
</template>
<script>
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import { createAlert } from '~/alert';
import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
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';
export default {
name: 'AbuseReportNotes',
SKELETON_NOTES_COUNT,
i18n: {
fetchError: __('An error occurred while fetching comments, please try again.'),
},
components: {
SkeletonLoadingContainer,
AbuseReportDiscussion,
},
props: {
abuseReportId: {
type: String,
required: true,
},
},
data() {
return {
addNoteKey: uniqueId(`abuse-report-add-note-${this.abuseReportId}`),
};
},
apollo: {
abuseReportNotes: {
query: abuseReportNotesQuery,
variables() {
return {
id: this.abuseReportId,
};
},
update(data) {
return data.abuseReport?.discussions || [];
},
skip() {
return !this.abuseReportId;
},
error() {
createAlert({ message: this.$options.i18n.fetchError });
},
},
},
computed: {
initialLoading() {
return this.$apollo.queries.abuseReportNotes.loading;
},
notesArray() {
return this.abuseReportNotes?.nodes || [];
},
},
methods: {
getDiscussionKey(discussion) {
const discussionId = discussion.notes.nodes[0].id;
return discussionId.split('/')[discussionId.split('/').length - 1];
},
},
};
</script>
<template>
<div>
<div class="issuable-discussion gl-mb-5 gl-clearfix!">
<template v-if="initialLoading">
<ul class="notes main-notes-list timeline">
<skeleton-loading-container
v-for="index in $options.SKELETON_NOTES_COUNT"
:key="index"
class="note-skeleton"
/>
</ul>
</template>
<template v-else>
<ul class="notes main-notes-list timeline">
<abuse-report-discussion
v-for="discussion in notesArray"
:key="getDiscussionKey(discussion)"
:discussion="discussion.notes.nodes"
:abuse-report-id="abuseReportId"
/>
</ul>
</template>
</div>
</div>
</template>
......@@ -11,7 +11,7 @@ export default {
<!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below
are declared in app/assets/stylesheets/pages/notes.scss -->
<section class="gl-pt-6 issuable-discussion">
<h2 class="gl-font-lg gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2>
<h2 class="gl-font-size-h1 gl-mt-0 gl-mb-4">{{ $options.i18n.activity }}</h2>
<ul class="timeline main-notes-list notes">
<slot name="history-items"></slot>
</ul>
......
<script>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
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';
export default {
name: 'AbuseReportDiscussion',
components: {
TimelineEntryItem,
DiscussionNotesRepliesWrapper,
ToggleRepliesWidget,
AbuseReportNote,
},
props: {
abuseReportId: {
type: String,
required: true,
},
discussion: {
type: Array,
required: true,
},
},
data() {
return {
isExpanded: true,
};
},
computed: {
note() {
return this.discussion[0];
},
noteId() {
return getIdFromGraphQLId(this.note.id);
},
replies() {
if (this.discussion?.length > 1) {
return this.discussion.slice(1);
}
return null;
},
hasReplies() {
return Boolean(this.replies?.length);
},
discussionId() {
return this.discussion[0]?.discussion?.id || '';
},
},
methods: {
toggleDiscussion() {
this.isExpanded = !this.isExpanded;
},
},
};
</script>
<template>
<abuse-report-note
v-if="!hasReplies"
:note="note"
:abuse-report-id="abuseReportId"
class="gl-mb-4"
/>
<timeline-entry-item v-else :data-note-id="noteId" class="note note-discussion gl-px-0">
<div class="timeline-content">
<div class="discussion">
<div class="discussion-body">
<div class="discussion-wrapper">
<div class="discussion-notes">
<ul class="notes">
<abuse-report-note
:note="note"
:discussion-id="discussionId"
:abuse-report-id="abuseReportId"
class="gl-mb-4"
/>
<discussion-notes-replies-wrapper>
<toggle-replies-widget
v-if="hasReplies"
:collapsed="!isExpanded"
:replies="replies"
@toggle="toggleDiscussion({ discussionId })"
/>
<template v-if="isExpanded">
<template v-for="reply in replies">
<abuse-report-note
:key="reply.id"
:discussion-id="discussionId"
:note="reply"
:abuse-report-id="abuseReportId"
/>
</template>
</template>
</discussion-notes-replies-wrapper>
</ul>
</div>
</div>
</div>
</div>
</div>
</timeline-entry-item>
</template>
<script>
import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteBody from './abuse_report_note_body.vue';
export default {
name: 'AbuseReportNote',
directives: {
SafeHtml,
},
components: {
GlAvatarLink,
GlAvatar,
TimelineEntryItem,
NoteHeader,
NoteBody,
},
props: {
abuseReportId: {
type: String,
required: true,
},
note: {
type: Object,
required: true,
},
},
computed: {
noteAnchorId() {
return `note_${getIdFromGraphQLId(this.note.id)}`;
},
author() {
return this.note.author;
},
authorId() {
return getIdFromGraphQLId(this.author.id);
},
},
};
</script>
<template>
<timeline-entry-item :id="noteAnchorId" class="note note-wrapper note-comment">
<div :key="note.id" class="timeline-avatar gl-float-left">
<gl-avatar-link
:href="author.webUrl"
:data-user-id="authorId"
:data-username="author.username"
class="js-user-link"
>
<gl-avatar
:src="author.avatarUrl"
:entity-name="author.username"
:alt="author.name"
:size="32"
/>
</gl-avatar-link>
</div>
<div class="timeline-content">
<div data-testid="note-wrapper">
<div class="note-header">
<note-header
:author="author"
:created-at="note.createdAt"
:note-id="note.id"
:note-url="note.url"
>
<span v-if="note.createdAt" class="d-none d-sm-inline">&middot;</span>
</note-header>
</div>
<div class="timeline-discussion-body">
<note-body ref="noteBody" :note="note" />
</div>
</div>
</div>
</timeline-entry-item>
</template>
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
export default {
name: 'AbuseReportNoteBody',
directives: {
SafeHtml,
},
props: {
note: {
type: Object,
required: true,
},
},
watch: {
'note.bodyHtml': {
immediate: true,
async handler(newVal, oldVal) {
if (newVal === oldVal) {
return;
}
await this.$nextTick();
this.renderGFM();
},
},
},
methods: {
renderGFM() {
renderGFM(this.$refs['note-body']);
gl?.lazyLoader?.searchLazyImages();
},
},
safeHtmlConfig: {
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
};
</script>
<template>
<div ref="note-body" class="note-body">
<div
v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml"
class="note-text md"
data-testid="abuse-report-note-body"
></div>
</div>
</template>
......@@ -67,7 +67,7 @@ export default {
<div
class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column gl-align-items-center"
>
<h2 class="gl-font-lg gl-mt-2 gl-mb-2">
<h2 class="gl-font-size-h1 gl-mt-2 gl-mb-2">
{{ $options.i18n.reportTypes[reportType] }}
</h2>
......
......@@ -111,3 +111,5 @@ export const HISTORY_ITEMS_I18N = {
reportedByForCategory: s__('AbuseReport|Reported by %{name} for %{category}.'),
deletedReporter: s__('AbuseReport|No user found'),
};
export const SKELETON_NOTES_COUNT = 5;
#import "./notes/abuse_report_note.fragment.graphql"
#import "./notes/abuse_report_discussion_resolved_status.fragment.graphql"
query abuseReportQuery($id: AbuseReportID!) {
abuseReport(id: $id) {
id
......@@ -13,17 +10,5 @@ query abuseReportQuery($id: AbuseReportID!) {
textColor
}
}
discussions {
nodes {
id
replyId
...AbuseReportDiscussionResolvedStatus
notes {
nodes {
...AbuseReportNote
}
}
}
}
}
}
fragment AbuseReportDiscussionResolvedStatus on Discussion {
id
resolvable
resolved
resolvedAt
resolvedBy {
id
name
webUrl
}
}
......@@ -3,13 +3,19 @@
fragment AbuseReportNote on Note {
id
author {
...Author
}
body
bodyHtml
createdAt
lastEditedAt
url
resolved
author {
...Author
}
lastEditedBy {
...Author
webPath
}
userPermissions {
...AbuseReportNotePermissions
}
......
#import "./abuse_report_note.fragment.graphql"
query abuseReportNotes($id: AbuseReportID!) {
abuseReport(id: $id) {
id
discussions {
nodes {
id
replyId
notes {
nodes {
...AbuseReportNote
}
}
}
}
}
}
......@@ -7,6 +7,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController
before_action :find_abuse_report, only: [:show, :moderate_user, :update, :destroy]
before_action only: :show do
push_frontend_feature_flag(:abuse_report_labels)
push_frontend_feature_flag(:abuse_report_notes)
end
def index
......
- add_to_breadcrumbs _('Abuse Reports'), admin_abuse_reports_path
- breadcrumb_title @abuse_report.user&.name
- @content_class = "limit-container-width" unless fluid_layout
- page_title @abuse_report.user&.name, _('Abuse Reports')
#js-abuse-reports-detail-view{ data: abuse_report_data(@abuse_report) }
......
---
name: abuse_report_notes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134730
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429027
milestone: '16.6'
type: development
group: group::anti-abuse
default_enabled: false
......@@ -5115,6 +5115,9 @@ msgstr ""
msgid "An error occurred while fetching codequality mr diff reports."
msgstr ""
 
msgid "An error occurred while fetching comments, please try again."
msgstr ""
msgid "An error occurred while fetching commit data."
msgstr ""
 
......@@ -7,12 +7,14 @@ import ReportDetails from '~/admin/abuse_report/components/report_details.vue';
import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
import ActivityEventsList from '~/admin/abuse_report/components/activity_events_list.vue';
import ActivityHistoryItem from '~/admin/abuse_report/components/activity_history_item.vue';
import AbuseReportNotes from '~/admin/abuse_report/components/abuse_report_notes.vue';
import { SUCCESS_ALERT } from '~/admin/abuse_report/constants';
import { mockAbuseReport } from '../mock_data';
describe('AbuseReportApp', () => {
let wrapper;
const mockAbuseReportId = mockAbuseReport.report.globalId;
const { similarOpenReports } = mockAbuseReport.user;
const findAlert = () => wrapper.findComponent(GlAlert);
......@@ -27,6 +29,7 @@ describe('AbuseReportApp', () => {
const findActivityList = () => wrapper.findComponent(ActivityEventsList);
const findActivityItem = () => wrapper.findByTestId('activity');
const findActivityForSimilarReports = () =>
wrapper.findAllByTestId('activity-similar-open-reports');
const firstActivityForSimilarReports = () =>
......@@ -34,6 +37,8 @@ describe('AbuseReportApp', () => {
const findReportDetails = () => wrapper.findComponent(ReportDetails);
const findAbuseReportNotes = () => wrapper.findComponent(AbuseReportNotes);
const createComponent = (props = {}, provide = {}) => {
wrapper = shallowMountExtended(AbuseReportApp, {
propsData: {
......@@ -135,7 +140,7 @@ describe('AbuseReportApp', () => {
it('renders ReportDetails', () => {
createComponent({}, { glFeatures: { abuseReportLabels: true } });
expect(findReportDetails().props('reportId')).toBe(mockAbuseReport.report.globalId);
expect(findReportDetails().props('reportId')).toBe(mockAbuseReportId);
});
});
......@@ -162,4 +167,25 @@ describe('AbuseReportApp', () => {
expect(firstActivityForSimilarReports().props('report')).toBe(similarOpenReports[0]);
});
});
describe('Notes', () => {
describe('when abuseReportNotes feature flag is enabled', () => {
it('renders abuse report notes', () => {
createComponent({}, { glFeatures: { abuseReportNotes: true } });
expect(findAbuseReportNotes().exists()).toBe(true);
expect(findAbuseReportNotes().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
});
});
});
describe('when abuseReportNotes feature flag is disabled', () => {
it('does not render ReportDetails', () => {
createComponent({}, { glFeatures: { abuseReportNotes: false } });
expect(findAbuseReportNotes().exists()).toBe(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
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 { mockAbuseReport, mockNotesByIdResponse } from '../mock_data';
jest.mock('~/alert');
describe('Abuse Report Notes', () => {
let wrapper;
Vue.use(VueApollo);
const mockAbuseReportId = mockAbuseReport.report.globalId;
const notesQueryHandler = jest.fn().mockResolvedValue(mockNotesByIdResponse);
const findSkeletonLoaders = () => wrapper.findAllComponents(SkeletonLoadingContainer);
const findAbuseReportDiscussions = () => wrapper.findAllComponents(AbuseReportDiscussion);
const createComponent = ({
queryHandler = notesQueryHandler,
abuseReportId = mockAbuseReportId,
} = {}) => {
wrapper = shallowMount(AbuseReportNotes, {
apolloProvider: createMockApollo([[abuseReportNotesQuery, queryHandler]]),
propsData: {
abuseReportId,
},
});
};
describe('when notes are loading', () => {
beforeEach(() => {
createComponent();
});
it('should show the skeleton loaders', () => {
expect(findSkeletonLoaders()).toHaveLength(5);
});
});
describe('when notes have been loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('should not render skeleton loader', () => {
expect(findSkeletonLoaders()).toHaveLength(0);
});
it('should call the abuse report notes query', () => {
expect(notesQueryHandler).toHaveBeenCalledWith({
id: mockAbuseReportId,
});
});
it('should show notes to the length of the response', () => {
expect(findAbuseReportDiscussions()).toHaveLength(2);
const discussions = mockNotesByIdResponse.data.abuseReport.discussions.nodes;
expect(findAbuseReportDiscussions().at(0).props()).toMatchObject({
abuseReportId: mockAbuseReportId,
discussion: discussions[0].notes.nodes,
});
expect(findAbuseReportDiscussions().at(1).props()).toMatchObject({
abuseReportId: mockAbuseReportId,
discussion: discussions[1].notes.nodes,
});
});
});
describe('When there is an error fetching the notes', () => {
beforeEach(() => {
createComponent({
queryHandler: jest.fn().mockRejectedValue(new Error()),
});
return waitForPromises();
});
it('should show an error when query fails', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while fetching comments, please try again.',
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Abuse Report Note Body should show the note body 1`] = `
<div
class="md note-text"
data-testid="abuse-report-note-body"
>
<p
data-sourcepos="1:1-1:9"
dir="auto"
>
Comment 1
</p>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
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 {
mockAbuseReport,
mockDiscussionWithNoReplies,
mockDiscussionWithReplies,
} from '../../mock_data';
describe('Abuse Report Discussion', () => {
let wrapper;
const mockAbuseReportId = mockAbuseReport.report.globalId;
const findAbuseReportNote = () => wrapper.findComponent(AbuseReportNote);
const findAbuseReportNotes = () => wrapper.findAllComponents(AbuseReportNote);
const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget);
const createComponent = ({
discussion = mockDiscussionWithNoReplies,
abuseReportId = mockAbuseReportId,
} = {}) => {
wrapper = shallowMount(AbuseReportDiscussion, {
propsData: {
discussion,
abuseReportId,
},
});
};
describe('Default', () => {
beforeEach(() => {
createComponent();
});
it('should show the abuse report note', () => {
expect(findAbuseReportNote().exists()).toBe(true);
expect(findAbuseReportNote().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
note: mockDiscussionWithNoReplies[0],
});
});
it('should not show timeline entry item component', () => {
expect(findTimelineEntryItem().exists()).toBe(false);
});
it('should not show the the toggle replies widget wrapper when no replies', () => {
expect(findToggleRepliesWidget().exists()).toBe(false);
});
});
describe('When the main comments has replies', () => {
beforeEach(() => {
createComponent({
discussion: mockDiscussionWithReplies,
});
});
it('should show the toggle replies widget', () => {
expect(findToggleRepliesWidget().exists()).toBe(true);
});
it('the number of replies should be equal to the response length', () => {
expect(findAbuseReportNotes()).toHaveLength(3);
});
it('should collapse when we click on toggle replies widget', async () => {
findToggleRepliesWidget().vm.$emit('toggle');
await nextTick();
expect(findAbuseReportNotes()).toHaveLength(1);
});
});
});
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