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