streams-ui: Add UI to update and archive channel folders.

This commit adds edit and delete buttons in the dropdown
list for folder.

Fixes #35498.
This commit is contained in:
Sahil Batra
2025-07-29 17:16:58 +05:30
committed by Tim Abbott
parent da53d5b978
commit 524442bf44
8 changed files with 217 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import type * as z from "zod/mini";
import {FoldDict} from "./fold_dict.ts";
import type {ChannelFolderUpdateEvent} from "./server_event_types.ts";
import type {StateData, channel_folder_schema} from "./state_data.ts";
import * as stream_data from "./stream_data.ts";
export type ChannelFolder = z.infer<typeof channel_folder_schema>;
@@ -72,3 +73,8 @@ export function update(event: ChannelFolderUpdateEvent): void {
channel_folder_name_dict.set(channel_folder.name, channel_folder);
}
}
export function get_stream_ids_in_folder(folder_id: number): number[] {
const streams = stream_data.get_unsorted_subs().filter((sub) => sub.folder_id === folder_id);
return streams.map((sub) => sub.stream_id);
}

View File

@@ -1,13 +1,16 @@
import $ from "jquery";
import * as z from "zod/mini";
import render_confirm_archive_channel_folder from "../templates/confirm_dialog/confirm_archive_channel_folder.hbs";
import render_unsubscribe_private_stream_modal from "../templates/confirm_dialog/confirm_unsubscribe_private_stream.hbs";
import render_inline_decorated_channel_name from "../templates/inline_decorated_channel_name.hbs";
import render_edit_channel_folder_modal from "../templates/stream_settings/edit_channel_folder_modal.hbs";
import render_selected_stream_title from "../templates/stream_settings/selected_stream_title.hbs";
import * as channel from "./channel.ts";
import * as channel_folders from "./channel_folders.ts";
import * as confirm_dialog from "./confirm_dialog.ts";
import * as dialog_widget from "./dialog_widget.ts";
import type {DropdownWidget} from "./dropdown_widget.ts";
import * as dropdown_widget from "./dropdown_widget.ts";
import * as hash_util from "./hash_util.ts";
@@ -19,7 +22,7 @@ import * as resize from "./resize.ts";
import * as settings_components from "./settings_components.ts";
import * as settings_config from "./settings_config.ts";
import * as settings_data from "./settings_data.ts";
import {current_user} from "./state_data.ts";
import {current_user, realm} from "./state_data.ts";
import * as stream_data from "./stream_data.ts";
import * as stream_settings_containers from "./stream_settings_containers.ts";
import * as stream_settings_data from "./stream_settings_data.ts";
@@ -319,6 +322,100 @@ export function filter_includes_channel(sub: StreamSubscription): boolean {
return true;
}
function archive_folder(folder_id: number): void {
const stream_ids = channel_folders.get_stream_ids_in_folder(folder_id);
let successful_requests = 0;
function make_archive_folder_request(): void {
const url = "/json/channel_folders/" + folder_id.toString();
const data = {
is_archived: JSON.stringify(true),
};
dialog_widget.submit_api_request(channel.patch, url, data);
}
if (stream_ids.length === 0) {
make_archive_folder_request();
return;
}
function remove_channel_from_folder(stream_id: number): void {
const url = "/json/streams/" + stream_id.toString();
const data = {
folder_id: JSON.stringify(null),
};
void channel.patch({
url,
data,
success() {
successful_requests = successful_requests + 1;
if (successful_requests === stream_ids.length) {
// Make request to archive folder only after all channels
// are removed from the folder.
make_archive_folder_request();
}
},
error(xhr) {
ui_report.error(
$t_html({
defaultMessage: "Failed removing one or more channels from the folder",
}),
xhr,
$("#dialog_error"),
);
dialog_widget.hide_dialog_spinner();
},
});
}
for (const stream_id of stream_ids) {
remove_channel_from_folder(stream_id);
}
}
function handle_archiving_channel_folder(folder_id: number): void {
confirm_dialog.launch({
html_heading: $t_html({defaultMessage: "Delete channel folder?"}),
html_body: render_confirm_archive_channel_folder(),
on_click() {
archive_folder(folder_id);
},
close_on_submit: false,
loading_spinner: true,
});
}
function handle_editing_channel_folder(folder_id: number): void {
const folder = channel_folders.get_channel_folder_by_id(folder_id);
const html_body = render_edit_channel_folder_modal({
name: folder.name,
description: folder.description,
max_channel_folder_name_length: realm.max_channel_folder_name_length,
max_channel_folder_description_length: realm.max_channel_folder_description_length,
});
dialog_widget.launch({
html_heading: $t_html({defaultMessage: "Edit channel folder"}),
html_body,
id: "edit_channel_folder",
on_click() {
const url = "/json/channel_folders/" + folder_id.toString();
const data = {
name: $<HTMLInputElement>("input#edit_channel_folder_name").val()!.trim(),
description: $<HTMLTextAreaElement>("textarea#edit_channel_folder_description")
.val()!
.trim(),
};
dialog_widget.submit_api_request(channel.patch, url, data);
},
loading_spinner: true,
on_shown: () => $("#edit_channel_folder_name").trigger("focus"),
update_submit_disabled_state_on_change: true,
});
}
export function set_up_folder_dropdown_widget(sub?: StreamSubscription): DropdownWidget {
const folder_options = (): dropdown_widget.Option[] => {
const folders = channel_folders
@@ -327,6 +424,10 @@ export function set_up_folder_dropdown_widget(sub?: StreamSubscription): Dropdow
const options: dropdown_widget.Option[] = folders.map((folder) => ({
name: folder.name,
unique_id: folder.id,
has_delete_icon: true,
has_edit_icon: true,
delete_icon_label: $t({defaultMessage: "Delete folder"}),
edit_icon_label: $t({defaultMessage: "Edit folder"}),
}));
const disabled_option = {
@@ -370,6 +471,37 @@ export function set_up_folder_dropdown_widget(sub?: StreamSubscription): Dropdow
);
}
},
item_button_click_callback(event) {
event.preventDefault();
event.stopPropagation();
if (
$(event.target).closest(
`.${CSS.escape(widget_name)}-dropdown-list-container .dropdown-list-delete`,
).length > 0
) {
const folder_id = Number.parseInt(
$(event.target).closest(".list-item").attr("data-unique-id")!,
10,
);
handle_archiving_channel_folder(folder_id);
return;
}
if (
$(event.target).closest(
`.${CSS.escape(widget_name)}-dropdown-list-container .dropdown-list-edit`,
).length > 0
) {
const folder_id = Number.parseInt(
$(event.target).closest(".list-item").attr("data-unique-id")!,
10,
);
handle_editing_channel_folder(folder_id);
return;
}
},
default_id,
unique_id_type: "number",
});

