mirror of
https://github.com/zulip/zulip.git
synced 2025-11-19 05:58:25 +00:00
Ever since we started bundling the app with webpack, there’s been less and less overlap between our ‘static’ directory (files belonging to the frontend app) and Django’s interpretation of the ‘static’ directory (files served directly to the web). Split the app out to its own ‘web’ directory outside of ‘static’, and remove all the custom collectstatic --ignore rules. This makes it much clearer what’s actually being served to the web, and what’s being bundled by webpack. It also shrinks the release tarball by 3%. Signed-off-by: Anders Kaseorg <anders@zulip.com>
600 lines
20 KiB
JavaScript
600 lines
20 KiB
JavaScript
import $ from "jquery";
|
|
import _ from "lodash";
|
|
import tippy from "tippy.js";
|
|
|
|
import render_dropdown_list from "../templates/settings/dropdown_list.hbs";
|
|
|
|
import * as blueslip from "./blueslip";
|
|
import {$t} from "./i18n";
|
|
import * as keydown_util from "./keydown_util";
|
|
import * as ListWidget from "./list_widget";
|
|
|
|
export class DropdownListWidget {
|
|
constructor({
|
|
widget_name,
|
|
data,
|
|
default_text,
|
|
render_text = (item_name) => item_name,
|
|
null_value = null,
|
|
include_current_item = true,
|
|
value,
|
|
on_update = () => {},
|
|
}) {
|
|
// Initializing values
|
|
this.widget_name = widget_name;
|
|
this.data = data;
|
|
this.default_text = default_text;
|
|
this.render_text = render_text;
|
|
this.null_value = null_value;
|
|
this.include_current_item = include_current_item;
|
|
this.initial_value = value;
|
|
this.on_update = on_update;
|
|
|
|
this.container_id = `${widget_name}_widget`;
|
|
this.value_id = `id_${widget_name}`;
|
|
|
|
if (value === undefined) {
|
|
this.initial_value = null_value;
|
|
blueslip.warn("dropdown-list-widget: Called without a default value; using null value");
|
|
}
|
|
}
|
|
|
|
render_default_text($elem) {
|
|
$elem.text(this.default_text);
|
|
$elem.addClass("text-warning");
|
|
$elem.closest(".input-group").find(".dropdown_list_reset_button").hide();
|
|
}
|
|
|
|
render(value) {
|
|
$(`#${CSS.escape(this.container_id)} #${CSS.escape(this.value_id)}`).data("value", value);
|
|
|
|
const $elem = $(`#${CSS.escape(this.container_id)} #${CSS.escape(this.widget_name)}_name`);
|
|
|
|
if (!value || value === this.null_value) {
|
|
this.render_default_text($elem);
|
|
return;
|
|
}
|
|
|
|
// Happy path
|
|
const item = this.data.find((x) => x.value === value.toString());
|
|
|
|
if (item === undefined) {
|
|
this.render_default_text($elem);
|
|
return;
|
|
}
|
|
|
|
const text = this.render_text(item.name);
|
|
$elem.text(text);
|
|
$elem.removeClass("text-warning");
|
|
$elem.closest(".input-group").find(".dropdown_list_reset_button").show();
|
|
}
|
|
|
|
update(value) {
|
|
this.render(value);
|
|
this.on_update(value);
|
|
}
|
|
|
|
register_event_handlers() {
|
|
$(`#${CSS.escape(this.container_id)} .dropdown-list-body`).on(
|
|
"click keypress",
|
|
".list_item",
|
|
(e) => {
|
|
const $setting_elem = $(e.currentTarget).closest(
|
|
`.${CSS.escape(this.widget_name)}_setting`,
|
|
);
|
|
if (e.type === "keypress") {
|
|
if (keydown_util.is_enter_event(e)) {
|
|
$setting_elem.find(".dropdown-menu").dropdown("toggle");
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
const value = $(e.currentTarget).attr("data-value");
|
|
this.update(value);
|
|
},
|
|
);
|
|
$(`#${CSS.escape(this.container_id)} .dropdown_list_reset_button`).on("click", (e) => {
|
|
this.update(this.null_value);
|
|
e.preventDefault();
|
|
});
|
|
}
|
|
|
|
setup_dropdown_widget(data) {
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
const get_data = () => {
|
|
if (this.include_current_item) {
|
|
return data;
|
|
}
|
|
return data.filter((x) => x.value !== this.value.toString());
|
|
};
|
|
|
|
ListWidget.create($dropdown_list_body, get_data(data), {
|
|
name: `${CSS.escape(this.widget_name)}_list`,
|
|
modifier(item) {
|
|
return render_dropdown_list({item});
|
|
},
|
|
filter: {
|
|
$element: $search_input,
|
|
predicate(item, value) {
|
|
return item.name.toLowerCase().includes(value);
|
|
},
|
|
},
|
|
$simplebar_container: $(`#${CSS.escape(this.container_id)} .dropdown-list-wrapper`),
|
|
});
|
|
}
|
|
|
|
// Sets the focus to the ListWidget input once the dropdown button is clicked.
|
|
dropdown_toggle_click_handler() {
|
|
const $dropdown_toggle = $(`#${CSS.escape(this.container_id)} .dropdown-toggle`);
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
|
|
$dropdown_toggle.on("click", () => {
|
|
$search_input.val("").trigger("input");
|
|
});
|
|
}
|
|
|
|
dropdown_focus_events() {
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
const $dropdown_menu = $(`.${CSS.escape(this.widget_name)}_setting .dropdown-menu`);
|
|
|
|
const dropdown_elements = () => {
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
|
|
return $dropdown_list_body.children().find("a");
|
|
};
|
|
|
|
// Rest of the key handlers are supported by our
|
|
// bootstrap library.
|
|
$dropdown_menu.on("keydown", (e) => {
|
|
function trigger_element_focus($element) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
$element.trigger("focus");
|
|
}
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown": {
|
|
switch (e.target) {
|
|
case dropdown_elements().last()[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
case $search_input[0]:
|
|
trigger_element_focus(dropdown_elements().first());
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "ArrowUp": {
|
|
switch (e.target) {
|
|
case dropdown_elements().first()[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
case $search_input[0]:
|
|
trigger_element_focus(dropdown_elements().last());
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "Tab": {
|
|
switch (e.target) {
|
|
case $search_input[0]:
|
|
trigger_element_focus(dropdown_elements().first());
|
|
break;
|
|
case dropdown_elements().last()[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setup() {
|
|
// populate the dropdown
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
const $dropdown_toggle = $(`#${CSS.escape(this.container_id)} .dropdown-toggle`);
|
|
|
|
this.setup_dropdown_widget(this.data);
|
|
|
|
$(`#${CSS.escape(this.container_id)} .dropdown-search`).on("click", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
|
|
this.dropdown_toggle_click_handler();
|
|
|
|
$dropdown_toggle.on("focus", (e) => {
|
|
// On opening a Bootstrap Dropdown, the parent element receives focus.
|
|
// Here, we want our search input to have focus instead.
|
|
e.preventDefault();
|
|
// This function gets called twice when focusing the
|
|
// dropdown, and only in the second call is the input
|
|
// field visible in the DOM; so the following visibility
|
|
// check ensures we wait for the second call to focus.
|
|
if ($dropdown_list_body.is(":visible")) {
|
|
$search_input.trigger("focus");
|
|
}
|
|
});
|
|
|
|
this.dropdown_focus_events();
|
|
|
|
this.render(this.initial_value);
|
|
this.register_event_handlers();
|
|
}
|
|
|
|
// Returns the updated value
|
|
value() {
|
|
let val = $(`#${CSS.escape(this.container_id)} #${CSS.escape(this.value_id)}`).data(
|
|
"value",
|
|
);
|
|
if (val === null) {
|
|
val = "";
|
|
}
|
|
return val;
|
|
}
|
|
}
|
|
|
|
// A widget mostly similar to `DropdownListWidget` but
|
|
// used in cases of multiple dropdown selection.
|
|
export class MultiSelectDropdownListWidget extends DropdownListWidget {
|
|
constructor({
|
|
widget_name,
|
|
data,
|
|
default_text,
|
|
null_value = null,
|
|
on_update = () => {},
|
|
on_close,
|
|
value,
|
|
limit,
|
|
}) {
|
|
super({
|
|
widget_name,
|
|
data,
|
|
default_text,
|
|
null_value,
|
|
on_update,
|
|
value,
|
|
});
|
|
|
|
// Initializing values specific to `MultiSelectDropdownListWidget`.
|
|
this.limit = limit;
|
|
this.on_close = on_close;
|
|
|
|
// Important thing to note is that this needs to be maintained as
|
|
// a reference type and not to deep clone it/assign it to a
|
|
// different variable, so that it can be later referenced within
|
|
// `list_widget` as well. The way we manage dropdown elements are
|
|
// essentially by just modifying the values in `data_selected` variable.
|
|
this.data_selected = []; // Populate the dropdown values selected by user.
|
|
|
|
if (limit === undefined) {
|
|
this.limit = 2;
|
|
blueslip.warn(
|
|
"Multiselect dropdown-list-widget: Called without limit value; using 2 as the limit",
|
|
);
|
|
}
|
|
}
|
|
|
|
setup() {
|
|
super.setup(this);
|
|
this.initialize_dropdown_values();
|
|
}
|
|
|
|
initialize_dropdown_values() {
|
|
// Stop the execution if value parameter is undefined and null_value is passed.
|
|
if (!this.initial_value || this.initial_value === this.null_value) {
|
|
return;
|
|
}
|
|
const $elem = $(`#${CSS.escape(this.container_id)} #${CSS.escape(this.widget_name)}_name`);
|
|
|
|
// Push values from initial valued array to `data_selected`.
|
|
this.data_selected.push(...this.initial_value);
|
|
this.render_button_text($elem, this.limit);
|
|
}
|
|
|
|
// Set the button text as per the selected data.
|
|
render_button_text($elem, limit) {
|
|
const items_selected = this.data_selected.length;
|
|
let text = "";
|
|
|
|
// Destroy the tooltip once the button text reloads.
|
|
this.destroy_tooltip();
|
|
|
|
if (items_selected === 0) {
|
|
this.render_default_text($elem);
|
|
return;
|
|
} else if (limit >= items_selected) {
|
|
const data_selected = this.data.filter((data) =>
|
|
this.data_selected.includes(data.value),
|
|
);
|
|
text = data_selected.map((data) => data.name).toString();
|
|
} else {
|
|
text = $t({defaultMessage: "{items_selected} selected"}, {items_selected});
|
|
this.render_tooltip();
|
|
}
|
|
|
|
$elem.text(text);
|
|
$elem.removeClass("text-warning");
|
|
$elem.closest(".input-group").find(".dropdown_list_reset_button").show();
|
|
}
|
|
|
|
// Override the DrodownListWidget `render` function.
|
|
render(value) {
|
|
const $elem = $(`#${CSS.escape(this.container_id)} #${CSS.escape(this.widget_name)}_name`);
|
|
|
|
if (!value || value === this.null_value) {
|
|
this.render_default_text($elem);
|
|
return;
|
|
}
|
|
this.render_button_text($elem, this.limit);
|
|
}
|
|
|
|
dropdown_toggle_click_handler() {
|
|
const $dropdown_toggle = $(`#${CSS.escape(this.container_id)} .dropdown-toggle`);
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
|
|
$dropdown_toggle.on("click", () => {
|
|
this.reset_dropdown_items();
|
|
$search_input.val("").trigger("input");
|
|
});
|
|
}
|
|
|
|
// Cases where a user presses any dropdown item but accidentally closes
|
|
// the dropdown list.
|
|
reset_dropdown_items() {
|
|
// Clear the data selected and stop the execution once the user has
|
|
// pressed the `reset` button.
|
|
if (this.is_reset) {
|
|
this.data_selected.splice(0, this.data_selected.length);
|
|
return;
|
|
}
|
|
|
|
const original_items = this.checked_items ?? this.initial_value;
|
|
const items_added = _.difference(this.data_selected, original_items);
|
|
|
|
// Removing the unnecessary items from dropdown.
|
|
for (const val of items_added) {
|
|
const index = this.data_selected.indexOf(val);
|
|
if (index > -1) {
|
|
this.data_selected.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
// Items that are removed in dropdown but should have been a part of it
|
|
const items_removed = _.difference(original_items, this.data_selected);
|
|
this.data_selected.push(...items_removed);
|
|
}
|
|
|
|
// Override the DrodownListWidget `setup_dropdown_widget` function.
|
|
setup_dropdown_widget(data) {
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
|
|
ListWidget.create($dropdown_list_body, data, {
|
|
name: `${CSS.escape(this.widget_name)}_list`,
|
|
modifier(item) {
|
|
return render_dropdown_list({item});
|
|
},
|
|
multiselect: {
|
|
selected_items: this.data_selected,
|
|
},
|
|
filter: {
|
|
$element: $search_input,
|
|
predicate(item, value) {
|
|
return item.name.toLowerCase().includes(value);
|
|
},
|
|
},
|
|
$simplebar_container: $(`#${CSS.escape(this.container_id)} .dropdown-list-wrapper`),
|
|
});
|
|
}
|
|
|
|
// Add the check mark to dropdown element passed.
|
|
add_check_mark($element) {
|
|
const value = $element.attr("data-value");
|
|
const $link_elem = $element.find("a").expectOne();
|
|
$link_elem.prepend($("<i>").addClass(["fa", "fa-check"]));
|
|
$element.addClass("checked");
|
|
this.data_selected.push(value);
|
|
}
|
|
|
|
// Remove the check mark from dropdown element.
|
|
remove_check_mark($element) {
|
|
const $icon = $element.find("i").expectOne();
|
|
const value = $element.attr("data-value");
|
|
const index = this.data_selected.indexOf(value);
|
|
|
|
if (index > -1) {
|
|
$icon.remove();
|
|
$element.removeClass("checked");
|
|
this.data_selected.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
// Render the tooltip once the text changes to `n` selected.
|
|
render_tooltip() {
|
|
const $elem = $(`#${CSS.escape(this.container_id)}`);
|
|
const selected_items = this.data.filter((item) => this.checked_items.includes(item.value));
|
|
|
|
tippy($elem[0], {
|
|
content: selected_items.map((item) => item.name).join(", "),
|
|
placement: "top",
|
|
});
|
|
}
|
|
|
|
destroy_tooltip() {
|
|
const $elem = $(`#${CSS.escape(this.container_id)}`);
|
|
const tippy_instance = $elem[0]._tippy;
|
|
if (!tippy_instance) {
|
|
return;
|
|
}
|
|
|
|
tippy_instance.destroy();
|
|
}
|
|
|
|
dropdown_focus_events() {
|
|
// Main keydown event handler which transfers the focus from one child element
|
|
// to another.
|
|
|
|
const $search_input = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-search > input[type=text]`,
|
|
);
|
|
const $dropdown_menu = $(`.${CSS.escape(this.widget_name)}_setting .dropdown-menu`);
|
|
const $filter_button = $(`#${CSS.escape(this.container_id)} .multiselect_btn`);
|
|
|
|
const dropdown_elements = () => {
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
|
|
return $dropdown_list_body.children().find("a");
|
|
};
|
|
|
|
$dropdown_menu.on("keydown", (e) => {
|
|
function trigger_element_focus($element) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
$element.trigger("focus");
|
|
}
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown": {
|
|
switch (e.target) {
|
|
case dropdown_elements().last()[0]:
|
|
trigger_element_focus($filter_button);
|
|
break;
|
|
case $(`#${CSS.escape(this.container_id)} .multiselect_btn`)[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
case $search_input[0]:
|
|
trigger_element_focus(dropdown_elements().first());
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "ArrowUp": {
|
|
switch (e.target) {
|
|
case dropdown_elements().first()[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
case $search_input[0]:
|
|
trigger_element_focus($filter_button);
|
|
break;
|
|
case $(`#${CSS.escape(this.container_id)} .multiselect_btn`)[0]:
|
|
trigger_element_focus(dropdown_elements().last());
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "Tab": {
|
|
switch (e.target) {
|
|
case $search_input[0]:
|
|
trigger_element_focus(dropdown_elements().first());
|
|
break;
|
|
case $filter_button[0]:
|
|
trigger_element_focus($search_input);
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Override the `register_event_handlers` function.
|
|
register_event_handlers() {
|
|
const $dropdown_list_body = $(
|
|
`#${CSS.escape(this.container_id)} .dropdown-list-body`,
|
|
).expectOne();
|
|
|
|
$dropdown_list_body.on("click keypress", ".list_item", (e) => {
|
|
if (e.type === "keypress" && !keydown_util.is_enter_event(e)) {
|
|
return;
|
|
}
|
|
|
|
const $element = e.target.closest("li");
|
|
if ($element.hasClass("checked")) {
|
|
this.remove_check_mark($element);
|
|
} else {
|
|
this.add_check_mark($element);
|
|
}
|
|
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$(`#${CSS.escape(this.container_id)} .dropdown_list_reset_button`).on("click", (e) => {
|
|
// Default back the values.
|
|
this.is_reset = true;
|
|
this.checked_items = undefined;
|
|
|
|
this.update(this.null_value);
|
|
e.preventDefault();
|
|
});
|
|
|
|
$(`#${CSS.escape(this.container_id)} .multiselect_btn`).on("click", (e) => {
|
|
e.preventDefault();
|
|
|
|
// Set the value to `false` to end the scope of the
|
|
// `reset` button.
|
|
this.is_reset = false;
|
|
// We deep clone the values of `data_selected` to a new
|
|
// variable. This is so because arrays are reference types
|
|
// and modifying the parent array can change the values
|
|
// within the child array. Here, `checked_items` copies over the
|
|
// value and not just the reference.
|
|
this.checked_items = _.cloneDeep(this.data_selected);
|
|
this.update(this.data_selected);
|
|
|
|
// Cases when the user wants to pass a successful event after
|
|
// the dropdown is closed.
|
|
if (this.on_close) {
|
|
e.stopPropagation();
|
|
const $setting_elem = $(e.currentTarget).closest(
|
|
`.${CSS.escape(this.widget_name)}_setting`,
|
|
);
|
|
$setting_elem.find(".dropdown-menu").dropdown("toggle");
|
|
|
|
this.on_close();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Returns array of values selected by user.
|
|
value() {
|
|
let val = this.checked_items;
|
|
// Cases taken care of -
|
|
// - User never pressed the filter button -> We return the initial value.
|
|
// - User pressed the `reset` button -> We return an empty array.
|
|
if (val === undefined) {
|
|
val = this.is_reset ? [] : this.initial_value;
|
|
}
|
|
return val;
|
|
}
|
|
}
|