diff --git a/static/js/hash_util.js b/static/js/hash_util.js index 333a40a524..4bebf5a98c 100644 --- a/static/js/hash_util.js +++ b/static/js/hash_util.js @@ -234,6 +234,24 @@ export function is_editing_stream(desired_stream_id) { return stream_id === desired_stream_id; } +export function is_editing_group(desired_group_id) { + const hash_components = window.location.hash.slice(1).split(/\//); + + if (hash_components[0] !== "groups") { + return false; + } + + if (!hash_components[2]) { + return false; + } + + // if the string casted to a number is valid, and another component + // after exists then it's a stream name/id pair. + const group_id = Number.parseFloat(hash_components[1]); + + return group_id === desired_group_id; +} + export function is_create_new_stream_narrow() { return window.location.hash === "#streams/new"; } diff --git a/static/js/stream_edit_subscribers.js b/static/js/stream_edit_subscribers.js index 8f2c790d48..8156efd31a 100644 --- a/static/js/stream_edit_subscribers.js +++ b/static/js/stream_edit_subscribers.js @@ -29,7 +29,7 @@ function format_member_list_elem(person) { user_id: person.user_id, is_current_user: person.user_id === page_params.user_id, email: settings_data.email_for_user_settings(person), - displaying_for_admin: page_params.is_admin, + can_edit_subscribers: page_params.is_admin, show_email: settings_data.show_email(), }); } @@ -243,7 +243,11 @@ function remove_subscriber({stream_id, target_user_id, $list_entry}) { } if (sub.invite_only && people.is_my_user_id(target_user_id)) { - const html_body = render_unsubscribe_private_stream_modal(); + const html_body = render_unsubscribe_private_stream_modal({ + message: $t({ + defaultMessage: "Once you leave this stream, you will not be able to rejoin.", + }), + }); confirm_dialog.launch({ html_heading: $t_html( diff --git a/static/js/ui_init.js b/static/js/ui_init.js index 3c8cdea671..2021422078 100644 --- a/static/js/ui_init.js +++ b/static/js/ui_init.js @@ -99,6 +99,7 @@ import * as ui from "./ui"; import * as unread from "./unread"; import * as unread_ui from "./unread_ui"; import * as user_group_edit from "./user_group_edit"; +import * as user_group_edit_members from "./user_group_edit_members"; import * as user_groups from "./user_groups"; import * as user_group_settings_ui from "./user_groups_settings_ui"; import {initialize_user_settings, user_settings} from "./user_settings"; @@ -622,6 +623,7 @@ export function initialize_everything() { user_group_edit.initialize(); stream_edit_subscribers.initialize(); stream_data.initialize(stream_data_params); + user_group_edit_members.initialize(); pm_conversations.recent.initialize(pm_conversations_params); user_topics.initialize(); muted_users.initialize(); diff --git a/static/js/user_group_edit.js b/static/js/user_group_edit.js index 5e8e13a148..ca812b2f0d 100644 --- a/static/js/user_group_edit.js +++ b/static/js/user_group_edit.js @@ -15,6 +15,7 @@ import * as people from "./people"; import * as settings_data from "./settings_data"; import * as settings_ui from "./settings_ui"; import * as ui from "./ui"; +import * as user_group_edit_members from "./user_group_edit_members"; import * as user_group_ui_updates from "./user_group_ui_updates"; import * as user_groups from "./user_groups"; import * as user_group_settings_ui from "./user_groups_settings_ui"; @@ -68,6 +69,17 @@ export function get_edit_container(group) { ); } +function show_membership_settings(group) { + const $edit_container = get_edit_container(group); + user_group_ui_updates.update_add_members_elements(group); + + const $member_container = $edit_container.find(".edit_members_for_user_group"); + user_group_edit_members.enable_member_management({ + group, + $parent_container: $member_container, + }); +} + export function show_settings_for(node) { const group = get_user_group_for_target(node); const html = render_user_group_settings({ @@ -83,6 +95,7 @@ export function show_settings_for(node) { $(".nothing-selected").hide(); $edit_container.show(); + show_membership_settings(group); } export function setup_group_settings(node) { diff --git a/static/js/user_group_edit_members.js b/static/js/user_group_edit_members.js new file mode 100644 index 0000000000..56243252fc --- /dev/null +++ b/static/js/user_group_edit_members.js @@ -0,0 +1,303 @@ +import $ from "jquery"; + +import render_leave_user_group_modal from "../templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs"; +import render_user_group_member_list_entry from "../templates/stream_settings/stream_member_list_entry.hbs"; +import render_user_group_subscription_request_result from "../templates/stream_settings/stream_subscription_request_result.hbs"; + +import * as add_subscribers_pill from "./add_subscribers_pill"; +import * as blueslip from "./blueslip"; +import * as channel from "./channel"; +import * as confirm_dialog from "./confirm_dialog"; +import {$t, $t_html} from "./i18n"; +import * as ListWidget from "./list_widget"; +import {page_params} from "./page_params"; +import * as people from "./people"; +import * as settings_data from "./settings_data"; +import * as ui from "./ui"; +import * as user_group_edit from "./user_group_edit"; +import * as user_groups from "./user_groups"; + +export let pill_widget; +let current_group_id; + +function get_potential_members() { + const group = user_groups.get_user_group_from_id(current_group_id); + function is_potential_member(person) { + // user verbose style filter to have room + // to add more potential checks easily. + if (group.members.has(person.user_id)) { + return false; + } + return true; + } + + return people.filter_all_users(is_potential_member); +} + +function format_member_list_elem(person) { + return render_user_group_member_list_entry({ + name: person.full_name, + user_id: person.user_id, + is_current_user: person.user_id === page_params.user_id, + email: settings_data.email_for_user_settings(person), + can_edit_subscribers: user_group_edit.can_edit(current_group_id), + show_email: settings_data.show_email(), + }); +} + +function make_list_widget({$parent_container, name, user_ids}) { + const users = people.get_users_from_ids(user_ids); + people.sort_but_pin_current_user_on_top(users); + + const $list_container = $parent_container.find(".member_table"); + $list_container.empty(); + + const $simplebar_container = $parent_container.find(".member_list_container"); + + return ListWidget.create($list_container, users, { + name, + modifier(item) { + return format_member_list_elem(item); + }, + filter: { + $element: $parent_container.find(".search"), + predicate(person, value) { + const matcher = people.build_person_matcher(value); + const match = matcher(person); + + return match; + }, + }, + $simplebar_container, + }); +} + +export function enable_member_management({group, $parent_container}) { + const group_id = group.id; + + const $pill_container = $parent_container.find(".pill-container"); + + // current_group_id and pill_widget are module-level variables + current_group_id = group_id; + + pill_widget = add_subscribers_pill.create({ + $pill_container, + get_potential_subscribers: get_potential_members, + }); + + make_list_widget({ + $parent_container, + name: "user_group_members", + user_ids: Array.from(group.members), + }); +} + +function show_user_group_membership_request_result({ + message, + add_class, + remove_class, + subscribed_users, + already_subscribed_users, + ignored_deactivated_users, +}) { + const $user_group_subscription_req_result_elem = $( + ".user_group_subscription_request_result", + ).expectOne(); + const html = render_user_group_subscription_request_result({ + message, + subscribed_users, + already_subscribed_users, + ignored_deactivated_users, + }); + ui.get_content_element($user_group_subscription_req_result_elem).html(html); + if (add_class) { + $user_group_subscription_req_result_elem.addClass(add_class); + } + if (remove_class) { + $user_group_subscription_req_result_elem.removeClass(remove_class); + } +} + +function edit_user_group_membership({group, added = [], removed = [], success, error}) { + channel.post({ + url: "/json/user_groups/" + group.id + "/members", + data: { + add: JSON.stringify(added), + delete: JSON.stringify(removed), + }, + success, + error, + }); +} + +function add_new_members({pill_user_ids}) { + const group = user_groups.get_user_group_from_id(current_group_id); + if (!group) { + return; + } + + const deactivated_users = new Set(); + const already_added_users = new Set(); + + const active_user_ids = pill_user_ids.filter((user_id) => { + if (!people.is_person_active(user_id)) { + deactivated_users.add(user_id); + return false; + } + if (user_groups.is_user_in_group(group.id, user_id)) { + // we filter out already subscribed users before sending + // add member request as the endpoint is not so robust and + // fails complete request if any already subscribed member + // is present in the request. + already_added_users.add(user_id); + return false; + } + return true; + }); + + const user_id_set = new Set(active_user_ids); + + if ( + user_id_set.has(page_params.user_id) && + user_groups.is_user_in_group(group.id, page_params.user_id) + ) { + // We don't want to send a request to add ourselves if we + // are already added to this group. This case occurs + // when creating user pills from a stream or user group. + user_id_set.delete(page_params.user_id); + } + + let ignored_deactivated_users; + let ignored_already_added_users; + if (deactivated_users.size > 0) { + ignored_deactivated_users = Array.from(deactivated_users); + ignored_deactivated_users = ignored_deactivated_users.map((user_id) => + people.get_by_user_id(user_id), + ); + } + if (already_added_users.size > 0) { + ignored_already_added_users = Array.from(already_added_users); + ignored_already_added_users = ignored_already_added_users.map((user_id) => + people.get_by_user_id(user_id), + ); + } + + if (user_id_set.size === 0) { + show_user_group_membership_request_result({ + message: $t({defaultMessage: "No user to subscribe."}), + add_class: "text-error", + remove_class: "text-success", + already_subscribed_users: ignored_already_added_users, + ignored_deactivated_users, + }); + return; + } + const user_ids = Array.from(user_id_set); + + function invite_success() { + pill_widget.clear(); + show_user_group_membership_request_result({ + message: $t({defaultMessage: "Added successfully."}), + add_class: "text-success", + remove_class: "text-error", + already_subscribed_users: ignored_already_added_users, + ignored_deactivated_users, + }); + } + + function invite_failure(xhr) { + const error = JSON.parse(xhr.responseText); + show_user_group_membership_request_result({ + message: error.msg, + add_class: "text-error", + remove_class: "text-success", + }); + } + + edit_user_group_membership({ + group, + added: user_ids, + success: invite_success, + error: invite_failure, + }); +} + +function remove_member({group_id, target_user_id, $list_entry}) { + const group = user_groups.get_user_group_from_id(current_group_id); + if (!group) { + return; + } + + function removal_success() { + if (group_id !== current_group_id) { + blueslip.info("Response for subscription removal came too late."); + return; + } + + $list_entry.remove(); + const message = $t({defaultMessage: "Removed successfully."}); + show_user_group_membership_request_result({ + message, + add_class: "text-success", + remove_class: "text-remove", + }); + } + + function removal_failure() { + show_user_group_membership_request_result({ + message: $t({defaultMessage: "Error removing user from this group."}), + add_class: "text-error", + remove_class: "text-success", + }); + } + + function do_remove_user_from_group() { + edit_user_group_membership({ + group, + removed: [target_user_id], + success: removal_success, + error: removal_failure, + }); + } + + if (people.is_my_user_id(target_user_id) && !page_params.is_admin) { + const html_body = render_leave_user_group_modal({ + message: $t({ + defaultMessage: "Once you leave this group, you will not be able to rejoin.", + }), + }); + + confirm_dialog.launch({ + html_heading: $t_html({defaultMessage: "Leave {group_name}"}, {group_name: group.name}), + html_body, + on_click: do_remove_user_from_group, + }); + return; + } + + do_remove_user_from_group(); +} + +export function initialize() { + add_subscribers_pill.set_up_handlers({ + get_pill_widget: () => pill_widget, + $parent_container: $("#manage_groups_container"), + pill_selector: ".edit_members_for_user_group .pill-container", + button_selector: ".edit_members_for_user_group .add-subscriber-button", + action: add_new_members, + }); + + $("#manage_groups_container").on( + "submit", + ".edit_members_for_user_group .subscriber_list_remove form", + (e) => { + e.preventDefault(); + + const $list_entry = $(e.target).closest("tr"); + const target_user_id = Number.parseInt($list_entry.attr("data-subscriber-id"), 10); + const group_id = current_group_id; + + remove_member({group_id, target_user_id, $list_entry}); + }, + ); +} diff --git a/static/js/user_group_ui_updates.js b/static/js/user_group_ui_updates.js index 9b8777423a..f9f755898c 100644 --- a/static/js/user_group_ui_updates.js +++ b/static/js/user_group_ui_updates.js @@ -1,3 +1,9 @@ +import $ from "jquery"; + +import * as hash_util from "./hash_util"; +import {$t} from "./i18n"; +import {page_params} from "./page_params"; +import * as stream_ui_updates from "./stream_ui_updates"; import * as user_group_edit from "./user_group_edit"; // This module will handle ui updates logic for group settings, @@ -5,3 +11,42 @@ import * as user_group_edit from "./user_group_edit"; export function update_toggler_for_group_setting() { user_group_edit.toggler.goto(user_group_edit.select_tab); } + +export function update_add_members_elements(group) { + if (!hash_util.is_editing_group(group.id)) { + return; + } + + // We are only concerned with the Members tab for editing groups. + const $add_members_container = $(".edit_members_for_user_group .add_subscribers_container"); + + if (page_params.is_guest || page_params.realm_is_zephyr_mirror_realm) { + // For guest users, we just hide the add_members feature. + $add_members_container.hide(); + return; + } + + // Otherwise, we adjust whether the widgets are disabled based on + // whether this user is authorized to add subscribers. + const $input_element = $add_members_container.find(".input").expectOne(); + const $button_element = $add_members_container + .find('button[name="add_subscriber"]') + .expectOne(); + + if (user_group_edit.can_edit(group.id)) { + $input_element.prop("disabled", false); + $button_element.prop("disabled", false); + $button_element.css("pointer-events", ""); + $input_element.popover("destroy"); + } else { + $input_element.prop("disabled", true); + $button_element.prop("disabled", true); + + stream_ui_updates.initialize_disable_btn_hint_popover( + $add_members_container, + $input_element, + $button_element, + $t({defaultMessage: "Only group members can add users to a group."}), + ); + } +} diff --git a/static/js/user_groups_settings_ui.js b/static/js/user_groups_settings_ui.js index 4e57dca757..474e3490a3 100644 --- a/static/js/user_groups_settings_ui.js +++ b/static/js/user_groups_settings_ui.js @@ -173,6 +173,13 @@ export function initialize() { e.preventDefault(); open_create_user_group(); }); + + $("#manage_groups_container").on("click", ".group-row", show_right_section); + + $("#manage_groups_container").on("click", ".fa-chevron-left", () => { + $(".right").removeClass("show"); + $(".user-groups-header").removeClass("slide-left"); + }); } export function launch(section) { diff --git a/static/styles/dark_theme.css b/static/styles/dark_theme.css index c27d9bc71f..d9ec73c860 100644 --- a/static/styles/dark_theme.css +++ b/static/styles/dark_theme.css @@ -142,6 +142,7 @@ body.dark-theme { #compose, .column-left .left-sidebar, .column-right .right-sidebar, + #groups_overlay .right, #subscription_overlay .right, #settings_page .right { background-color: hsl(212, 28%, 18%); diff --git a/static/styles/subscriptions.css b/static/styles/subscriptions.css index 60303742ad..d8dbf101a9 100644 --- a/static/styles/subscriptions.css +++ b/static/styles/subscriptions.css @@ -2,10 +2,12 @@ margin: 10px auto; } +.member_list_loading_indicator, .subscriber_list_loading_indicator { margin: 10px auto; } +.member_list_loading_indicator:empty, .subscriber_list_loading_indicator:empty { margin: 0; } @@ -218,6 +220,7 @@ h4.user_group_setting_subsection_title { width: 100%; margin: 0 0 10px; + .user_group_subscription_request_result, .stream_subscription_request_result { a { color: inherit; @@ -225,6 +228,7 @@ h4.user_group_setting_subsection_title { } } +.member-search, .subscriber-search { margin: 10px 0 0; @@ -279,6 +283,7 @@ h4.user_group_setting_subsection_title { transition: all 0.3s ease; } +.user-groups-container .user-groups-header.slide-left .fa-chevron-left, .subscriptions-container .subscriptions-header.slide-left .fa-chevron-left, #settings_overlay_container .settings-header.mobile.slide-left @@ -740,6 +745,7 @@ h4.user_group_setting_subsection_title { margin: 20px; } + .group_settings_header, .stream_settings_header { white-space: nowrap; display: flex; @@ -1042,6 +1048,7 @@ h4.user_group_setting_subsection_title { text-align: center; } + #groups_overlay .group_settings_header, #subscription_overlay .stream_settings_header { flex-wrap: wrap; } @@ -1155,7 +1162,9 @@ h4.user_group_setting_subsection_title { } @media (width <= 500px) { + #groups_overlay, #subscription_overlay { + .groups_settings_header, .stream_settings_header { display: block; text-align: center; diff --git a/static/templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs b/static/templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs index 41f2b073d5..dcce06762c 100644 --- a/static/templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs +++ b/static/templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs @@ -1 +1 @@ -

{{t "Once you leave this stream, you will not be able to rejoin." }}

+

{{message}}

diff --git a/static/templates/stream_settings/stream_member_list_entry.hbs b/static/templates/stream_settings/stream_member_list_entry.hbs index 1efce31319..20bccc5325 100644 --- a/static/templates/stream_settings/stream_member_list_entry.hbs +++ b/static/templates/stream_settings/stream_member_list_entry.hbs @@ -8,7 +8,7 @@ {{t "(hidden)"}} {{/if}} {{user_id}} - {{#if displaying_for_admin}} + {{#if can_edit_subscribers}}
diff --git a/static/templates/user_group_settings/user_group_members.hbs b/static/templates/user_group_settings/user_group_members.hbs new file mode 100644 index 0000000000..71a9bc1c56 --- /dev/null +++ b/static/templates/user_group_settings/user_group_members.hbs @@ -0,0 +1,34 @@ +
+

+ {{t "Add members" }} +

+
+
+ {{> ../stream_settings/add_subscribers_form}} +
+
+
+
+
+

{{t "Members"}}

+ + + +
+
+
+
+ + + + + + {{#if can_edit}} + + {{/if}} + + +
{{t "Name" }}{{t "Email" }}{{t "User ID" }}{{t "Actions" }}
+
+
+
diff --git a/static/templates/user_group_settings/user_group_settings.hbs b/static/templates/user_group_settings/user_group_settings.hbs index 73d91365b2..a5f93e78e1 100644 --- a/static/templates/user_group_settings/user_group_settings.hbs +++ b/static/templates/user_group_settings/user_group_settings.hbs @@ -24,7 +24,9 @@
- Group member settings. +
+ {{> user_group_members}} +
diff --git a/tools/test-js-with-node b/tools/test-js-with-node index b31fa34d90..2377e3120d 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -204,6 +204,7 @@ EXEMPT_FILES = make_set( "static/js/user_group_create_members.js", "static/js/user_group_create_members_data.js", "static/js/user_group_edit.js", + "static/js/user_group_edit_members.js", "static/js/user_group_ui_updates.js", "static/js/user_groups_settings_ui.js", "static/js/user_profile.js",