diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 501b0cbbec..cfe590b333 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -103,7 +103,7 @@ EXEMPT_FILES = make_set( "web/src/invite.js", "web/src/lightbox.js", "web/src/list_util.ts", - "web/src/list_widget.js", + "web/src/list_widget.ts", "web/src/loading.ts", "web/src/local_message.js", "web/src/localstorage.ts", diff --git a/web/src/list_widget.js b/web/src/list_widget.ts similarity index 72% rename from web/src/list_widget.js rename to web/src/list_widget.ts index bc946597f8..d91904af3c 100644 --- a/web/src/list_widget.js +++ b/web/src/list_widget.ts @@ -1,19 +1,91 @@ import $ from "jquery"; +import assert from "minimalistic-assert"; import * as blueslip from "./blueslip"; import * as scroll_util from "./scroll_util"; +type SortingFunction = (a: T, b: T) => number; +type GenericSortingFunction> = (prop: string) => SortingFunction; + +type ListWidgetMeta = { + sorting_function: SortingFunction | null; + sorting_functions: Map>; + filter_value: string; + offset: number; + list: Key[]; + filtered_list: Item[]; + reverse_mode: boolean; + $scroll_container: JQuery; +}; + +// This type ensures the mutually exclusive nature of the predicate and filterer options. +type ListWidgetFilterOpts = { + $element?: JQuery; + onupdate?: () => void; +} & ( + | { + predicate: (item: Item, value: string) => boolean; + filterer?: never; + } + | { + predicate?: never; + filterer: (list: Item[], value: string) => Item[]; + } +); + +type ListWidgetOpts = { + name?: string; + get_item: (key: Key) => Item; + modifier: (item: Item) => string; + init_sort?: string | SortingFunction; + initially_descending_sort?: boolean; + html_selector?: (item: Item) => JQuery; + callback_after_render?: () => void; + post_scroll__pre_render_callback?: () => void; + get_min_load_count?: (rendered_count: number, load_count: number) => number; + is_scroll_position_for_render?: (scroll_container: HTMLElement) => boolean; + filter?: ListWidgetFilterOpts; + multiselect?: { + selected_items: Key[]; + }; + sort_fields?: Record>; + $simplebar_container: JQuery; + $parent_container?: JQuery; +}; + +type ListWidget = { + get_current_list(): Item[]; + filter_and_sort(): void; + retain_selected_items(): void; + render(how_many?: number): void; + render_item(item: Item): void; + clear(): void; + set_filter_value(value: string): void; + set_reverse_mode(reverse_mode: boolean): void; + set_sorting_function(sorting_function: string | SortingFunction): void; + set_up_event_handlers(): void; + clear_event_handlers(): void; + increase_rendered_offset(): void; + reduce_rendered_offset(): void; + remove_rendered_row(row: JQuery): void; + clean_redraw(): void; + hard_redraw(): void; + insert_rendered_row(item: Item, get_insert_index: (list: Item[], item: Item) => number): void; + sort(sorting_function: string, prop?: string): void; + replace_list_data(list: Key[]): void; +}; + const DEFAULTS = { INITIAL_RENDER_COUNT: 80, LOAD_COUNT: 20, - instances: new Map(), + instances: new Map(), }; // ---------------------------------------------------- // This function describes (programmatically) how to use the ListWidget. // ---------------------------------------------------- -export function validate_opts(opts) { +export function validate_opts(opts: ListWidgetOpts): boolean { if (opts.html_selector && typeof opts.html_selector !== "function") { // We have an html_selector, but it is not a function. // This is a programming error. @@ -31,7 +103,11 @@ export function validate_opts(opts) { return true; } -export function get_filtered_items(value, list, opts) { +export function get_filtered_items( + value: string, + list: Key[], + opts: ListWidgetOpts, +): Item[] { /* This is used by the main object (see `create`), but we split it out to make it a bit easier @@ -50,7 +126,7 @@ export function get_filtered_items(value, list, opts) { ); } - const predicate = (item) => opts.filter.predicate(item, value); + const predicate = (item: Item): boolean => opts.filter!.predicate!(item, value); const result = []; @@ -64,11 +140,11 @@ export function get_filtered_items(value, list, opts) { return result; } -export function alphabetic_sort(prop) { - return function (a, b) { +export const alphabetic_sort: GenericSortingFunction = (prop) => + function (a, b) { // The conversion to uppercase helps make the sorting case insensitive. - const str1 = a[prop].toUpperCase(); - const str2 = b[prop].toUpperCase(); + const str1 = (a[prop] as string).toUpperCase(); + const str2 = (b[prop] as string).toUpperCase(); if (str1 === str2) { return 0; @@ -78,27 +154,31 @@ export function alphabetic_sort(prop) { return -1; }; -} -export function numeric_sort(prop) { - return function (a, b) { - if (Number.parseFloat(a[prop]) > Number.parseFloat(b[prop])) { +export const numeric_sort: GenericSortingFunction = (prop) => + function (a, b) { + const a_prop = Number.parseFloat(a[prop] as string); + const b_prop = Number.parseFloat(b[prop] as string); + + if (a_prop > b_prop) { return 1; - } else if (Number.parseFloat(a[prop]) === Number.parseFloat(b[prop])) { + } else if (a_prop === b_prop) { return 0; } return -1; }; -} const generic_sorts = { alphabetic: alphabetic_sort, numeric: numeric_sort, }; -export function generic_sort_functions(generic_func, props) { - const sorting_functions = {}; +export function generic_sort_functions>( + generic_func: keyof typeof generic_sorts, + props: string[], +): Record> { + const sorting_functions: Record> = {}; for (const prop of props) { const key = `${prop}_${generic_func}`; sorting_functions[key] = generic_sorts[generic_func](prop); @@ -107,7 +187,7 @@ export function generic_sort_functions(generic_func, props) { return sorting_functions; } -export function valid_filter_opts(opts) { +export function valid_filter_opts(opts: ListWidgetOpts): boolean { if (!opts.filter) { return true; } @@ -130,7 +210,7 @@ export function valid_filter_opts(opts) { return true; } -function is_scroll_position_for_render(scroll_container) { +function is_scroll_position_for_render(scroll_container: HTMLElement): boolean { return ( scroll_container.scrollHeight - (scroll_container.scrollTop + scroll_container.clientHeight) < @@ -142,7 +222,11 @@ function is_scroll_position_for_render(scroll_container) { // $container: jQuery object to append to. // list: The list of items to progressively append. // opts: An object of random preferences. -export function create($container, list, opts) { +export function create( + $container: JQuery, + list: Key[], + opts: ListWidgetOpts, +): ListWidget | undefined { if (!opts) { blueslip.error("Need opts to create widget."); return undefined; @@ -154,16 +238,16 @@ export function create($container, list, opts) { if (opts.name && DEFAULTS.instances.get(opts.name)) { // Clear event handlers for prior widget. - const old_widget = DEFAULTS.instances.get(opts.name); + const old_widget = DEFAULTS.instances.get(opts.name)!; old_widget.clear_event_handlers(); } - const meta = { + const meta: ListWidgetMeta = { sorting_function: null, sorting_functions: new Map(), offset: 0, list, - filtered_list: list, + filtered_list: [], reverse_mode: false, filter_value: "", $scroll_container: scroll_util.get_scroll_element(opts.$simplebar_container), @@ -178,7 +262,7 @@ export function create($container, list, opts) { return undefined; } - const widget = { + const widget: ListWidget = { get_current_list() { return meta.filtered_list; }, @@ -200,10 +284,10 @@ export function create($container, list, opts) { retain_selected_items() { const items = opts.multiselect; - if (items.selected_items) { + if (items?.selected_items) { const data = items.selected_items; for (const value of data) { - const $list_item = $container.find(`li[data-value = "${value}"]`); + const $list_item = $container.find(`li[data-value = "${value as string}"]`); if ($list_item.length) { const $link_elem = $list_item.find("a").expectOne(); $list_item.addClass("checked"); @@ -217,7 +301,7 @@ export function create($container, list, opts) { // and renders the next block of messages automatically // into the specified container. render(how_many) { - let load_count = how_many || DEFAULTS.LOAD_COUNT; + let load_count = how_many ?? DEFAULTS.LOAD_COUNT; if (opts.get_min_load_count) { load_count = opts.get_min_load_count(meta.offset, load_count); } @@ -268,7 +352,6 @@ export function create($container, list, opts) { return; } - item = opts.get_item(item); const html = opts.modifier(item); if (typeof html !== "string") { blueslip.error("List item is not a string", {item: html}); @@ -306,7 +389,7 @@ export function create($container, list, opts) { return; } - meta.sorting_function = meta.sorting_functions.get(sorting_function); + meta.sorting_function = meta.sorting_functions.get(sorting_function)!; } }, @@ -334,13 +417,11 @@ export function create($container, list, opts) { }); } - if (opts.filter && opts.filter.$element) { - opts.filter.$element.on("input.list_widget_filter", function () { - const value = this.value.toLocaleLowerCase(); - widget.set_filter_value(value); - widget.hard_redraw(); - }); - } + opts.filter?.$element?.on("input.list_widget_filter", function () { + const value = (this as HTMLInputElement).value.toLocaleLowerCase(); + widget.set_filter_value(value); + widget.hard_redraw(); + }); }, clear_event_handlers() { @@ -350,9 +431,7 @@ export function create($container, list, opts) { opts.$parent_container.off("click.list_widget_sort", "[data-sort]"); } - if (opts.filter && opts.filter.$element) { - opts.filter.$element.off("input.list_widget_filter"); - } + opts.filter?.$element?.off("input.list_widget_filter"); }, increase_rendered_offset() { @@ -377,7 +456,7 @@ export function create($container, list, opts) { hard_redraw() { widget.clean_redraw(); - if (opts.filter && opts.filter.onupdate) { + if (opts.filter?.onupdate) { opts.filter.onupdate(); } }, @@ -390,9 +469,15 @@ export function create($container, list, opts) { widget.clean_redraw(); return; } - if (!opts.filter.predicate(item)) { + + assert( + opts.filter?.predicate, + "filter.predicate should be defined for insert_rendered_row", + ); + if (!opts.filter.predicate(item, meta.filter_value)) { return; } + // We need to insert the row for it to be displayed at the // correct position. filtered_list must contain the new item // since we know it is not hidden from the above check. @@ -408,10 +493,10 @@ export function create($container, list, opts) { } const rendered_row = opts.modifier(item); if (insert_index === meta.filtered_list.length - 1) { - const $target_row = opts.html_selector(meta.filtered_list[insert_index - 1]); + const $target_row = opts.html_selector!(meta.filtered_list[insert_index - 1]); $target_row.after(rendered_row); } else { - const $target_row = opts.html_selector(meta.filtered_list[insert_index + 1]); + const $target_row = opts.html_selector!(meta.filtered_list[insert_index + 1]); $target_row.before(rendered_row); } widget.increase_rendered_offset(); @@ -462,11 +547,11 @@ export function create($container, list, opts) { return widget; } -export function get(name) { - return DEFAULTS.instances.get(name) || false; +export function get(name: string): ListWidget | false { + return DEFAULTS.instances.get(name) ?? false; } -export function handle_sort($th, list) { +export function handle_sort($th: JQuery, list: ListWidget): void { /* one would specify sort parameters like this: - name => sort alphabetic. @@ -480,8 +565,8 @@ export function handle_sort($th, list) { */ - const sort_type = $th.data("sort"); - const prop_name = $th.data("sort-prop"); + const sort_type: string = $th.data("sort"); + const prop_name: string = $th.data("sort-prop"); if ($th.hasClass("active")) { if (!$th.hasClass("descend")) { @@ -501,4 +586,4 @@ export function handle_sort($th, list) { list.sort(sort_type, prop_name); } -export const default_get_item = (item) => item; +export const default_get_item = (item: T): T => item; diff --git a/web/src/recent_topics_ui.js b/web/src/recent_topics_ui.js index 680bfba04b..92cacccfe3 100644 --- a/web/src/recent_topics_ui.js +++ b/web/src/recent_topics_ui.js @@ -583,11 +583,11 @@ export function inplace_rerender(topic_key) { } const topic_data = topics.get(topic_key); - const topic_row = get_topic_row(topic_data); + const $topic_row = get_topic_row(topic_data); // We cannot rely on `topic_widget.meta.filtered_list` to know // if a topic is rendered since the `filtered_list` might have // already been updated via other calls. - const is_topic_rendered = topic_row.length; + const is_topic_rendered = $topic_row.length; // Resorting the topics_widget is important for the case where we // are rerendering because of message editing or new messages // arriving, since those operations often change the sort key. @@ -605,7 +605,7 @@ export function inplace_rerender(topic_key) { if (row_is_focused && row_focus >= current_topics_list.length) { row_focus = current_topics_list.length - 1; } - topics_widget.remove_rendered_row(topic_row); + topics_widget.remove_rendered_row($topic_row); } else if (!is_topic_rendered && filters_should_hide_topic(topic_data)) { // In case `topic_row` is not present, our job is already done here // since it has not been rendered yet and we already removed it from diff --git a/web/src/settings_users.js b/web/src/settings_users.js index 008597e464..3125417d04 100644 --- a/web/src/settings_users.js +++ b/web/src/settings_users.js @@ -287,7 +287,7 @@ section.bots.create_table = () => { name: "admin_bot_list", get_item: bot_info, modifier: render_admin_user_list, - html_selector: (item) => `tr[data-user-id='${CSS.escape(item)}']`, + html_selector: (item) => $(`tr[data-user-id='${CSS.escape(item)}']`), filter: { $element: $bots_table.closest(".settings-section").find(".search"), predicate(item, value) { diff --git a/web/tests/list_widget.test.js b/web/tests/list_widget.test.js index a44c3e0169..a9e641ee11 100644 --- a/web/tests/list_widget.test.js +++ b/web/tests/list_widget.test.js @@ -5,6 +5,7 @@ const {strict: assert} = require("assert"); const {mock_esm, mock_jquery, zrequire} = require("./lib/namespace"); const {run_test} = require("./lib/test"); const blueslip = require("./lib/zblueslip"); +const $ = require("./lib/zjquery"); // We need these stubs to get by instanceof checks. // The ListWidget library allows you to insert objects @@ -723,7 +724,8 @@ run_test("render item", () => { const $scroll_container = make_scroll_container(); const INITIAL_RENDER_COUNT = 80; // Keep this in sync with the actual code. let called = false; - $scroll_container.find = (query) => { + $scroll_container.find = (element) => { + const query = element.selector; const expected_queries = [ `tr[data-item='${INITIAL_RENDER_COUNT}']`, `tr[data-item='${INITIAL_RENDER_COUNT - 1}']`, @@ -756,7 +758,7 @@ run_test("render item", () => { name: "replace-list", modifier: (item) => `${item.text}\n`, get_item, - html_selector: (item) => `tr[data-item='${item}']`, + html_selector: (item) => $(`tr[data-item='${item.value}']`), $simplebar_container: $scroll_container, }); const item = INITIAL_RENDER_COUNT - 1; @@ -765,7 +767,7 @@ run_test("render item", () => { assert.ok($container.$appended_data.html().includes("initial: 3")); text = "updated"; called = false; - widget.render_item(INITIAL_RENDER_COUNT - 1); + widget.render_item(get_item(INITIAL_RENDER_COUNT - 1)); assert.ok(called); assert.ok($container.$appended_data.html().includes("initial: 2")); assert.ok( @@ -781,9 +783,9 @@ run_test("render item", () => { ), ); called = false; - widget.render_item(INITIAL_RENDER_COUNT); + widget.render_item(get_item(INITIAL_RENDER_COUNT)); assert.ok(!called); - widget.render_item(INITIAL_RENDER_COUNT - 1); + widget.render_item(get_item(INITIAL_RENDER_COUNT - 1)); assert.ok(called); // Tests below this are for the corner cases, where we abort the rerender. @@ -809,6 +811,7 @@ run_test("render item", () => { }, $simplebar_container: $scroll_container, }); + get_item_called = false; widget_2.render_item(item); // Test that we didn't try to render the item. @@ -819,7 +822,7 @@ run_test("render item", () => { name: "replace-list", modifier: (item) => (rendering_item ? undefined : `${item}\n`), get_item, - html_selector: (item) => `tr[data-item='${item}']`, + html_selector: (item) => $(`tr[data-item='${item}']`), $simplebar_container: $scroll_container, }); // Once we have initially rendered the widget, change the