From a9dc669f3c64f27616461b3e8250a0be75472c12 Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Mon, 19 Feb 2024 13:22:38 +0000 Subject: [PATCH] dropdown_widget: Migrate to typescript. --- tools/test-js-with-node | 2 +- ...{dropdown_widget.js => dropdown_widget.ts} | 215 +++++++++++------- web/src/integration_url_modal.js | 4 +- web/src/list_widget.ts | 2 +- web/src/settings_org.js | 10 +- web/src/stream_edit.js | 2 +- web/src/stream_settings_components.js | 2 +- web/src/user_group_components.js | 2 +- web/src/user_profile.js | 2 +- web/src/views_util.js | 2 +- web/tests/recent_view.test.js | 2 +- 11 files changed, 151 insertions(+), 94 deletions(-) rename web/src/{dropdown_widget.js => dropdown_widget.ts} (64%) diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 357ca2e362..b6be706715 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -98,7 +98,7 @@ EXEMPT_FILES = make_set( "web/src/dialog_widget.ts", "web/src/drafts.js", "web/src/drafts_overlay_ui.js", - "web/src/dropdown_widget.js", + "web/src/dropdown_widget.ts", "web/src/echo.js", "web/src/electron_bridge.d.ts", "web/src/emoji_picker.js", diff --git a/web/src/dropdown_widget.js b/web/src/dropdown_widget.ts similarity index 64% rename from web/src/dropdown_widget.js rename to web/src/dropdown_widget.ts index 638cd7f463..67191a3c42 100644 --- a/web/src/dropdown_widget.js +++ b/web/src/dropdown_widget.ts @@ -1,4 +1,5 @@ import $ from "jquery"; +import assert from "minimalistic-assert"; import * as tippy from "tippy.js"; import render_dropdown_current_value_not_in_options from "../templates/dropdown_current_value_not_in_options.hbs"; @@ -9,79 +10,126 @@ import render_inline_decorated_stream_name from "../templates/inline_decorated_s import * as blueslip from "./blueslip"; import * as ListWidget from "./list_widget"; +import type {ListWidget as ListWidgetType} from "./list_widget"; import {page_params} from "./page_params"; import {default_popover_props} from "./popover_menus"; +import type {StreamSubscription} from "./sub_store"; import {parse_html} from "./ui_util"; /* Sync with max-height set in zulip.css */ export const DEFAULT_DROPDOWN_HEIGHT = 210; -const noop = () => {}; -export const DATA_TYPES = { - NUMBER: "number", - STRING: "string", +const noop = (): void => { + // Empty function for default values. +}; + +export enum DataTypes { + NUMBER = "number", + STRING = "string", +} + +type Option = { + unique_id: number | string; + name: string; + is_setting_disabled?: boolean; + stream?: StreamSubscription; +}; + +type DropdownWidgetOptions = { + widget_name: string; + // You can bold the selected `option` by setting `option.bold_current_selection` to `true`. + // Currently, not implemented for stream names. + get_options: () => Option[]; + item_click_callback: ( + event: JQuery.ClickEvent, + instance: tippy.Instance, + widget: DropdownWidget, + ) => void; + // Provide an parent element to widget which will be re-rendered if the widget is setup again. + // It is important to not pass `$("body")` here for widgets that would be `setup()` + // multiple times, so that we don't have duplicate event handlers. + $events_container: JQuery; + on_show_callback?: (instance: tippy.Instance) => void; + on_mount_callback?: (instance: tippy.Instance) => void; + on_hidden_callback?: (instance: tippy.Instance) => void; + on_exit_with_escape_callback?: () => void; + render_selected_option?: () => void; + // Used to focus the `target` after dropdown is closed. This is important since the dropdown is + // appended to `body` and hence `body` is focused when the dropdown is closed, which makes + // it hard for the user to get focus back to the `target`. + focus_target_on_hidden?: boolean; + tippy_props?: Partial; + // NOTE: Any value other than `undefined` will be rendered when class is initialized. + default_id?: string | number; + unique_id_type: DataTypes; + // Text to show if the current value is not in `get_options()`. + text_if_current_value_not_in_options?: string; + hide_search_box?: boolean; + // Disable the widget for spectators. + disable_for_spectators?: boolean; }; export class DropdownWidget { - constructor({ - widget_name, - // You can bold the selected `option` by setting `option.bold_current_selection` to `true`. - // Currently, not implemented for stream names. - get_options, - item_click_callback, - // Provide an parent element to widget which will be re-rendered if the widget is setup again. - // It is important to not pass `$("body")` here for widgets that would be `setup()` - // multiple times, so that we don't have duplicate event handlers. - $events_container, - on_show_callback = noop, - on_mount_callback = noop, - on_hidden_callback = noop, - on_exit_with_escape_callback = noop, - render_selected_option = noop, - // Used to focus the `target` after dropdown is closed. This is important since the dropdown is - // appended to `body` and hence `body` is focused when the dropdown is closed, which makes - // it hard for the user to get focus back to the `target`. - focus_target_on_hidden = true, - tippy_props = {}, - // NOTE: Any value other than `null` will be rendered when class is initialized. - default_id = null, - unique_id_type = null, - // Text to show if the current value is not in `get_options()`. - text_if_current_value_not_in_options = null, - hide_search_box = false, - // Disable the widget for spectators. - disable_for_spectators = false, - }) { - this.widget_name = widget_name; - this.widget_id = `#${CSS.escape(widget_name)}_widget`; + widget_name: string; + widget_id: string; + widget_wrapper_id: string; + widget_value_selector: string; + get_options: () => Option[]; + item_click_callback: ( + event: JQuery.ClickEvent, + instance: tippy.Instance, + widget: DropdownWidget, + ) => void; + focus_target_on_hidden: boolean; + on_show_callback: (instance: tippy.Instance) => void; + on_mount_callback: (instance: tippy.Instance) => void; + on_hidden_callback: (instance: tippy.Instance) => void; + on_exit_with_escape_callback: () => void; + render_selected_option: () => void; + tippy_props: Partial; + list_widget: ListWidgetType | undefined; + instance: tippy.Instance | undefined; + default_id: string | number | undefined; + current_value: string | number | undefined; + unique_id_type: DataTypes; + $events_container: JQuery; + text_if_current_value_not_in_options: string; + hide_search_box: boolean; + disable_for_spectators: boolean; + + constructor(options: DropdownWidgetOptions) { + this.widget_name = options.widget_name; + this.widget_id = `#${CSS.escape(this.widget_name)}_widget`; // A widget wrapper may not exist based on the UI requirement. this.widget_wrapper_id = `${this.widget_id}_wrapper`; this.widget_value_selector = `${this.widget_id} .dropdown_widget_value`; - this.get_options = get_options; - this.item_click_callback = item_click_callback; - this.focus_target_on_hidden = focus_target_on_hidden; - this.on_show_callback = on_show_callback; - this.on_mount_callback = on_mount_callback; - this.on_hidden_callback = on_hidden_callback; - this.on_exit_with_escape_callback = on_exit_with_escape_callback; - this.render_selected_option = render_selected_option; - this.tippy_props = tippy_props; - this.list_widget = null; - this.instance = null; - this.default_id = default_id; - this.current_value = default_id; - this.unique_id_type = unique_id_type; - this.$events_container = $events_container; - this.text_if_current_value_not_in_options = text_if_current_value_not_in_options; - this.hide_search_box = hide_search_box; - this.disable_for_spectators = disable_for_spectators; + this.get_options = options.get_options; + this.item_click_callback = options.item_click_callback; + this.focus_target_on_hidden = options.focus_target_on_hidden ?? true; + this.on_show_callback = options.on_show_callback ?? noop; + this.on_mount_callback = options.on_mount_callback ?? noop; + this.on_hidden_callback = options.on_hidden_callback ?? noop; + this.on_exit_with_escape_callback = options.on_exit_with_escape_callback ?? noop; + this.render_selected_option = options.render_selected_option ?? noop; + // These properties can override any tippy props. + this.tippy_props = options.tippy_props ?? {}; + this.list_widget = undefined; + this.instance = undefined; + this.default_id = options.default_id; + this.current_value = this.default_id; + this.unique_id_type = options.unique_id_type; + this.$events_container = options.$events_container; + this.text_if_current_value_not_in_options = + options.text_if_current_value_not_in_options ?? ""; + this.hide_search_box = options.hide_search_box ?? false; + this.disable_for_spectators = options.disable_for_spectators ?? false; } - init() { + init(): void { // NOTE: Widget should only be initialized again if the events_container was rendered again to // avoid duplicate events to be attached to events_container. // Don't attach any events or classes to any element other than `events_container` here, otherwise // the attached events / classes will be lost when the widget is rendered again without initialing the widget again. - if (this.current_value !== null) { + if (this.current_value !== undefined) { this.render(); } @@ -90,7 +138,7 @@ export class DropdownWidget { `${this.widget_id}, ${this.widget_wrapper_id}`, (e) => { if (e.key === "Enter") { - $(`${this.widget_id}`).trigger("click"); + $(this.widget_id).trigger("click"); e.stopPropagation(); e.preventDefault(); } @@ -110,7 +158,8 @@ export class DropdownWidget { } } - show_empty_if_no_items($popper) { + show_empty_if_no_items($popper: JQuery): void { + assert(this.list_widget !== undefined); const list_items = this.list_widget.get_current_list(); const $no_search_results = $popper.find(".no-dropdown-items"); if (list_items.length === 0) { @@ -120,14 +169,15 @@ export class DropdownWidget { } } - setup() { + setup(): void { this.init(); const delegate_container = this.$events_container.get(0); - if (!delegate_container) { + if (delegate_container === undefined) { blueslip.error( "Cannot initialize dropdown. `$events_container` empty.", this.$events_container, ); + return; } this.instance = tippy.delegate(delegate_container, { ...default_popover_props, @@ -135,7 +185,7 @@ export class DropdownWidget { // Custom theme defined in popovers.css theme: "dropdown-widget", arrow: false, - onShow: function (instance) { + onShow: (instance: tippy.Instance) => { instance.setContent( parse_html( render_dropdown_list_container({ @@ -146,7 +196,9 @@ export class DropdownWidget { ); const $popper = $(instance.popper); const $dropdown_list_body = $popper.find(".dropdown-list"); - const $search_input = $popper.find(".dropdown-list-search-input"); + const $search_input = $popper.find( + "input.dropdown-list-search-input", + ); this.list_widget = ListWidget.create($dropdown_list_body, this.get_options(), { name: `${CSS.escape(this.widget_name)}-list-widget`, @@ -169,7 +221,7 @@ export class DropdownWidget { // Keyboard handler $popper.on("keydown", (e) => { - function trigger_element_focus($element) { + function trigger_element_focus($element: JQuery): void { e.preventDefault(); e.stopPropagation(); // When bringing a non-visible element into view, scroll as minimum as possible. @@ -178,31 +230,34 @@ export class DropdownWidget { } const $search_input = $popper.find(".dropdown-list-search-input"); + assert(this.list_widget !== undefined); const list_items = this.list_widget.get_current_list(); if (list_items.length === 0 && !(e.key === "Escape")) { // Let the browser handle it. return; } - function first_item() { + function first_item(): JQuery { const first_item = list_items[0]; return $popper.find(`.list-item[data-unique-id="${first_item.unique_id}"]`); } - function last_item() { + function last_item(): JQuery { const last_item = list_items.at(-1); + assert(last_item !== undefined); return $popper.find(`.list-item[data-unique-id="${last_item.unique_id}"]`); } - const render_all_items_and_focus_last_item = function () { + const render_all_items_and_focus_last_item = (): void => { + assert(this.list_widget !== undefined); // List widget doesn't render all items by default, so we need to render all // the items and focus on the last element. const list_items = this.list_widget.get_current_list(); this.list_widget.render(list_items.length); trigger_element_focus(last_item()); - }.bind(this); + }; - const handle_arrow_down_on_last_item = () => { + const handle_arrow_down_on_last_item = (): void => { if (this.hide_search_box) { trigger_element_focus(first_item()); } else { @@ -210,7 +265,7 @@ export class DropdownWidget { } }; - const handle_arrow_up_on_first_item = () => { + const handle_arrow_up_on_first_item = (): void => { if (this.hide_search_box) { render_all_items_and_focus_last_item(); } else { @@ -268,8 +323,10 @@ export class DropdownWidget { // Click on item. $popper.one("click", ".list-item", (event) => { - this.current_value = $(event.currentTarget).attr("data-unique-id"); - if (this.unique_id_type === DATA_TYPES.NUMBER) { + const selected_unique_id = $(event.currentTarget).attr("data-unique-id"); + assert(selected_unique_id !== undefined); + this.current_value = selected_unique_id; + if (this.unique_id_type === DataTypes.NUMBER) { this.current_value = Number.parseInt(this.current_value, 10); } this.item_click_callback(event, instance, this); @@ -285,32 +342,32 @@ export class DropdownWidget { }, 0); this.on_show_callback(instance); - }.bind(this), - onMount: function (instance) { + }, + onMount: (instance: tippy.Instance) => { this.show_empty_if_no_items($(instance.popper)); this.on_mount_callback(instance); - }.bind(this), - onHidden: function (instance) { + }, + onHidden: (instance: tippy.Instance) => { if (this.focus_target_on_hidden) { $(this.widget_id).trigger("focus"); } this.on_hidden_callback(instance); - this.instance = null; - }.bind(this), + this.instance = undefined; + }, ...this.tippy_props, }); } - value() { + value(): number | string | undefined { return this.current_value; } // NOTE: This function needs to be explicitly called when you want to update the // current value of the widget. We don't call this automatically since some of our // dropdowns don't need it. Maybe we can follow a reverse approach in the future. - render(value) { + render(value?: number | string): void { // Check if the value is valid otherwise just render previous value. - if (typeof value === typeof this.current_value) { + if (value !== undefined && typeof value === typeof this.current_value) { this.current_value = value; } diff --git a/web/src/integration_url_modal.js b/web/src/integration_url_modal.js index a962ccd8cd..891bb5d4d1 100644 --- a/web/src/integration_url_modal.js +++ b/web/src/integration_url_modal.js @@ -95,7 +95,7 @@ export function show_generate_integration_url_modal(api_key) { placement: "bottom-start", }, default_id: default_integration_option.unique_id, - unique_id_type: dropdown_widget.DATA_TYPES.STRING, + unique_id_type: dropdown_widget.DataTypes.STRING, }); integration_input_dropdown_widget.setup(); @@ -130,7 +130,7 @@ export function show_generate_integration_url_modal(api_key) { placement: "bottom-start", }, default_id: direct_messages_option.unique_id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, }); stream_input_dropdown_widget.setup(); diff --git a/web/src/list_widget.ts b/web/src/list_widget.ts index 562375605c..16b364cdaa 100644 --- a/web/src/list_widget.ts +++ b/web/src/list_widget.ts @@ -59,7 +59,7 @@ type BaseListWidget = { clear_event_handlers: () => void; }; -type ListWidget = BaseListWidget & { +export type ListWidget = BaseListWidget & { get_current_list: () => Item[]; filter_and_sort: () => void; retain_selected_items: () => void; diff --git a/web/src/settings_org.js b/web/src/settings_org.js index bee907b196..de5bf95b88 100644 --- a/web/src/settings_org.js +++ b/web/src/settings_org.js @@ -652,7 +652,7 @@ export function init_dropdown_widgets() { placement: "bottom-start", }, default_id: realm.realm_new_stream_announcements_stream_id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, text_if_current_value_not_in_options: $t({defaultMessage: "Cannot view stream"}), }); settings_components.set_new_stream_announcements_stream_widget( @@ -675,7 +675,7 @@ export function init_dropdown_widgets() { placement: "bottom-start", }, default_id: realm.realm_signup_announcements_stream_id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, text_if_current_value_not_in_options: $t({defaultMessage: "Cannot view stream"}), }); settings_components.set_signup_announcements_stream_widget(signup_announcements_stream_widget); @@ -700,7 +700,7 @@ export function init_dropdown_widgets() { }, $events_container: $("#settings_overlay_container #organization-settings"), default_id: realm.realm_default_code_block_language, - unique_id_type: dropdown_widget.DATA_TYPES.STRING, + unique_id_type: dropdown_widget.DataTypes.STRING, tippy_props: { placement: "bottom-start", }, @@ -734,7 +734,7 @@ export function init_dropdown_widgets() { placement: "bottom-start", }, default_id: realm.realm_create_multiuse_invite_group, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, on_mount_callback(dropdown) { $(dropdown.popper).css("min-width", "300px"); }, @@ -763,7 +763,7 @@ export function init_dropdown_widgets() { placement: "bottom-start", }, default_id: realm.realm_can_access_all_users_group, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, on_mount_callback(dropdown) { $(dropdown.popper).css("min-width", "300px"); }, diff --git a/web/src/stream_edit.js b/web/src/stream_edit.js index a5797cec36..da4b2341f6 100644 --- a/web/src/stream_edit.js +++ b/web/src/stream_edit.js @@ -215,7 +215,7 @@ function setup_dropdown(sub, slim_sub) { placement: "bottom-start", }, default_id: sub.can_remove_subscribers_group, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, on_mount_callback(dropdown) { $(dropdown.popper).css("min-width", "300px"); }, diff --git a/web/src/stream_settings_components.js b/web/src/stream_settings_components.js index 17eb32aa03..03a5f9c0fc 100644 --- a/web/src/stream_settings_components.js +++ b/web/src/stream_settings_components.js @@ -86,7 +86,7 @@ export function dropdown_setup() { }, default_text: $t({defaultMessage: "No user groups"}), default_id: user_groups.get_user_group_from_name("role:administrators").id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, }); } diff --git a/web/src/user_group_components.js b/web/src/user_group_components.js index 8c99dba768..4dac631a6a 100644 --- a/web/src/user_group_components.js +++ b/web/src/user_group_components.js @@ -41,7 +41,7 @@ export function setup_permissions_dropdown(group, for_group_creation) { placement: "bottom-start", }, default_id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, on_mount_callback(dropdown) { $(dropdown.popper).css("min-width", "300px"); }, diff --git a/web/src/user_profile.js b/web/src/user_profile.js index 816a6f29dc..a4b76e2b38 100644 --- a/web/src/user_profile.js +++ b/web/src/user_profile.js @@ -617,7 +617,7 @@ export function show_edit_bot_info_modal(user_id, $container) { placement: "bottom-start", }, default_id: owner_id, - unique_id_type: dropdown_widget.DATA_TYPES.NUMBER, + unique_id_type: dropdown_widget.DataTypes.NUMBER, }); bot_owner_dropdown_widget.setup(); diff --git a/web/src/views_util.js b/web/src/views_util.js index 7857c70414..615128fab4 100644 --- a/web/src/views_util.js +++ b/web/src/views_util.js @@ -27,7 +27,7 @@ export const COMMON_DROPDOWN_WIDGET_PARAMS = { placement: "bottom-start", offset: [0, 2], }, - unique_id_type: dropdown_widget.DATA_TYPES.STRING, + unique_id_type: dropdown_widget.DataTypes.STRING, hide_search_box: true, bold_current_selection: true, disable_for_spectators: true, diff --git a/web/tests/recent_view.test.js b/web/tests/recent_view.test.js index b01eb7a9ef..cfd4ab5a36 100644 --- a/web/tests/recent_view.test.js +++ b/web/tests/recent_view.test.js @@ -181,7 +181,7 @@ mock_esm("../src/resize", { update_recent_view_filters_height: noop, }); const dropdown_widget = mock_esm("../src/dropdown_widget", { - DATA_TYPES: {NUMBER: "number", STRING: "string"}, + DataTypes: {NUMBER: "number", STRING: "string"}, }); dropdown_widget.DropdownWidget = function DropdownWidget() { this.setup = noop;