View File

@@ -780,6 +780,26 @@ export function initialize(): void {
},
});
tippy.delegate("body", {
target: ".folder_id-dropdown-list-container .dropdown-list-delete, .new_channel_folder_id-dropdown-list-container .dropdown-list-delete",
content: $t({defaultMessage: "Delete folder"}),
delay: LONG_HOVER_DELAY,
appendTo: () => document.body,
onHidden(instance) {
instance.destroy();
},
});
tippy.delegate("body", {
target: ".folder_id-dropdown-list-container .dropdown-list-edit, .new_channel_folder_id-dropdown-list-container .dropdown-list-edit",
content: $t({defaultMessage: "Edit folder"}),
delay: LONG_HOVER_DELAY,
appendTo: () => document.body,
onHidden(instance) {
instance.destroy();
},
});
tippy.delegate("body", {
target: ".generate-channel-email-button-container.disabled_setting_tooltip",
onShow(instance) {

View File

@@ -1346,7 +1346,8 @@ div.settings-radio-input-parent {
#change_user_group_description,
#change_stream_description,
#new_channel_folder_description {
#new_channel_folder_description,
#edit_channel_folder_description {
width: 100%;
height: 80px;
margin-bottom: 4px;

View File

@@ -0,0 +1,2 @@
<p>{{t "Channels in this folder will become uncategorized."}}</p>
<p>{{t "This action cannot be undone."}}</p>

View File

@@ -42,6 +42,12 @@
{{else}}
{{name}}
{{/if}}
{{#if has_edit_icon}}
{{> components/icon_button custom_classes="dropdown-list-edit dropdown-list-control-button" intent="neutral" icon="edit" aria-label=(t "Edit folder") }}
{{/if}}
{{#if has_delete_icon}}
{{> components/icon_button custom_classes="dropdown-list-delete dropdown-list-control-button" intent="danger" icon="trash" aria-label=(t "Delete folder") }}
{{/if}}
{{/if}}
</a>
{{/if}}

View File

@@ -0,0 +1,12 @@
<div>
<label for="edit_channel_folder_name" class="modal-field-label">
{{t 'Channel folder name' }}
</label>
<input type="text" id="edit_channel_folder_name" class="modal_text_input" name="channel_folder_name" maxlength="{{ max_channel_folder_name_length }}" value="{{name}}" />
</div>
<div>
<label for="edit_channel_folder_description" class="modal-field-label">
{{t 'Description' }}
</label>
<textarea id="edit_channel_folder_description" class="settings_textarea" name="channel_folder_description" maxlength="{{ max_channel_folder_description_length }}">{{~description~}}</textarea>
</div>

View File

@@ -2,10 +2,12 @@
const assert = require("node:assert/strict");
const {make_stream} = require("./lib/example_stream.cjs");
const {zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs");
const channel_folders = zrequire("channel_folders");
const stream_data = zrequire("stream_data");
run_test("basics", () => {
const params = {};
@@ -61,4 +63,38 @@ run_test("basics", () => {
assert.ok(!channel_folders.is_valid_folder_id(999));
assert.equal(channel_folders.get_channel_folder_by_id(frontend_folder.id), frontend_folder);
const stream_1 = make_stream({
stream_id: 1,
name: "Stream 1",
folder_id: null,
});
const stream_2 = make_stream({
stream_id: 2,
name: "Stream 2",
folder_id: frontend_folder.id,
});
const stream_3 = make_stream({
stream_id: 3,
name: "Stream 3",
folder_id: devops_folder.id,
});
const stream_4 = make_stream({
stream_id: 4,
name: "Stream 4",
folder_id: frontend_folder.id,
});
stream_data.add_sub(stream_1);
stream_data.add_sub(stream_2);
stream_data.add_sub(stream_3);
stream_data.add_sub(stream_4);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(frontend_folder.id), [
stream_2.stream_id,
stream_4.stream_id,
]);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(devops_folder.id), [
stream_3.stream_id,
]);
assert.deepEqual(channel_folders.get_stream_ids_in_folder(backend_folder.id), []);
});