user_groups: Do not show invalid subgroups in typeahead.

We do not show groups that will break the DAG constraint
on being added to a group as subgroups in the typeahead
shown in the members edit UI.

Fixes #32087.
This commit is contained in:
Sahil Batra 2024-10-23 21:30:10 +05:30 committed by Tim Abbott
parent 625245af50
commit 9a72d6e72e
6 changed files with 150 additions and 4 deletions

View File

@ -11,6 +11,7 @@ import * as stream_pill from "./stream_pill";
import type {CombinedPill, CombinedPillContainer} from "./typeahead_helper"; import type {CombinedPill, CombinedPillContainer} from "./typeahead_helper";
import * as user_group_pill from "./user_group_pill"; import * as user_group_pill from "./user_group_pill";
import * as user_groups from "./user_groups"; import * as user_groups from "./user_groups";
import type {UserGroup} from "./user_groups";
import * as user_pill from "./user_pill"; import * as user_pill from "./user_pill";
export function create_item_from_text( export function create_item_from_text(
@ -51,17 +52,28 @@ export function set_up_pill_typeahead({
pill_widget, pill_widget,
$pill_container, $pill_container,
get_users, get_users,
get_user_groups,
}: { }: {
pill_widget: CombinedPillContainer; pill_widget: CombinedPillContainer;
$pill_container: JQuery; $pill_container: JQuery;
get_users: () => User[]; get_users: () => User[];
get_user_groups?: () => UserGroup[];
}): void { }): void {
const opts = { const opts: {
user_source: () => User[];
stream: boolean;
user_group: boolean;
user: boolean;
user_group_source?: () => UserGroup[];
} = {
user_source: get_users, user_source: get_users,
stream: true, stream: true,
user_group: true, user_group: true,
user: true, user: true,
}; };
if (get_user_groups !== undefined) {
opts.user_group_source = get_user_groups;
}
pill_typeahead.set_up_combined($pill_container.find(".input"), pill_widget, opts); pill_typeahead.set_up_combined($pill_container.find(".input"), pill_widget, opts);
} }
@ -91,9 +103,11 @@ export function generate_pill_html(item: CombinedPill): string {
export function create({ export function create({
$pill_container, $pill_container,
get_potential_subscribers, get_potential_subscribers,
get_potential_groups,
}: { }: {
$pill_container: JQuery; $pill_container: JQuery;
get_potential_subscribers: () => User[]; get_potential_subscribers: () => User[];
get_potential_groups?: () => UserGroup[];
}): CombinedPillContainer { }): CombinedPillContainer {
const pill_widget = input_pill.create<CombinedPill>({ const pill_widget = input_pill.create<CombinedPill>({
$container: $pill_container, $container: $pill_container,
@ -106,8 +120,26 @@ export function create({
const potential_subscribers = get_potential_subscribers(); const potential_subscribers = get_potential_subscribers();
return user_pill.filter_taken_users(potential_subscribers, pill_widget); return user_pill.filter_taken_users(potential_subscribers, pill_widget);
} }
const opts: {
pill_widget: CombinedPillContainer;
$pill_container: JQuery;
get_users: () => User[];
get_user_groups?: () => UserGroup[];
} = {
pill_widget,
$pill_container,
get_users,
};
if (get_potential_groups !== undefined) {
function get_user_groups(): UserGroup[] {
assert(get_potential_groups !== undefined);
const potential_groups = get_potential_groups();
return user_group_pill.filter_taken_groups(potential_groups, pill_widget);
}
opts.get_user_groups = get_user_groups;
}
set_up_pill_typeahead({pill_widget, $pill_container, get_users}); set_up_pill_typeahead(opts);
const $pill_widget_input = $pill_container.find(".input"); 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.parent().find(".add-users-button");

View File

@ -222,6 +222,7 @@ export function set_up_combined(
user_group?: boolean; user_group?: boolean;
stream?: boolean; stream?: boolean;
user_source?: () => User[]; user_source?: () => User[];
user_group_source?: () => UserGroup[];
exclude_bots?: boolean; exclude_bots?: boolean;
update_func?: () => void; update_func?: () => void;
}, },
@ -252,7 +253,14 @@ export function set_up_combined(
} }
if (include_user_groups) { if (include_user_groups) {
source = [...source, ...user_group_pill.typeahead_source(pills)]; if (opts.user_group_source !== undefined) {
const groups: UserGroupPillData[] = opts
.user_group_source()
.map((user_group) => ({type: "user_group", ...user_group}));
source = [...source, ...groups];
} else {
source = [...source, ...user_group_pill.typeahead_source(pills)];
}
} }
if (include_users) { if (include_users) {

View File

@ -44,6 +44,10 @@ function get_potential_members(): User[] {
return people.filter_all_users(is_potential_member); return people.filter_all_users(is_potential_member);
} }
function get_potential_subgroups(): UserGroup[] {
return user_groups.get_potential_subgroups(current_group_id);
}
function get_user_group_members(group: UserGroup): (User | UserGroup)[] { function get_user_group_members(group: UserGroup): (User | UserGroup)[] {
const member_ids = [...group.members]; const member_ids = [...group.members];
const member_users = people.get_users_from_ids(member_ids); const member_users = people.get_users_from_ids(member_ids);
@ -141,6 +145,7 @@ export function enable_member_management({
pill_widget = add_subscribers_pill.create({ pill_widget = add_subscribers_pill.create({
$pill_container, $pill_container,
get_potential_subscribers: get_potential_members, get_potential_subscribers: get_potential_members,
get_potential_groups: get_potential_subgroups,
}); });
$pill_container.find(".input").on("input", () => { $pill_container.find(".input").on("input", () => {

View File

@ -311,6 +311,30 @@ export function get_recursive_group_members(target_user_group: UserGroup): Set<n
return members; return members;
} }
export function get_potential_subgroups(target_user_group_id: number): UserGroup[] {
// This logic could be optimized if we maintained a reverse map
// from each group to the groups containing it, which might be a
// useful data structure for other code paths as well.
const target_user_group = get_user_group_from_id(target_user_group_id);
const already_subgroup_ids = target_user_group.direct_subgroup_ids;
return get_all_realm_user_groups().filter((user_group) => {
if (user_group.id === target_user_group.id) {
return false;
}
if (already_subgroup_ids.has(user_group.id)) {
return false;
}
const recursive_subgroup_ids = get_recursive_subgroups(user_group);
assert(recursive_subgroup_ids !== undefined);
if (recursive_subgroup_ids.has(target_user_group.id)) {
return false;
}
return true;
});
}
export function is_user_in_group( export function is_user_in_group(
user_group_id: number, user_group_id: number,
user_id: number, user_id: number,

View File

@ -465,7 +465,11 @@ run_test("set_up_combined", ({mock_template, override, override_rewire}) => {
}) })
.filter(Boolean); .filter(Boolean);
if (opts.user_group) { if (opts.user_group) {
expected_result = [...expected_result, ...group_items]; if (opts.user_group_source) {
expected_result = [...expected_result, ...opts.user_group_source()];
} else {
expected_result = [...expected_result, ...group_items];
}
} }
if (opts.user) { if (opts.user) {
if (opts.user_source) { if (opts.user_source) {
@ -530,6 +534,8 @@ run_test("set_up_combined", ({mock_template, override, override_rewire}) => {
{user: true, user_source: () => [fred_item, mark_item]}, {user: true, user_source: () => [fred_item, mark_item]},
{stream: true}, {stream: true},
{user_group: true}, {user_group: true},
// user and custom user group source.
{user_group: true, user_group_source: () => [admins_item]},
{user_group: true, stream: true}, {user_group: true, stream: true},
{user_group: true, user: true}, {user_group: true, user: true},
{user: true, stream: true}, {user: true, stream: true},

View File

@ -583,3 +583,74 @@ run_test("get_display_group_name", () => {
assert.equal(user_groups.get_display_group_name(all.name), "translated: Everyone"); assert.equal(user_groups.get_display_group_name(all.name), "translated: Everyone");
assert.equal(user_groups.get_display_group_name(students.name), "Students"); assert.equal(user_groups.get_display_group_name(students.name), "Students");
}); });
run_test("get_potential_subgroups", () => {
// Remove existing groups.
user_groups.init();
const admins = {
name: "Administrators",
id: 1,
members: new Set([1]),
is_system_group: false,
direct_subgroup_ids: new Set([4]),
};
const all = {
name: "Everyone",
id: 2,
members: new Set([2, 3]),
is_system_group: false,
direct_subgroup_ids: new Set([1, 3]),
};
const students = {
name: "Students",
id: 3,
members: new Set([4, 5]),
is_system_group: false,
direct_subgroup_ids: new Set([]),
};
const teachers = {
name: "Teachers",
id: 4,
members: new Set([6, 7, 8]),
is_system_group: false,
direct_subgroup_ids: new Set([]),
};
const science = {
name: "Science",
id: 5,
members: new Set([9]),
is_system_group: false,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({
realm_user_groups: [admins, all, students, teachers, science],
});
function get_potential_subgroup_ids(group_id) {
return user_groups
.get_potential_subgroups(group_id)
.map((subgroup) => subgroup.id)
.sort();
}
assert.deepEqual(get_potential_subgroup_ids(all.id), [teachers.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(admins.id), [students.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(teachers.id), [students.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(students.id), [admins.id, teachers.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(science.id), [
admins.id,
all.id,
students.id,
teachers.id,
]);
user_groups.add_subgroups(all.id, [teachers.id]);
user_groups.add_subgroups(teachers.id, [science.id]);
assert.deepEqual(get_potential_subgroup_ids(all.id), [science.id]);
assert.deepEqual(get_potential_subgroup_ids(admins.id), [students.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(teachers.id), [students.id]);
assert.deepEqual(get_potential_subgroup_ids(students.id), [admins.id, teachers.id, science.id]);
assert.deepEqual(get_potential_subgroup_ids(science.id), [students.id]);
});