Commit 1b0464d3 authored by Illya Klymov's avatar Illya Klymov

Merge branch '338751-hidden-issue-icon-in-issue-header' into 'master'

Add spam icon to issues that are hidden

See merge request gitlab-org/gitlab!68504
parents 3ee03d28 fe340ebb
<script>
import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
......@@ -32,6 +32,9 @@ export default {
formComponent,
PinnedLinks,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
endpoint: {
required: true,
......@@ -183,6 +186,11 @@ export default {
required: false,
default: true,
},
isHidden: {
type: Boolean,
required: false,
default: false,
},
},
data() {
const store = new Store({
......@@ -508,6 +516,15 @@ export default {
<span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
<gl-icon name="eye-slash" :aria-label="__('Confidential')" />
</span>
<span
v-if="isHidden"
v-gl-tooltip
:title="__('This issue is hidden because its author has been banned')"
data-testid="hidden"
class="issuable-warning-icon"
>
<gl-icon name="spam" />
</span>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
......
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableHeaderWarnings from './issuable_header_warnings.vue';
export default function issuableHeaderWarnings(store) {
const el = document.getElementById('js-issuable-header-warnings');
if (!el) {
return false;
}
const { hidden } = el.dataset;
return new Vue({
el: document.getElementById('js-issuable-header-warnings'),
el,
store,
provide: { hidden: parseBoolean(hidden) },
render(createElement) {
return createElement(IssuableHeaderWarnings);
},
......
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['hidden'],
computed: {
...mapGetters(['getNoteableData']),
isLocked() {
......@@ -26,6 +31,12 @@ export default {
visible: this.isConfidential,
dataTestId: 'confidential',
},
{
iconName: 'spam',
visible: this.hidden,
dataTestId: 'hidden',
tooltip: __('This issue is hidden because its author has been banned'),
},
];
},
},
......@@ -35,8 +46,15 @@ export default {
<template>
<div class="gl-display-inline-block">
<template v-for="meta in warningIconsMeta">
<div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline">
<gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" />
<div
v-if="meta.visible"
:key="meta.iconName"
v-gl-tooltip
:data-testid="meta.dataTestId"
:title="meta.tooltip || null"
class="issuable-warning-icon inline"
>
<gl-icon :name="meta.iconName" class="icon" />
</div>
</template>
</div>
......
......@@ -7,6 +7,7 @@
text-align: center;
margin-right: $issuable-warning-icon-margin;
line-height: $gl-line-height-24;
flex: 0 0 auto;
}
.limit-container-width {
......
......@@ -256,7 +256,8 @@ module IssuablesHelper
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord
iid: issuable.iid.to_s
iid: issuable.iid.to_s,
isHidden: issue_hidden?(issuable)
}
end
......
......@@ -60,8 +60,16 @@ module IssuesHelper
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
def issue_hidden?(issue)
Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
end
def hidden_issue_icon(issue)
sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') if issue.hidden?
return unless issue_hidden?(issue)
content_tag(:span, class: 'has-tooltip', title: _('This issue is hidden because its author has been banned')) do
sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
end
end
def award_user_list(awards, current_user, limit: 10)
......
......@@ -12,9 +12,7 @@
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
- if Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
%span.has-tooltip{ title: _('This issue is hidden because its author has been banned') }
= hidden_issue_icon(issue)
= hidden_issue_icon(issue)
= link_to issue.title, issue_path(issue)
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
......
......@@ -15,7 +15,7 @@
= _('Open')
.issuable-meta
#js-issuable-header-warnings
#js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } }
= issuable_meta(issuable, @project)
%a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
......
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
......@@ -33,13 +34,17 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header');
const findLockedBadge = () => wrapper.findByTestId('locked');
const findConfidentialBadge = () => wrapper.findByTestId('confidential');
const findHiddenBadge = () => wrapper.findByTestId('hidden');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}, data = {}) => {
wrapper = mount(IssuableApp, {
wrapper = mountExtended(IssuableApp, {
directives: {
GlTooltip: createMockDirective(),
},
propsData: { ...appProps, ...props },
provide: {
fullPath: 'gitlab-org/incidents',
......@@ -539,8 +544,8 @@ describe('Issuable output', () => {
it.each`
title | isConfidential
${'does not show confidential badge when issue is not confidential'} | ${true}
${'shows confidential badge when issue is confidential'} | ${false}
${'does not show confidential badge when issue is not confidential'} | ${false}
${'shows confidential badge when issue is confidential'} | ${true}
`('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential });
......@@ -551,8 +556,8 @@ describe('Issuable output', () => {
it.each`
title | isLocked
${'does not show locked badge when issue is not locked'} | ${true}
${'shows locked badge when issue is locked'} | ${false}
${'does not show locked badge when issue is not locked'} | ${false}
${'shows locked badge when issue is locked'} | ${true}
`('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked });
......@@ -560,6 +565,27 @@ describe('Issuable output', () => {
expect(findLockedBadge().exists()).toBe(isLocked);
});
it.each`
title | isHidden
${'does not show hidden badge when issue is not hidden'} | ${false}
${'shows hidden badge when issue is hidden'} | ${true}
`('$title', async ({ isHidden }) => {
wrapper.setProps({ isHidden });
await nextTick();
const hiddenBadge = findHiddenBadge();
expect(hiddenBadge.exists()).toBe(isHidden);
if (isHidden) {
expect(hiddenBadge.attributes('title')).toBe(
'This issue is hidden because its author has been banned',
);
expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined();
}
});
});
});
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createStore as createMrStore } from '~/mr_notes/stores';
import createIssueStore from '~/notes/stores';
import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue';
......@@ -12,52 +14,53 @@ localVue.use(Vuex);
describe('IssuableHeaderWarnings', () => {
let wrapper;
let store;
const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]');
const findLockedIcon = () => wrapper.find('[data-testid="locked"]');
const findConfidentialIcon = () => wrapper.findByTestId('confidential');
const findLockedIcon = () => wrapper.findByTestId('locked');
const findHiddenIcon = () => wrapper.findByTestId('hidden');
const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render');
const setLock = (locked) => {
store.getters.getNoteableData.discussion_locked = locked;
};
const setConfidential = (confidential) => {
store.getters.getNoteableData.confidential = confidential;
};
const createComponent = () => {
wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue });
const createComponent = ({ store, provide }) => {
wrapper = shallowMountExtended(IssuableHeaderWarnings, {
store,
localVue,
provide,
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
});
describe.each`
issuableType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
`(`when issuableType=$issuableType`, ({ issuableType }) => {
beforeEach(() => {
store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
createComponent();
});
describe.each`
lockStatus | confidentialStatus
${true} | ${true}
${true} | ${false}
${false} | ${true}
${false} | ${false}
lockStatus | confidentialStatus | hiddenStatus
${true} | ${true} | ${false}
${true} | ${false} | ${false}
${false} | ${true} | ${false}
${false} | ${false} | ${false}
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${true}
`(
`when locked=$lockStatus and confidential=$confidentialStatus`,
({ lockStatus, confidentialStatus }) => {
`when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`,
({ lockStatus, confidentialStatus, hiddenStatus }) => {
const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
beforeEach(() => {
setLock(lockStatus);
setConfidential(confidentialStatus);
store.getters.getNoteableData.confidential = confidentialStatus;
store.getters.getNoteableData.discussion_locked = lockStatus;
createComponent({ store, provide: { hidden: hiddenStatus } });
});
it(`${renderTestMessage(lockStatus)} the locked icon`, () => {
......@@ -67,6 +70,19 @@ describe('IssuableHeaderWarnings', () => {
it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => {
expect(findConfidentialIcon().exists()).toBe(confidentialStatus);
});
it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => {
const hiddenIcon = findHiddenIcon();
expect(hiddenIcon.exists()).toBe(hiddenStatus);
if (hiddenStatus) {
expect(hiddenIcon.attributes('title')).toBe(
'This issue is hidden because its author has been banned',
);
expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined();
}
});
},
);
});
......
......@@ -285,7 +285,8 @@ RSpec.describe IssuablesHelper do
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue',
iid: issue.iid.to_s
iid: issue.iid.to_s,
isHidden: false
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end
......
......@@ -410,4 +410,55 @@ RSpec.describe IssuesHelper do
end
end
end
describe '#issue_hidden?' do
context 'when issue is hidden' do
let_it_be(:banned_user) { build(:user, :banned) }
let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
context 'when `ban_user_feature_flag` feature flag is enabled' do
it 'returns `true`' do
expect(helper.issue_hidden?(hidden_issue)).to eq(true)
end
end
context 'when `ban_user_feature_flag` feature flag is disabled' do
before do
stub_feature_flags(ban_user_feature_flag: false)
end
it 'returns `false`' do
expect(helper.issue_hidden?(hidden_issue)).to eq(false)
end
end
end
context 'when issue is not hidden' do
it 'returns `false`' do
expect(helper.issue_hidden?(issue)).to eq(false)
end
end
end
describe '#hidden_issue_icon' do
let_it_be(:banned_user) { build(:user, :banned) }
let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
let_it_be(:mock_svg) { '<svg></svg>'.html_safe }
before do
allow(helper).to receive(:sprite_icon).and_return(mock_svg)
end
context 'when issue is hidden' do
it 'returns icon with tooltip' do
expect(helper.hidden_issue_icon(hidden_issue)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>")
end
end
context 'when issue is not hidden' do
it 'returns `nil`' do
expect(helper.hidden_issue_icon(issue)).to be_nil
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