Commit c26b3d9a authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '336009' into 'master'

Feat: enable spam button from snippets

See merge request gitlab-org/gitlab!66913
parents 551d55a5 449a7e04
...@@ -54,6 +54,7 @@ export default { ...@@ -54,6 +54,7 @@ export default {
}, },
}, },
}, },
inject: ['reportAbusePath'],
props: { props: {
snippet: { snippet: {
type: Object, type: Object,
...@@ -93,7 +94,6 @@ export default { ...@@ -93,7 +94,6 @@ export default {
click: this.showDeleteModal, click: this.showDeleteModal,
variant: 'danger', variant: 'danger',
category: 'secondary', category: 'secondary',
cssClass: 'ml-2',
}, },
{ {
condition: this.canCreateSnippet, condition: this.canCreateSnippet,
...@@ -103,10 +103,18 @@ export default { ...@@ -103,10 +103,18 @@ export default {
: joinPaths('/', gon.relative_url_root, '/-/snippets/new'), : joinPaths('/', gon.relative_url_root, '/-/snippets/new'),
variant: 'success', variant: 'success',
category: 'secondary', category: 'secondary',
cssClass: 'ml-2', },
{
condition: this.reportAbusePath,
text: __('Submit as spam'),
href: this.reportAbusePath,
title: __('Submit as spam'),
}, },
]; ];
}, },
hasPersonalSnippetActions() {
return Boolean(this.personalSnippetActions.filter(({ condition }) => condition).length);
},
editLink() { editLink() {
return `${this.snippet.webUrl}/edit`; return `${this.snippet.webUrl}/edit`;
}, },
...@@ -212,7 +220,7 @@ export default { ...@@ -212,7 +220,7 @@ export default {
</div> </div>
</div> </div>
<div class="detail-page-header-actions"> <div v-if="hasPersonalSnippetActions" class="detail-page-header-actions">
<div class="d-none d-sm-flex"> <div class="d-none d-sm-flex">
<template v-for="(action, index) in personalSnippetActions"> <template v-for="(action, index) in personalSnippetActions">
<div <div
...@@ -221,6 +229,7 @@ export default { ...@@ -221,6 +229,7 @@ export default {
v-gl-tooltip v-gl-tooltip
:title="action.title" :title="action.title"
class="d-inline-block" class="d-inline-block"
:class="{ 'gl-ml-3': index > 0 }"
> >
<gl-button <gl-button
:disabled="action.disabled" :disabled="action.disabled"
...@@ -239,8 +248,9 @@ export default { ...@@ -239,8 +248,9 @@ export default {
</div> </div>
<div class="d-block d-sm-none dropdown"> <div class="d-block d-sm-none dropdown">
<gl-dropdown :text="__('Options')" block> <gl-dropdown :text="__('Options')" block>
<template v-for="(action, index) in personalSnippetActions">
<gl-dropdown-item <gl-dropdown-item
v-for="(action, index) in personalSnippetActions" v-if="action.condition"
:key="index" :key="index"
:disabled="action.disabled" :disabled="action.disabled"
:title="action.title" :title="action.title"
...@@ -248,6 +258,7 @@ export default { ...@@ -248,6 +258,7 @@ export default {
@click="action.click ? action.click() : undefined" @click="action.click ? action.click() : undefined"
>{{ action.text }}</gl-dropdown-item >{{ action.text }}</gl-dropdown-item
> >
</template>
</gl-dropdown> </gl-dropdown>
</div> </div>
</div> </div>
......
...@@ -27,6 +27,7 @@ export default function appFactory(el, Component) { ...@@ -27,6 +27,7 @@ export default function appFactory(el, Component) {
visibilityLevels = '[]', visibilityLevels = '[]',
selectedLevel, selectedLevel,
multipleLevelsRestricted, multipleLevelsRestricted,
reportAbusePath,
...restDataset ...restDataset
} = el.dataset; } = el.dataset;
...@@ -37,6 +38,7 @@ export default function appFactory(el, Component) { ...@@ -37,6 +38,7 @@ export default function appFactory(el, Component) {
visibilityLevels: JSON.parse(visibilityLevels), visibilityLevels: JSON.parse(visibilityLevels),
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE, selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset, multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
}, },
render(createElement) { render(createElement) {
return createElement(Component, { return createElement(Component, {
......
...@@ -39,6 +39,12 @@ module SnippetsHelper ...@@ -39,6 +39,12 @@ module SnippetsHelper
end end
end end
def snippet_report_abuse_path(snippet)
return unless snippet.submittable_as_spam_by?(current_user)
mark_as_spam_snippet_path(snippet)
end
def embedded_raw_snippet_button(snippet, blob) def embedded_raw_snippet_button(snippet, blob)
return if blob.empty? || blob.binary? || blob.stored_externally? return if blob.empty? || blob.binary? || blob.stored_externally?
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
- breadcrumb_title @snippet.to_reference - breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
.row-content-block.top-block.content-component-block .row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet) = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet)
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
- content_for :prefetch_asset_tags do - content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true) - webpack_preload_asset_tag('monaco', prefetch: true)
#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
.row-content-block.top-block.content-component-block .row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true = render 'award_emoji/awards_block', awardable: @snippet, inline: true
......
import { GlButton, GlModal } from '@gitlab/ui'; import { GlButton, GlModal, GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo'; import { ApolloMutation } from 'vue-apollo';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
...@@ -8,8 +8,6 @@ import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; ...@@ -8,8 +8,6 @@ import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import SnippetHeader from '~/snippets/components/snippet_header.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue';
import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql';
useMockLocationHelper();
describe('Snippet header component', () => { describe('Snippet header component', () => {
let wrapper; let wrapper;
let snippet; let snippet;
...@@ -19,6 +17,7 @@ describe('Snippet header component', () => { ...@@ -19,6 +17,7 @@ describe('Snippet header component', () => {
let errorMsg; let errorMsg;
let err; let err;
const originalRelativeUrlRoot = gon.relative_url_root; const originalRelativeUrlRoot = gon.relative_url_root;
const reportAbusePath = '/-/snippets/42/mark_as_spam';
const GlEmoji = { template: '<img/>' }; const GlEmoji = { template: '<img/>' };
...@@ -27,6 +26,7 @@ describe('Snippet header component', () => { ...@@ -27,6 +26,7 @@ describe('Snippet header component', () => {
permissions = {}, permissions = {},
mutationRes = mutationTypes.RESOLVE, mutationRes = mutationTypes.RESOLVE,
snippetProps = {}, snippetProps = {},
provide = {},
} = {}) { } = {}) {
const defaultProps = Object.assign(snippet, snippetProps); const defaultProps = Object.assign(snippet, snippetProps);
if (permissions) { if (permissions) {
...@@ -45,6 +45,10 @@ describe('Snippet header component', () => { ...@@ -45,6 +45,10 @@ describe('Snippet header component', () => {
wrapper = mount(SnippetHeader, { wrapper = mount(SnippetHeader, {
mocks: { $apollo }, mocks: { $apollo },
provide: {
reportAbusePath,
...provide,
},
propsData: { propsData: {
snippet: { snippet: {
...defaultProps, ...defaultProps,
...@@ -57,9 +61,27 @@ describe('Snippet header component', () => { ...@@ -57,9 +61,27 @@ describe('Snippet header component', () => {
}); });
} }
const findAuthorEmoji = () => wrapper.find(GlEmoji); const findAuthorEmoji = () => wrapper.findComponent(GlEmoji);
const findAuthoredMessage = () => wrapper.find('[data-testid="authored-message"]').text(); const findAuthoredMessage = () => wrapper.find('[data-testid="authored-message"]').text();
const buttonCount = () => wrapper.findAll(GlButton).length; const findButtons = () => wrapper.findAllComponents(GlButton);
const findButtonsAsModel = () =>
findButtons().wrappers.map((x) => ({
text: x.text(),
href: x.attributes('href'),
category: x.props('category'),
variant: x.props('variant'),
disabled: x.props('disabled'),
}));
const findResponsiveDropdown = () => wrapper.findComponent(GlDropdown);
// We can't search by component here since we are full mounting and the attributes are applied to a child of the GlDropdownItem
const findResponsiveDropdownItems = () => findResponsiveDropdown().findAll('[role="menuitem"]');
const findResponsiveDropdownItemsAsModel = () =>
findResponsiveDropdownItems().wrappers.map((x) => ({
disabled: x.attributes('disabled'),
href: x.attributes('href'),
title: x.attributes('title'),
text: x.text(),
}));
beforeEach(() => { beforeEach(() => {
gon.relative_url_root = '/foo/'; gon.relative_url_root = '/foo/';
...@@ -144,42 +166,108 @@ describe('Snippet header component', () => { ...@@ -144,42 +166,108 @@ describe('Snippet header component', () => {
expect(text).toBe('Authored 1 month ago'); expect(text).toBe('Authored 1 month ago');
}); });
it('renders action buttons based on permissions', () => { it('renders a action buttons', () => {
createComponent({ createComponent();
permissions: {
adminSnippet: false, expect(findButtonsAsModel()).toEqual([
updateSnippet: false, {
category: 'primary',
disabled: false,
href: `${snippet.webUrl}/edit`,
text: 'Edit',
variant: 'default',
},
{
category: 'secondary',
disabled: false,
text: 'Delete',
variant: 'danger',
},
{
category: 'primary',
disabled: false,
href: reportAbusePath,
text: 'Submit as spam',
variant: 'default',
}, },
]);
}); });
expect(buttonCount()).toEqual(0);
createComponent({ it('renders responsive dropdown for action buttons', () => {
permissions: { createComponent();
adminSnippet: true,
updateSnippet: false, expect(findResponsiveDropdownItemsAsModel()).toEqual([
{
href: `${snippet.webUrl}/edit`,
text: 'Edit',
},
{
text: 'Delete',
},
{
href: reportAbusePath,
text: 'Submit as spam',
title: 'Submit as spam',
}, },
]);
}); });
expect(buttonCount()).toEqual(1);
it.each`
permissions | buttons
${{ adminSnippet: false, updateSnippet: false }} | ${['Submit as spam']}
${{ adminSnippet: true, updateSnippet: false }} | ${['Delete', 'Submit as spam']}
${{ adminSnippet: false, updateSnippet: true }} | ${['Edit', 'Submit as spam']}
`('with permissions ($permissions), renders buttons ($buttons)', ({ permissions, buttons }) => {
createComponent({ createComponent({
permissions: { permissions: {
adminSnippet: true, ...permissions,
updateSnippet: true, },
});
expect(findButtonsAsModel().map((x) => x.text)).toEqual(buttons);
});
it('with canCreateSnippet permission, renders create button', async () => {
createComponent();
// TODO: we should avoid `wrapper.setData` since they
// are component internals. Let's use the apollo mock helpers
// in a follow-up.
wrapper.setData({ canCreateSnippet: true });
await wrapper.vm.$nextTick();
expect(findButtonsAsModel()).toEqual(
expect.arrayContaining([
{
category: 'secondary',
disabled: false,
href: `/foo/-/snippets/new`,
text: 'New snippet',
variant: 'success',
}, },
]),
);
}); });
expect(buttonCount()).toEqual(2);
describe('with guest user', () => {
beforeEach(() => {
createComponent({ createComponent({
permissions: { permissions: {
adminSnippet: true, adminSnippet: false,
updateSnippet: true, updateSnippet: false,
},
provide: {
reportAbusePath: null,
}, },
}); });
wrapper.setData({
canCreateSnippet: true,
}); });
return wrapper.vm.$nextTick().then(() => {
expect(buttonCount()).toEqual(3); it('does not show any action buttons', () => {
expect(findButtons()).toHaveLength(0);
});
it('does not show responsive action dropdown', () => {
expect(findResponsiveDropdown().exists()).toBe(false);
}); });
}); });
...@@ -221,6 +309,8 @@ describe('Snippet header component', () => { ...@@ -221,6 +309,8 @@ describe('Snippet header component', () => {
}); });
describe('in case of successful mutation, closes modal and redirects to correct listing', () => { describe('in case of successful mutation, closes modal and redirects to correct listing', () => {
useMockLocationHelper();
const createDeleteSnippet = (snippetProps = {}) => { const createDeleteSnippet = (snippetProps = {}) => {
createComponent({ createComponent({
snippetProps, snippetProps,
......
...@@ -92,4 +92,23 @@ RSpec.describe SnippetsHelper do ...@@ -92,4 +92,23 @@ RSpec.describe SnippetsHelper do
end end
end end
end end
describe '#snippet_report_abuse_path' do
let(:snippet) { public_personal_snippet }
let(:current_user) { create(:user) }
subject { snippet_report_abuse_path(snippet) }
it 'returns false if the user cannot submit the snippet as spam' do
allow(snippet).to receive(:submittable_as_spam_by?).and_return(false)
expect(subject).to be_falsey
end
it 'returns true if the user can submit the snippet as spam' do
allow(snippet).to receive(:submittable_as_spam_by?).and_return(true)
expect(subject).to be_truthy
end
end
end end
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