Commit 0558a809 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'feat/group-mentionings-prevention' into 'master'

Implement disable group mentions for members

Closes #21301

See merge request gitlab-org/gitlab!20184
parents 32d804d3 9fb99aef
...@@ -3,11 +3,44 @@ import 'at.js'; ...@@ -3,11 +3,44 @@ import 'at.js';
import _ from 'underscore'; import _ from 'underscore';
import glRegexp from './lib/utils/regexp'; import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache'; import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
function sanitize(str) { function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, ''); return str.replace(/<(?:.|\n)*?>/gm, '');
} }
export function membersBeforeSave(members) {
return _.map(members, member => {
const GROUP_TYPE = 'Group';
let title = '';
if (member.username == null) {
return member;
}
title = member.name;
if (member.count && !member.mentionsDisabled) {
title += ` (${member.count})`;
}
const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase();
const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
const avatarIcon = member.mentionsDisabled
? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5')
: '';
return {
username: member.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
search: sanitize(`${member.username} ${member.name}`),
icon: avatarIcon,
};
});
}
export const defaultAutocompleteConfig = { export const defaultAutocompleteConfig = {
emojis: true, emojis: true,
members: true, members: true,
...@@ -167,12 +200,13 @@ class GfmAutoComplete { ...@@ -167,12 +200,13 @@ class GfmAutoComplete {
alias: 'users', alias: 'users',
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title } = value; const { avatarTag, username, title, icon } = value;
if (username != null) { if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({ tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag, avatarTag,
username, username,
title, title,
icon,
}); });
} }
return tmpl; return tmpl;
...@@ -185,33 +219,7 @@ class GfmAutoComplete { ...@@ -185,33 +219,7 @@ class GfmAutoComplete {
data: GfmAutoComplete.defaultLoadingData, data: GfmAutoComplete.defaultLoadingData,
callbacks: { callbacks: {
...this.getDefaultCallbacks(), ...this.getDefaultCallbacks(),
beforeSave(members) { beforeSave: membersBeforeSave,
return $.map(members, m => {
let title = '';
if (m.username == null) {
return m;
}
title = m.name;
if (m.count) {
title += ` (${m.count})`;
}
const GROUP_TYPE = 'Group';
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
return {
username: m.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
search: sanitize(`${m.username} ${m.name}`),
};
});
},
}, },
}); });
} }
...@@ -624,8 +632,8 @@ GfmAutoComplete.Emoji = { ...@@ -624,8 +632,8 @@ GfmAutoComplete.Emoji = {
}; };
// Team Members // Team Members
GfmAutoComplete.Members = { GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title }) { templateFunction({ avatarTag, username, title, icon }) {
return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small></li>`; return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`;
}, },
}; };
GfmAutoComplete.Labels = { GfmAutoComplete.Labels = {
......
...@@ -181,6 +181,7 @@ class GroupsController < Groups::ApplicationController ...@@ -181,6 +181,7 @@ class GroupsController < Groups::ApplicationController
:avatar, :avatar,
:description, :description,
:emails_disabled, :emails_disabled,
:mentions_disabled,
:lfs_enabled, :lfs_enabled,
:name, :name,
:path, :path,
......
...@@ -55,7 +55,8 @@ module Users ...@@ -55,7 +55,8 @@ module Users
username: group.full_path, username: group.full_path,
name: group.full_name, name: group.full_name,
avatar_url: group.avatar_url, avatar_url: group.avatar_url,
count: group_counts.fetch(group.id, 0) count: group_counts.fetch(group.id, 0),
mentionsDisabled: group.mentions_disabled
} }
end end
end end
......
...@@ -23,6 +23,13 @@ ...@@ -23,6 +23,13 @@
%span.d-block= s_('GroupSettings|Disable email notifications') %span.d-block= s_('GroupSettings|Disable email notifications')
%span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') %span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
.form-group.append-bottom-default
.form-check
= f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
= f.label :mentions_disabled, class: 'form-check-label' do
%span.d-block= s_('GroupSettings|Disable group mentions')
%span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
= render 'groups/settings/lfs', f: f = render 'groups/settings/lfs', f: f
......
---
title: Allow groups to disable mentioning their members, if the group is mentioned
merge_request: 20184
author: Fabio Huser
type: added
# frozen_string_literal: true
class AddMentionsDisabledToNamespaces < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :namespaces, :mentions_disabled, :boolean
end
end
...@@ -349,6 +349,7 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do ...@@ -349,6 +349,7 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do
t.boolean "sourcegraph_enabled", default: false, null: false t.boolean "sourcegraph_enabled", default: false, null: false
t.string "sourcegraph_url", limit: 255 t.string "sourcegraph_url", limit: 255
t.boolean "sourcegraph_public_only", default: true, null: false t.boolean "sourcegraph_public_only", default: true, null: false
t.bigint "snippet_size_limit", default: 52428800, null: false
t.text "encrypted_akismet_api_key" t.text "encrypted_akismet_api_key"
t.string "encrypted_akismet_api_key_iv", limit: 255 t.string "encrypted_akismet_api_key_iv", limit: 255
t.text "encrypted_elasticsearch_aws_secret_access_key" t.text "encrypted_elasticsearch_aws_secret_access_key"
...@@ -361,7 +362,6 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do ...@@ -361,7 +362,6 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do
t.string "encrypted_slack_app_secret_iv", limit: 255 t.string "encrypted_slack_app_secret_iv", limit: 255
t.text "encrypted_slack_app_verification_token" t.text "encrypted_slack_app_verification_token"
t.string "encrypted_slack_app_verification_token_iv", limit: 255 t.string "encrypted_slack_app_verification_token_iv", limit: 255
t.bigint "snippet_size_limit", default: 52428800, null: false
t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id" t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id"
t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id" t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id"
t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id" t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id"
...@@ -2603,6 +2603,7 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do ...@@ -2603,6 +2603,7 @@ ActiveRecord::Schema.define(version: 2019_11_25_140458) do
t.boolean "emails_disabled" t.boolean "emails_disabled"
t.integer "max_pages_size" t.integer "max_pages_size"
t.integer "max_artifacts_size" t.integer "max_artifacts_size"
t.boolean "mentions_disabled"
t.index ["created_at"], name: "index_namespaces_on_created_at" t.index ["created_at"], name: "index_namespaces_on_created_at"
t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)" t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)"
t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id" t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id"
......
...@@ -431,6 +431,23 @@ To enable this feature: ...@@ -431,6 +431,23 @@ To enable this feature:
1. Expand the **Permissions, LFS, 2FA** section, and select **Disable email notifications**. 1. Expand the **Permissions, LFS, 2FA** section, and select **Disable email notifications**.
1. Click **Save changes**. 1. Click **Save changes**.
#### Disabling group mentions
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/21301) in GitLab 12.6.
You can prevent users from being added to a conversation and getting notified when
anyone mentions a group in which those users are members.
Groups with disabled mentions are visualized accordingly in the autocompletion dropdown.
This is particularly helpful for groups with a large number of users.
To enable this feature:
1. Navigate to the group's **Settings > General** page.
1. Expand the **Permissions, LFS, 2FA** section, and select **Disable group mentions**.
1. Click **Save changes**.
### Advanced settings ### Advanced settings
- **Projects**: View all projects within that group, add members to each project, - **Projects**: View all projects within that group, add members to each project,
......
...@@ -97,7 +97,9 @@ module Banzai ...@@ -97,7 +97,9 @@ module Banzai
def find_users_for_groups(ids) def find_users_for_groups(ids)
return [] if ids.empty? return [] if ids.empty?
User.joins(:group_members).where(members: { source_id: ids }).to_a User.joins(:group_members).where(members: {
source_id: Namespace.where(id: ids).where('mentions_disabled IS NOT TRUE').select(:id)
}).to_a
end end
def find_users_for_projects(ids) def find_users_for_projects(ids)
......
...@@ -8863,6 +8863,9 @@ msgstr "" ...@@ -8863,6 +8863,9 @@ msgstr ""
msgid "GroupSettings|Disable email notifications" msgid "GroupSettings|Disable email notifications"
msgstr "" msgstr ""
msgid "GroupSettings|Disable group mentions"
msgstr ""
msgid "GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility." msgid "GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility."
msgstr "" msgstr ""
...@@ -8911,6 +8914,9 @@ msgstr "" ...@@ -8911,6 +8914,9 @@ msgstr ""
msgid "GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects." msgid "GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects."
msgstr "" msgstr ""
msgid "GroupSettings|This setting will prevent group members from being notified if the group is mentioned."
msgstr ""
msgid "GroupSettings|Transfer group" msgid "GroupSettings|Transfer group"
msgstr "" msgstr ""
......
/* eslint no-param-reassign: "off" */ /* eslint no-param-reassign: "off" */
import $ from 'jquery'; import $ from 'jquery';
import { membersBeforeSave } from '~/gfm_auto_complete';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import 'jquery.caret'; import 'jquery.caret';
...@@ -262,6 +263,79 @@ describe('GfmAutoComplete', () => { ...@@ -262,6 +263,79 @@ describe('GfmAutoComplete', () => {
}); });
}); });
describe('membersBeforeSave', () => {
const mockGroup = {
username: 'my-group',
name: 'My Group',
count: 2,
avatar_url: './group.jpg',
type: 'Group',
mentionsDisabled: false,
};
it('should return the original object when username is null', () => {
expect(membersBeforeSave([{ ...mockGroup, username: null }])).toEqual([
{ ...mockGroup, username: null },
]);
});
it('should set the text avatar if avatar_url is null', () => {
expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([
{
username: 'my-group',
avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>',
title: 'My Group (2)',
search: 'my-group My Group',
icon: '',
},
]);
});
it('should set the image avatar if avatar_url is given', () => {
expect(membersBeforeSave([mockGroup])).toEqual([
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
title: 'My Group (2)',
search: 'my-group My Group',
icon: '',
},
]);
});
it('should set mentions disabled icon if mentionsDisabled is set', () => {
expect(membersBeforeSave([{ ...mockGroup, mentionsDisabled: true }])).toEqual([
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
title: 'My Group',
search: 'my-group My Group',
icon:
'<svg class="s16 vertical-align-middle prepend-left-5"><use xlink:href="undefined#notifications-off" /></svg>',
},
]);
});
it('should set the right image classes for User type members', () => {
expect(
membersBeforeSave([
{ username: 'my-user', name: 'My User', avatar_url: './users.jpg', type: 'User' },
]),
).toEqual([
{
username: 'my-user',
avatarTag:
'<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>',
title: 'My User',
search: 'my-user My User',
icon: '',
},
]);
});
});
describe('Issues.insertTemplateFunction', () => { describe('Issues.insertTemplateFunction', () => {
it('should return default template', () => { it('should return default template', () => {
expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe( expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe(
...@@ -298,6 +372,41 @@ describe('GfmAutoComplete', () => { ...@@ -298,6 +372,41 @@ describe('GfmAutoComplete', () => {
}); });
}); });
describe('Members.templateFunction', () => {
it('should return html with avatarTag and username', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
it('should add icon if icon is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
}),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
});
it('should add escaped title if title is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
}),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
});
});
describe('labels', () => { describe('labels', () => {
const dataSources = { const dataSources = {
labels: `${TEST_HOST}/autocomplete_sources/labels`, labels: `${TEST_HOST}/autocomplete_sources/labels`,
......
...@@ -19,15 +19,23 @@ describe Banzai::ReferenceParser::UserParser do ...@@ -19,15 +19,23 @@ describe Banzai::ReferenceParser::UserParser do
link['data-group'] = project.group.id.to_s link['data-group'] = project.group.id.to_s
end end
it 'returns the users of the group' do
create(:group_member, group: group, user: user)
expect(subject.referenced_by([link])).to eq([user])
end
it 'returns an empty Array when the group has no users' do it 'returns an empty Array when the group has no users' do
expect(subject.referenced_by([link])).to eq([]) expect(subject.referenced_by([link])).to eq([])
end end
context 'when group has members' do
let!(:group_member) { create(:group_member, group: group, user: user) }
it 'returns the users of the group' do
expect(subject.referenced_by([link])).to eq([user])
end
it 'returns an empty Array when the group has mentions disabled' do
group.update!(mentions_disabled: true)
expect(subject.referenced_by([link])).to eq([])
end
end
end end
context 'using a non-existing group ID' do context 'using a non-existing group ID' do
......
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