user_groups: Allow adding a user to groups via user profile.

Fixes: #32488.
This commit is contained in:
Maneesh Shukla
2025-01-07 20:25:13 +05:30
committed by Tim Abbott
parent e33fe6779b
commit faa8b0d4a5
8 changed files with 190 additions and 9 deletions

View File

@@ -101,12 +101,11 @@ export function generate_pill_html(item: CombinedPill): string {
}
export function set_up_handlers_for_add_button_state(
pill_widget: CombinedPillContainer,
pill_widget: CombinedPillContainer | user_group_pill.UserGroupPillWidget,
$pill_container: JQuery,
): void {
const $pill_widget_input = $pill_container.find(".input");
const $pill_widget_button = $pill_container.parent().find(".add-users-button");
const $pill_widget_button = $pill_container.closest(".add-button-container").find(".button");
// Disable the add button first time the pill container is created.
$pill_widget_button.prop("disabled", true);
@@ -114,6 +113,8 @@ export function set_up_handlers_for_add_button_state(
pill_widget.onPillRemove(() =>
$pill_widget_button.prop("disabled", pill_widget.items().length === 0),
);
// If a pill is added, enable the add button.
pill_widget.onPillCreate(() => $pill_widget_button.prop("disabled", false));
// Disable the add button when there is no pending text that can be converted
// into a pill and the number of existing pills is zero.
$pill_widget_input.on("input", () =>

View File

@@ -15,6 +15,7 @@ export type InputPillConfig = {
exclude_inaccessible_users?: boolean;
setting_name?: string;
setting_type?: "realm" | "stream" | "group";
user_id?: number;
};
type InputPillCreateOptions<ItemType> = {
@@ -68,6 +69,10 @@ export type InputPillContainer<ItemType> = {
appendValidatedData: (item: ItemType) => void;
getByElement: (element: HTMLElement) => InputPill<ItemType> | undefined;
items: () => ItemType[];
removePill: (
element: HTMLElement,
trigger: RemovePillTrigger,
) => InputPill<ItemType> | undefined;
onPillCreate: (callback: () => void) => void;
onPillRemove: (
callback: (pill: InputPill<ItemType>, trigger: RemovePillTrigger) => void,
@@ -480,6 +485,7 @@ export function create<ItemType extends {type: string}>(
getByElement: funcs.getByElement.bind(funcs),
getCurrentText: funcs.getCurrentText.bind(funcs),
items: funcs.items.bind(funcs),
removePill: funcs.removePill.bind(funcs),
onPillCreate(callback) {
store.onPillCreate = callback;

View File

@@ -1,5 +1,6 @@
import render_input_pill from "../templates/input_pill.hbs";
import {set_up_handlers_for_add_button_state} from "./add_subscribers_pill.ts";
import * as input_pill from "./input_pill.ts";
import {set_up_user_group} from "./pill_typeahead.ts";
import * as settings_data from "./settings_data.ts";
@@ -16,6 +17,7 @@ type SetUpPillTypeaheadConfig = {
function create_item_from_group_name(
group_name: string,
current_items: UserGroupPill[],
pill_config?: input_pill.InputPillConfig,
): UserGroupPill | undefined {
group_name = group_name.trim();
const group = user_groups.get_user_group_from_name(group_name);
@@ -31,6 +33,12 @@ function create_item_from_group_name(
return undefined;
}
// Check if the user is already a direct member of the user group.
const user_id = pill_config?.user_id;
if (user_id !== undefined && user_groups.is_direct_member_of(user_id, group.id)) {
return undefined;
}
return {
type: "user_group",
group_id: group.id,
@@ -43,9 +51,17 @@ export function get_user_groups_allowed_to_add_members(): UserGroup[] {
return all_user_groups.filter((group) => settings_data.can_add_members_to_user_group(group.id));
}
function set_up_pill_typeahead({pill_widget, $pill_container}: SetUpPillTypeaheadConfig): void {
function set_up_pill_typeahead(
{pill_widget, $pill_container}: SetUpPillTypeaheadConfig,
user_id?: number,
): void {
const user_group_source: () => UserGroup[] = () => {
const groups_with_permission = get_user_groups_allowed_to_add_members();
let groups_with_permission = get_user_groups_allowed_to_add_members();
if (user_id !== undefined) {
groups_with_permission = groups_with_permission.filter(
(group) => !user_groups.is_direct_member_of(user_id, group.id),
);
}
return user_group_pill.filter_taken_groups(groups_with_permission, pill_widget);
};
set_up_user_group($pill_container.find(".input"), pill_widget, {user_group_source});
@@ -62,15 +78,24 @@ function generate_pill_html(item: UserGroupPill): string {
});
}
export function create($user_group_pill_container: JQuery): user_group_pill.UserGroupPillWidget {
export function create(
$user_group_pill_container: JQuery,
user_id?: number,
): user_group_pill.UserGroupPillWidget {
const pill_config = user_id ? {user_id} : undefined;
const pill_widget = input_pill.create({
$container: $user_group_pill_container,
pill_config,
create_item_from_text: create_item_from_group_name,
get_text_from_item: user_group_pill.get_group_name_from_item,
generate_pill_html,
get_display_value_from_item,
});
set_up_pill_typeahead({pill_widget, $pill_container: $user_group_pill_container});
set_up_pill_typeahead({pill_widget, $pill_container: $user_group_pill_container}, user_id);
if (user_id) {
set_up_handlers_for_add_button_state(pill_widget, $user_group_pill_container);
}
return pill_widget;
}

View File

@@ -57,6 +57,8 @@ import * as ui_report from "./ui_report.ts";
import type {UploadWidget} from "./upload_widget.ts";
import * as user_deactivation_ui from "./user_deactivation_ui.ts";
import * as user_group_edit_members from "./user_group_edit_members.ts";
import * as user_group_picker_pill from "./user_group_picker_pill.ts";
import * as user_group_pill from "./user_group_pill.ts";
import * as user_groups from "./user_groups.ts";
import type {UserGroup} from "./user_groups.ts";
import * as user_pill from "./user_pill.ts";
@@ -80,6 +82,7 @@ export type CustomProfileFieldData = {
let user_streams_list_widget: ListWidgetType<StreamSubscription> | undefined;
let user_groups_list_widget: ListWidgetType<UserGroup> | undefined;
let user_profile_subscribe_widget: DropdownWidget | undefined;
let user_group_pill_widget: user_group_pill.UserGroupPillWidget;
let toggler: components.Toggle;
let bot_owner_dropdown_widget: DropdownWidget | undefined;
let original_values: (Record<string, unknown> & {user_id?: string | undefined}) | undefined;
@@ -548,6 +551,82 @@ export function show_user_profile_access_error_modal(): void {
});
}
function add_user_to_groups(group_ids: number[], user_id: number, $alert_box: JQuery): void {
const group_ids_successfully_added: number[] = [];
function add_user_to_next_group(): void {
if (group_ids_successfully_added.length >= group_ids.length) {
if (group_ids_successfully_added.length > 0) {
ui_report.success(
$t_html({
defaultMessage: "Added successfully!",
}),
$alert_box,
1200,
);
clear_successful_pills();
}
return;
}
const group_id = group_ids[group_ids_successfully_added.length]!;
const target_user_group = user_groups.get_user_group_from_id(group_id);
if (!target_user_group) {
return;
}
user_group_edit_members.edit_user_group_membership({
group: target_user_group,
added: [user_id],
success(): void {
group_ids_successfully_added.push(group_id);
add_user_to_next_group();
},
error(xhr): void {
const parsed = z
.object({
result: z.literal("error"),
msg: z.string(),
code: z.string(),
})
.safeParse(xhr?.responseJSON);
const error_message = people.is_my_user_id(user_id)
? $t(
{defaultMessage: "Error joining {group_name}: {error}"},
{
group_name: target_user_group.name,
error: parsed.success ? parsed.data.msg : "Unknown error",
},
)
: $t(
{defaultMessage: "Error adding user to {group_name}: {error}"},
{
group_name: target_user_group.name,
error: parsed.success ? parsed.data.msg : "Unknown error",
},
);
ui_report.client_error(error_message, $alert_box);
clear_successful_pills();
},
});
}
function clear_successful_pills(): void {
for (const id of group_ids_successfully_added) {
const $pill = $(`#user-group-to-add .pill-container .pill[data-user-group-id="${id}"]`);
if ($pill.length > 0) {
user_group_pill_widget.removePill($pill[0]!, "close");
}
}
}
// Start the process
add_user_to_next_group();
}
export function show_user_profile(user: User, default_tab_key = "profile-tab"): void {
const field_types = realm.custom_profile_field_types;
const profile_data = realm.custom_profile_fields
@@ -565,6 +644,9 @@ export function show_user_profile(user: User, default_tab_key = "profile-tab"):
(people.can_admin_user(user) || user_unsub_streams.length > 0) &&
!user.is_system_bot &&
people.is_person_active(user.user_id);
const show_user_group_container =
user_group_picker_pill.get_user_groups_allowed_to_add_members().length > 0 &&
people.is_person_active(user.user_id);
// We currently have the main UI for editing your own profile in
// settings, so can_manage_profile is artificially false for those.
const can_manage_profile =
@@ -586,6 +668,7 @@ export function show_user_profile(user: User, default_tab_key = "profile-tab"):
profile_data,
should_add_guest_user_indicator: people.should_add_guest_user_indicator(user.user_id),
show_user_subscribe_widget,
show_user_group_container,
user_avatar: people.medium_avatar_url_for_person(user),
user_circle_class: buddy_data.get_user_circle_class(user.user_id),
user_id: user.user_id,
@@ -695,6 +778,13 @@ export function show_user_profile(user: User, default_tab_key = "profile-tab"):
if (show_user_subscribe_widget) {
reset_subscribe_widget();
}
if (show_user_group_container) {
const $user_group_pill_container = $("#user-group-to-add .pill-container");
user_group_pill_widget = user_group_picker_pill.create(
$user_group_pill_container,
user.user_id,
);
}
}
function handle_remove_stream_subscription(
@@ -1314,6 +1404,27 @@ export function initialize(): void {
});
});
$("body").on("click", "#user-profile-modal .add-groups-button", (e) => {
e.preventDefault();
const user_id = Number.parseInt($("#user-profile-modal").attr("data-user-id")!, 10);
const $alert_box = $("#user-profile-groups-tab .user-profile-group-list-alert");
const item = $("#user-group-to-add .pill-container .input").text().trim();
if (item) {
$("#user-group-to-add .pill-container .input").addClass("shake");
if (
$("#user-group-to-add .pill-container .input").hasClass(
"show-outline-on-invalid-input",
)
) {
$("#user-group-to-add .pill-container").addClass("invalid");
}
return;
}
const group_ids = user_group_pill.get_group_ids(user_group_pill_widget);
add_user_to_groups(group_ids, user_id, $alert_box);
});
$("body").on("click", "#user-profile-modal #clear_stream_search", (e) => {
const $input = $("#user-profile-streams-tab .stream-search");
$input.val("");

View File

@@ -1506,3 +1506,19 @@ ul.popover-menu-list {
}
}
}
#groups-to-add {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#user-group-to-add {
flex: 1;
min-width: 0;
}
.add-groups-button-wrapper {
flex: 0 0 auto;
}

View File

@@ -1,4 +1,4 @@
<div class="add_subscribers_container">
<div class="add_subscribers_container add-button-container">
<div class="pill-container person_picker">
<div class="input" contenteditable="true"
data-placeholder="{{t 'Add subscribers. Use usergroup or #channelname to bulk add subscribers.' }}">

View File

@@ -1,4 +1,4 @@
<div class="add_members_container">
<div class="add_members_container add-button-container">
<div class="pill-container person_picker">
<div class="input" contenteditable="true"
data-placeholder="{{t 'Add users or groups. Use #channelname to add all subscribers.' }}">

View File

@@ -131,6 +131,28 @@
<div class="tabcontent" id="user-profile-groups-tab">
<div class="alert user-profile-group-list-alert"></div>
{{#if show_user_group_container}}
<div class="header-section">
<h3 class="group-tab-element-header">{{t 'Add {full_name} to groups'}}</h3>
</div>
<div id="groups-to-add" class="add-button-container">
<div id="user-group-to-add">
<div class="add-user-group-container">
<div class="pill-container">
<div class="input" contenteditable="true"
data-placeholder="{{t 'Add user groups' }}">
{{~! Squash whitespace so that placeholder is displayed when empty. ~}}
</div>
</div>
</div>
</div>
<div class="add-groups-button-wrapper">
<button type="button" name="subscribe" class="add-groups-button button small rounded">
{{t 'Add' }}
</button>
</div>
</div>
{{/if}}
<div class="group-list-top-section">
<div class="header-section">
<h3 class="group-tab-element-header">{{t 'Group membership' }}</h3>