Repository: zulip/zulip
Pull request: org settings: Add UI for managing user groups.
Fixes #165.

# To-Do

- [x] Add framework for page
- [x] Add content and design to page
- [x] Render current user groups data dynamically
- [x] Add user group creation functionality (initialized with current user inside)
- [x] Add user group deletion functionality
- [x] Add save user group state functionality to group members
- [x] Add save user group state functionality to group data (name + description)
- [x] Add edit user group members functionality
- [x] Add edit user group data functionality
- [x] Add compose typeahead
- [x] Add Node tests for `user_groups.js`
- [x] Add placeholder for empty input pills
- [ ] Add Node tests for `settings_user_groups.js`

# Updates
## 01/05/18
* Updated UI with suggestions from Aditya
* Fixed minor bugs (empty name OR description showing save button, duplicate user groups after editing group data, edit members button on new line)
* Added i18n for text strings
* Added placeholders for name, description, and empty input pills

![screenshot at jan 05 12-00-49](https://user-images.githubusercontent.com/15116870/34626068-18868284-f210-11e7-8d12-b284643a38fe.png)


## 01/03/18
* Updated UI with suggestions from Brock
![](https://chat.zulip.org/user_uploads/2/cd/8UEUqc4kOhtR3d6d1UyPyaF-/Screenshot-at-Jan-03-10-08-37.png)

## 12/29/17
Various UI updates from feedback (tests failing due to Casper flake)

[![https://gyazo.com/53573303b8bf6ccea748a4889d0ef5fa](https://i.gyazo.com/53573303b8bf6ccea748a4889d0ef5fa.gif)](https://gyazo.com/53573303b8bf6ccea748a4889d0ef5fa)

* Name and description have borders around them (and bolded outlines when focused)
* Delete icon moved to name and description line
* Prevent save icon from showing while name or description fields are empty
* Prevent enter key from triggering line breaks
* Duplicate edited groups bug should be fixed

## 12/19/17
* Fully covered `user_groups.js`
* Added typeahead to user group member inputs
[![https://gyazo.com/8e9d8678deacb4cd3a779580f0f965a1](https://i.gyazo.com/8e9d8678deacb4cd3a779580f0f965a1.gif)](https://gyazo.com/8e9d8678deacb4cd3a779580f0f965a1)

## 12/18/17
* Add functionality for changing group name/description
[![https://gyazo.com/aeed3e7abd3e293eddc6eab58988168c](https://i.gyazo.com/aeed3e7abd3e293eddc6eab58988168c.gif)](https://gyazo.com/aeed3e7abd3e293eddc6eab58988168c)
* Add save group data state functionality; button only appears if group data is changed
* Add functionality for adding/removing users from groups
* Add save group members state functionality; button only appears if group members are changed (button is hidden if there are no users in the group)
[![https://gyazo.com/c29841abd27294a44fed962e1c91de1f](https://i.gyazo.com/c29841abd27294a44fed962e1c91de1f.gif)](https://gyazo.com/c29841abd27294a44fed962e1c91de1f)
* Reject invalid inputs to prevent invalid pills from being created
* Modify inputs to only accept user emails, not full names (non-unique)

## 12/11/17
* Initialize created user groups with current user already inserted
* Various UI updates

## 12/06/17 
* Added group deletion functionality
* Added UI for deleting groups
![screenshot at dec 06 11-02-33](https://user-images.githubusercontent.com/15116870/33680002-1fe359ec-da75-11e7-97e5-ed7893e38a5c.png)
* Implemented user group creation
* Updated page styling to include group name and description
![screenshot at dec 06 09-37-36](https://user-images.githubusercontent.com/15116870/33675987-4d28790c-da69-11e7-81a0-4bba96270a18.png)


## 11/16/17
* Added page content with design suggestions from Brock
* User group data is now dynamically rendered
![screenshot at nov 16 21-23-33](https://user-images.githubusercontent.com/15116870/32930582-6cb243f2-cb14-11e7-8302-949cd3cc0c03.png)
* Initial commit: Add framework for User Groups page
  
  
  

Problem statement:
Add user-definable groups
[summarizing a Zulip discussion]

Add a way for users (or, at the very least, administrators) to define their own groups, such that someone can write `@group1` to notify everyone in `group1`.

Design complication: what should happen when `group1` is (say) 100 members, but someone says `@group1` in a stream to which only 30 of those members are subscribed?  Should the sender get a prompt asking whether the other 70 people should be subscribed to the stream?

Hints from issue discussion:
why not use a stream instead? whoever subscribed to it is in the group...

@ritschwumm It could be helpful to tag a specific group in a discussion that's open to a wider audience. For example, you might want to tag the support team in a discussion among all company employees.

As a workaround, you can simulate that today using the "alert words" feature -- just have all the members of a team all add `@group1` to their list of alert words.  

Hello @umairwaheed, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!
I think from the point of view of the backend, we need to finish https://github.com/zulip/zulip/issues/7381. On the frontend side, we need to create the UI to manage groups.
@synicalsyntax is working on the UI, so if you can finish #7381 we should be in great shape on this :)
Sure, will do.
Hello @umairwaheed, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->
My work on this is done. I think @synicalsyntax is now working on the frontend. @zulipbot abandon.

Commit message: org settings: Add user group deletion functionality.

Patch to review:
diff --git a/.eslintrc.json b/.eslintrc.json
index 182309f2fa5f2..31a3542bf707f 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -56,6 +56,7 @@
         "settings_streams": false,
         "settings_filters": false,
         "settings_invites": false,
+        "settings_user_groups": false,
         "settings": false,
         "resize": false,
         "loading": false,
diff --git a/frontend_tests/node_tests/templates.js b/frontend_tests/node_tests/templates.js
index 1150e71df1993..61fc5cac37a2e 100644
--- a/frontend_tests/node_tests/templates.js
+++ b/frontend_tests/node_tests/templates.js
@@ -283,6 +283,31 @@ function render(template_name, args) {
     global.write_handlebars_output("admin_tab", html);
 }());
 
+(function admin_user_group_list() {
+    var args = {
+        user_group: {
+            id: "9",
+            name: "uranohoshi",
+            description: "Students at Uranohoshi Academy",
+        },
+    };
+
+    var html = '';
+    html += '<div id="user-groups">';
+    html += render('admin_user_group_list', args);
+    html += '</div>';
+
+    global.write_handlebars_output('admin_user_group_list', html);
+
+    var group_id = $(html).find('.user-group:first').prop('id');
+    var group_name = $(html).find('.user-group:first .pill-container').attr('data-group-pills');
+    var group_description = $(html).find('.user-group:first h4').text();
+
+    assert.equal(group_id, '9');
+    assert.equal(group_name, 'uranohoshi');
+    assert.equal(group_description, 'uranohoshi — Students at Uranohoshi Academy');
+}());
+
 (function admin_user_list() {
     var html = '<table>';
     var users = ['alice', 'bob', 'carl'];
diff --git a/static/js/admin_sections.js b/static/js/admin_sections.js
index 856e627a58e72..240e332682ed7 100644
--- a/static/js/admin_sections.js
+++ b/static/js/admin_sections.js
@@ -32,6 +32,9 @@ exports.load_admin_section = function (name) {
         case 'invites-list-admin':
             section = 'invites';
             break;
+        case 'user-groups-admin':
+            section = 'user-groups';
+            break;
         default:
             blueslip.error('Unknown admin id ' + name);
             return;
@@ -62,6 +65,9 @@ exports.load_admin_section = function (name) {
         case 'invites':
             settings_invites.set_up();
             break;
+        case 'user-groups':
+            settings_user_groups.set_up();
+            break;
         default:
             blueslip.error('programming error for section ' + section);
             return;
@@ -78,6 +84,7 @@ exports.reset_sections = function () {
     settings_streams.reset();
     settings_filters.reset();
     settings_invites.reset();
+    settings_user_groups.reset();
 };
 
 return exports;
diff --git a/static/js/server_events_dispatch.js b/static/js/server_events_dispatch.js
index 779a82b01b36f..332743cd316de 100644
--- a/static/js/server_events_dispatch.js
+++ b/static/js/server_events_dispatch.js
@@ -359,6 +359,13 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
         ui.remove_message(msg_id);
         break;
 
+    case 'user_group':
+        if (event.op === 'add') {
+            user_groups.add(event.group);
+            settings_user_groups.reload();
+        }
+        break;
+
     }
 };
 
diff --git a/static/js/settings.js b/static/js/settings.js
index a42143d216fee..02dd8c46d7935 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -84,6 +84,7 @@ function _setup_page() {
             "default-streams-list": i18n.t("Default streams"),
             "filter-settings": i18n.t("Filter settings"),
             "invites-list-admin": i18n.t("Invitations"),
+            "user-groups-admin": i18n.t("User groups"),
         };
     }
 
diff --git a/static/js/settings_user_groups.js b/static/js/settings_user_groups.js
new file mode 100644
index 0000000000000..a02690e0b6613
--- /dev/null
+++ b/static/js/settings_user_groups.js
@@ -0,0 +1,115 @@
+var settings_user_groups = (function () {
+
+var exports = {};
+
+var meta = {
+    loaded: false,
+};
+
+exports.reset = function () {
+    meta.loaded = false;
+};
+
+exports.reload = function () {
+    var user_groups_section = $('#user-groups').expectOne();
+    user_groups_section.html('');
+    exports.populate_user_groups();
+};
+
+exports.populate_user_groups = function () {
+    if (!meta.loaded) {
+        return;
+    }
+
+    var user_groups_section = $('#user-groups').expectOne();
+    var user_groups_array = user_groups.get_realm_user_groups();
+
+    _.each(user_groups_array, function (data) {
+        user_groups_section.append(templates.render('admin_user_group_list', {
+            user_group: {
+                name: data.name,
+                id: data.id,
+                description: data.description,
+            },
+        }));
+
+        var pill_container = $('.pill-container[data-group-pills="' + data.name + '"]');
+        var pills = input_pill(pill_container);
+        data.members.forEach(function (user_id) {
+            var user = people.get_person_from_user_id(user_id);
+
+            if (user) {
+                pills.pill.append(user.full_name, user_id);
+            } else {
+                blueslip.warn('Unknown user ID ' + user_id + ' in members of user group ' + data.name);
+            }
+        });
+    });
+
+};
+
+exports.set_up = function () {
+    meta.loaded = true;
+
+    exports.populate_user_groups();
+
+    $(".organization").on("submit", "form.admin-user-group-form", function (e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        var user_group_status = $('#admin-user-group-status');
+
+        var group = {
+            members: JSON.stringify([people.my_current_user_id()]),
+        };
+        _.each($(this).serializeArray(), function (obj) {
+            if (obj.value.trim() === "") {
+                return;
+            }
+            group[obj.name] = obj.value;
+        });
+
+        channel.post({
+            url: "/json/user_groups/create",
+            data: group,
+            success: function () {
+                user_group_status.hide();
+                ui_report.success(i18n.t("User group added!"), user_group_status);
+                $("form.admin-user-group-form input[type='text']").val("");
+            },
+            error: function (xhr) {
+                user_group_status.hide();
+                var errors = JSON.parse(xhr.responseText).msg;
+                xhr.responseText = JSON.stringify({msg: errors});
+                ui_report.error(i18n.t("Failed"), xhr, user_group_status);
+            },
+        });
+    });
+
+    $('#user-groups').on('click', '.delete', function () {
+        var group_id = $(this).parent().attr('id');
+        var user_group = user_groups.get_user_group_from_id(group_id);
+        var btn = $(this);
+
+        channel.del({
+            url: "/json/user_groups/" + group_id,
+            data: {
+                id: group_id,
+            },
+            success: function () {
+                user_groups.remove(user_group);
+                settings_user_groups.reload();
+            },
+            error: function () {
+                btn.text(i18n.t("Failed!"));
+            },
+        });
+    });
+};
+
+return exports;
+}());
+
+if (typeof module !== 'undefined') {
+    module.exports = settings_user_groups;
+}
diff --git a/static/js/user_groups.js b/static/js/user_groups.js
index e23c1bbc2f994..21d762300cc41 100644
--- a/static/js/user_groups.js
+++ b/static/js/user_groups.js
@@ -15,11 +15,16 @@ exports.init = function () {
 // WE INITIALIZE DATA STRUCTURES HERE!
 exports.init();
 
-exports.add = function add(user_group) {
+exports.add = function (user_group) {
     user_group_name_dict.set(user_group.name, user_group);
     user_group_by_id_dict.set(user_group.id, user_group);
 };
 
+exports.remove = function (user_group) {
+    user_group_name_dict.del(user_group.name);
+    user_group_by_id_dict.del(user_group.id);
+};
+
 exports.get_user_group_from_id = function (group_id) {
     if (!user_group_by_id_dict.has(group_id)) {
         blueslip.error('Unknown group_id in get_user_group_from_id: ' + group_id);
diff --git a/static/styles/settings.css b/static/styles/settings.css
index 523f581a89ce0..ca1f28788e54a 100644
--- a/static/styles/settings.css
+++ b/static/styles/settings.css
@@ -477,15 +477,18 @@ input[type=checkbox].inline-block {
     max-width: 100%;
 }
 
-.add-new-emoji-box {
+.add-new-emoji-box,
+.add-new-user-group-box {
     margin-bottom: 20px;
 }
 
-.add-new-emoji-box .new-emoji-form {
+.add-new-emoji-box .new-emoji-form,
+.add-new-user-group-box .new-user-group-form {
     margin: 10px 0px;
 }
 
 .add-new-emoji-box input[type=text],
+.add-new-user-group-box input[type=text],
 .add-new-default-stream-box input[type=text] {
     padding: 6px;
 }
@@ -820,6 +823,10 @@ input[type=checkbox].inline-block {
     margin-right: 5px;
 }
 
+#user-groups .user-group h4 > span {
+    font-weight: initial;
+}
+
 /* -- new settings overlay -- */
 #settings_page {
     height: 95vh;
diff --git a/static/templates/admin_tab.handlebars b/static/templates/admin_tab.handlebars
index ae2ce2a4b9b7c..8d3274f4cfa78 100644
--- a/static/templates/admin_tab.handlebars
+++ b/static/templates/admin_tab.handlebars
@@ -27,3 +27,5 @@
 {{ partial "realm-filter-settings-admin" }}
 
 {{ partial "invites-list-admin" }}
+
+{{ partial "user-groups-admin" }}
diff --git a/static/templates/admin_user_group_list.handlebars b/static/templates/admin_user_group_list.handlebars
new file mode 100644
index 0000000000000..0671f08335f52
--- /dev/null
+++ b/static/templates/admin_user_group_list.handlebars
@@ -0,0 +1,11 @@
+{{#with user_group}}
+<div class="user-group" id="{{id}}">
+    <h4>{{name}}<span> — {{description}}</span></h4>
+    <div class="pill-container" data-group-pills="{{name}}">
+        <div class="input" contenteditable="true"></div>
+    </div>
+    <button class="button rounded small delete btn-danger">
+        <i class="icon-vector-trash" aria-hidden="true"></i>
+    </button>
+</div>
+{{/with}}
diff --git a/static/templates/user-groups-admin.handlebars b/static/templates/user-groups-admin.handlebars
new file mode 100644
index 0000000000000..670cc7b22c8f4
--- /dev/null
+++ b/static/templates/user-groups-admin.handlebars
@@ -0,0 +1,25 @@
+<div id="user-groups-admin" class="settings-section" data-name="user-groups-admin">
+    <div class="tip">Anyone in this organization can add user groups.</div>
+    <form class="form-horizontal admin-user-group-form">
+        <div class="add-new-user-group-box grey-box">
+            <div class="new-user-group-form">
+                <div class="settings-section-title new-user-group-section-title no-padding">{{t "Add a new user group" }}</div>
+                <div class="alert" id="admin-user-group-status"></div>
+                <div class="inline-block">
+                    <label for="user_group_name">{{t "Name" }}</label>
+                    <input type="text" name="name" id="user_group_name" placeholder="hamletcharacters" />
+                </div>
+                <div class="inline-block">
+                    <label for="user_group_description">{{t "Description" }}</label>
+                    <input type="text" name="description" id="user_group_description" placeholder="{{t 'Characters of Hamlet' }}" />
+                </div>
+                <button type="submit" class="button rounded sea-green">
+                    {{t 'Submit' }}
+                </button>
+            </div>
+        </div>
+    </form>
+
+    <p>{{#tr this}}Add members of your organization to mentionable user groups.{{/tr}}</p>
+    <div id="user-groups"></div>
+</div>
diff --git a/templates/zerver/settings_overlay.html b/templates/zerver/settings_overlay.html
index 7bff54ad03d2b..b2758b3d94843 100644
--- a/templates/zerver/settings_overlay.html
+++ b/templates/zerver/settings_overlay.html
@@ -60,6 +60,10 @@ <h1>{{ _('Settings') }}<span class="section"></span></h1>
                     <i class="icon icon-vector-smile"></i>
                     <div class="text">{{ _('Custom emoji') }}</div>
                 </li>
+                <li class="admin" tabindex="0" data-section="user-groups-admin">
+                    <i class="icon icon-vector-group"></i>
+                    <div class="text">{{ _('User groups') }}</div>
+                </li>
                 <li class="admin" tabindex="0" data-section="auth-methods">
                     <i class="icon icon-vector-lock"></i>
                     <div class="text">{{ _('Authentication methods') }}</div>
diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py
index fb38fab3438fd..d4aee800923da 100644
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -20,6 +20,7 @@
     r"Dropbox",
     r"GitHub",
     r"Google",
+    r"Hamlet",
     r"HTTP",
     r"ID",
     r"IDs",
diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py
index 1cdd6e1cb1f1b..88212db794c88 100644
--- a/tools/linter_lib/custom_check.py
+++ b/tools/linter_lib/custom_check.py
@@ -503,7 +503,9 @@ def build_custom_checkers(by_lang):
          'exclude_line': [('templates/zerver/register.html', 'placeholder="acme"'),
                           ('templates/zerver/register.html', 'placeholder="Acme or Aκμή"'),
                           ('static/templates/settings/realm-domains-modal.handlebars',
-                           '<td><input type="text" class="new-realm-domain" placeholder="acme.com"></input></td>')],
+                           '<td><input type="text" class="new-realm-domain" placeholder="acme.com"></input></td>'),
+                          ("static/templates/user-groups-admin.handlebars",
+                           '<input type="text" name="name" id="user_group_name" placeholder="hamletcharacters" />')],
          'exclude': set(["static/templates/settings/emoji-settings-admin.handlebars",
                          "static/templates/settings/realm-filter-settings-admin.handlebars",
                          "static/templates/settings/bot-settings.handlebars",
diff --git a/zproject/settings.py b/zproject/settings.py
index 87167742d0603..612fa7b75260f 100644
--- a/zproject/settings.py
+++ b/zproject/settings.py
@@ -1079,6 +1079,7 @@ def get_secret(key: str) -> None:
             'js/settings_streams.js',
             'js/settings_filters.js',
             'js/settings_invites.js',
+            'js/settings_user_groups.js',
             'js/settings.js',
             'js/admin_sections.js',
             'js/admin.js',


Code review comments:
diff_hunk: @@ -4,5 +4,8 @@
     <div class="pill-container" data-group-pills="{{name}}">
         <div class="input" contenteditable="true"></div>
     </div>
+    <button class="button rounded small delete btn-danger">
+        <i class="icon-vector-trash" aria-hidden="true"></i>
path: static/templates/admin_user_group_list.handlebars
text: @user1:
Can you please change the `class="icon-vector-trash"` to `class="fa fa-trash"`.