dropdown_list_widget: Add support for Multiselect dropdown list widget (MDLW).

This commit adds the support to select multiple dropdown items by inheriting
dropdown list widget and overriding some of it's properties.

The parameters that can be passed along with it are-

- widget_name: The desired name of the widget.
- data: The data that needs to be populated as dropdown items.
- default_text: The default text to be rendered when none of the items is selected.
- on_update: Function to trigger once the filter button is pressed.
- on_close: Function to trigger once the dropdown is successfully closed after filtering.
- value: The default value that is initially selected by user.
- limit: The maximum number of dropdown items to display on button text.

This widget can later be implemented in recent topic view to replace the
several ellipses filter button and also within the organisation user's page
to quickly sort the users list according to their org role.
This commit is contained in:
aryanshridhar 2021-07-27 12:35:58 +00:00 committed by Tim Abbott
parent 0d20a20b07
commit 7c588d4747
6 changed files with 563 additions and 4 deletions

View File

@ -12,9 +12,11 @@ const noop = () => {};
mock_esm("../../static/js/list_widget", {
create: () => ({init: noop}),
});
const {DropdownListWidget} = zrequire("dropdown_list_widget");
const setup_zjquery_data = (name) => {
const {DropdownListWidget, MultiSelectDropdownListWidget} = zrequire("dropdown_list_widget");
// For DropdownListWidget
const setup_dropdown_zjquery_data = (name) => {
const input_group = $(".input_group");
const reset_button = $(".dropdown_list_reset_button");
input_group.set_find_results(".dropdown_list_reset_button:enabled", reset_button);
@ -36,7 +38,7 @@ run_test("basic_functions", () => {
render_text: (text) => `rendered: ${text}`,
};
const {reset_button, $widget} = setup_zjquery_data(opts.widget_name);
const {reset_button, $widget} = setup_dropdown_zjquery_data(opts.widget_name);
const widget = new DropdownListWidget(opts);
@ -76,7 +78,125 @@ run_test("no_default_value", () => {
"warn",
"dropdown-list-widget: Called without a default value; using null value",
);
setup_zjquery_data(opts.widget_name);
setup_dropdown_zjquery_data(opts.widget_name);
const widget = new DropdownListWidget(opts);
assert.equal(widget.value(), "null-value");
});
// For MultiSelectDropdownListWidget
const setup_multiselect_dropdown_zjquery_data = function (name) {
$(`#${CSS.escape(name)}_widget`)[0] = {};
return setup_dropdown_zjquery_data(name);
};
run_test("basic MDLW functions", () => {
let updated_value;
const opts = {
widget_name: "my_setting",
data: ["one", "two", "three", "four"].map((x) => ({name: x, value: x})),
value: ["one"],
limit: 2,
on_update: (val) => {
updated_value = val;
},
default_text: $t({defaultMessage: "not set"}),
};
const {reset_button, $widget} = setup_multiselect_dropdown_zjquery_data(opts.widget_name);
const widget = new MultiSelectDropdownListWidget(opts);
function set_dropdown_variables(widget, value) {
widget.data_selected = value;
widget.checked_items = value;
}
assert.deepEqual(widget.value(), ["one"]);
assert.equal(updated_value, undefined);
assert.equal($widget.text(), "one");
assert.ok(reset_button.visible());
set_dropdown_variables(widget, ["one", "two"]);
widget.update(widget.data_selected);
assert.equal($widget.text(), "one,two");
assert.deepEqual(widget.value(), ["one", "two"]);
assert.deepEqual(updated_value, ["one", "two"]);
assert.ok(reset_button.visible());
set_dropdown_variables(widget, ["one", "two", "three"]);
widget.update(widget.data_selected);
assert.equal($widget.text(), "translated: 3 selected");
assert.deepEqual(widget.value(), ["one", "two", "three"]);
assert.deepEqual(updated_value, ["one", "two", "three"]);
assert.ok(reset_button.visible());
set_dropdown_variables(widget, null);
widget.update(widget.data_selected);
assert.equal($widget.text(), "translated: not set");
assert.equal(widget.value(), null);
assert.equal(updated_value, null);
assert.ok(!reset_button.visible());
set_dropdown_variables(widget, ["one"]);
widget.update(widget.data_selected);
assert.equal($widget.text(), "one");
assert.deepEqual(widget.value(), ["one"]);
assert.deepEqual(updated_value, ["one"]);
assert.ok(reset_button.visible());
});
run_test("MDLW no_default_value", () => {
const opts = {
widget_name: "my_setting",
data: ["one", "two", "three", "four"].map((x) => ({name: x, value: x})),
limit: 2,
null_value: "null-value",
default_text: $t({defaultMessage: "not set"}),
};
blueslip.expect(
"warn",
"dropdown-list-widget: Called without a default value; using null value",
);
setup_multiselect_dropdown_zjquery_data(opts.widget_name);
const widget = new MultiSelectDropdownListWidget(opts);
assert.equal(widget.value(), "null-value");
});
run_test("MDLW no_limit_set", () => {
const opts = {
widget_name: "my_setting",
data: ["one", "two", "three", "four"].map((x) => ({name: x, value: x})),
value: ["one"],
default_text: $t({defaultMessage: "not set"}),
};
blueslip.expect(
"warn",
"Multiselect dropdown-list-widget: Called without limit value; using 2 as the limit",
);
function set_dropdown_variables(widget, value) {
widget.data_selected = value;
widget.checked_items = value;
}
const {$widget} = setup_multiselect_dropdown_zjquery_data(opts.widget_name);
const widget = new MultiSelectDropdownListWidget(opts);
set_dropdown_variables(widget, ["one", "two", "three"]);
widget.update(widget.data_selected);
// limit is set to 2 (Default value).
assert.equal($widget.text(), "translated: 3 selected");
set_dropdown_variables(widget, ["one"]);
widget.update(widget.data_selected);
assert.equal($widget.text(), "one");
});

View File

@ -832,3 +832,95 @@ run_test("render item", () => {
widget_3.render_item(item);
blueslip.reset();
});
run_test("Multiselect dropdown retain_selected_items", () => {
const container = make_container();
const scroll_container = make_scroll_container();
const filter_element = make_filter_element();
let data_rendered = [];
const list = ["one", "two", "three", "four"].map((x) => ({name: x, value: x}));
const data = ["one"]; // Data initially selected.
container.html = () => {};
container.find = (elem) => DropdownItem(elem);
// We essentially create fake Jquery functions
// whose return value are stored in objects so that
// they can be later asserted with expected values.
function DropdownItem(element) {
const temp = {};
function length() {
if (element) {
return true;
}
return false;
}
function find(tag) {
return ListItem(tag, temp);
}
function addClass(cls) {
temp.appended_class = cls;
}
temp.element = element;
return {
length: length(),
find,
addClass,
};
}
function ListItem(element, temp) {
function expectOne() {
data_rendered.push(temp);
return ListItem(element, temp);
}
function prepend(data) {
temp.prepended_data = data.html();
}
return {
expectOne,
prepend,
};
}
const widget = ListWidget.create(container, list, {
name: "replace-list",
modifier: (item) => `<li data-value="${item.value}">${item.name}</li>\n`,
multiselect: {
selected_items: data,
},
filter: {
element: filter_element,
predicate: () => true,
},
simplebar_container: scroll_container,
});
const expected_value = [
{
element: 'li[data-value = "one"]',
appended_class: "checked",
prepended_data: "<i>",
},
];
assert.deepEqual(expected_value, data_rendered);
// Reset the variable and re execute the `widget.render` method.
data_rendered = [];
// Making sure!
assert.deepEqual(data_rendered, []);
widget.hard_redraw();
// Expect the `data_rendered` array to be same again.
assert.deepEqual(expected_value, data_rendered);
});

View File

@ -1,8 +1,10 @@
import $ from "jquery";
import _ from "lodash";
import render_dropdown_list from "../templates/settings/dropdown_list.hbs";
import * as blueslip from "./blueslip";
import {$t} from "./i18n";
import * as ListWidget from "./list_widget";
export function DropdownListWidget({
@ -238,3 +240,314 @@ DropdownListWidget.prototype.value = function () {
}
return val;
};
export function MultiSelectDropdownListWidget({
widget_name,
data,
default_text,
null_value = null,
on_update = () => {},
on_close,
value,
limit,
}) {
// A widget mostly similar to `DropdownListWidget` but
// used in cases of multiple dropdown selection.
// 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.
DropdownListWidget.call(this, {
widget_name,
data,
default_text,
null_value,
on_update,
value,
});
if (limit === undefined) {
this.limit = 2;
blueslip.warn(
"Multiselect dropdown-list-widget: Called without limit value; using 2 as the limit",
);
}
this.initialize_dropdown_values();
}
MultiSelectDropdownListWidget.prototype = Object.create(DropdownListWidget.prototype);
MultiSelectDropdownListWidget.prototype.initialize_dropdown_values = function () {
// 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.
MultiSelectDropdownListWidget.prototype.render_button_text = function (elem, limit) {
const items_selected = this.data_selected.length;
let text = "";
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});
}
elem.text(text);
elem.removeClass("text-warning");
elem.closest(".input-group").find(".dropdown_list_reset_button:enabled").show();
};
// Override the DrodownListWidget `render` function.
MultiSelectDropdownListWidget.prototype.render = function (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);
};
MultiSelectDropdownListWidget.prototype.dropdown_toggle_click_handler = function () {
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.
MultiSelectDropdownListWidget.prototype.reset_dropdown_items = function () {
// 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.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.
MultiSelectDropdownListWidget.prototype.setup_dropdown_widget = function (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.
MultiSelectDropdownListWidget.prototype.add_check_mark = function (element) {
const value = element.attr("data-value");
const link_elem = element.find("a").expectOne();
link_elem.prepend($("<i>", {class: "fa fa-check"}));
element.addClass("checked");
this.data_selected.push(value);
};
// Remove the check mark from dropdown element.
MultiSelectDropdownListWidget.prototype.remove_check_mark = function (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);
}
};
MultiSelectDropdownListWidget.prototype.dropdown_focus_events = function () {
// 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.
MultiSelectDropdownListWidget.prototype.register_event_handlers = function () {
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" && e.key !== "Enter") {
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.
MultiSelectDropdownListWidget.prototype.value = function () {
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;
};

View File

@ -190,6 +190,24 @@ export function create($container, list, opts) {
}
};
// Used in case of Multiselect DropdownListWidget to retain
// previously checked items even after widget redraws.
widget.retain_selected_items = function () {
const items = opts.multiselect;
if (items.selected_items) {
const data = items.selected_items;
for (const value of data) {
const list_item = $container.find(`li[data-value = "${value}"]`);
if (list_item.length) {
const link_elem = list_item.find("a").expectOne();
list_item.addClass("checked");
link_elem.prepend($("<i>", {class: "fa fa-check"}));
}
}
}
};
// Reads the provided list (in the scope directly above)
// and renders the next block of messages automatically
// into the specified container.
@ -224,6 +242,10 @@ export function create($container, list, opts) {
$container.append($(html));
meta.offset += load_count;
if (opts.multiselect) {
widget.retain_selected_items();
}
if (opts.callback_after_render) {
opts.callback_after_render();
}

View File

@ -1613,6 +1613,12 @@ body:not(.night-mode) #settings_page .custom_user_field .datepicker {
}
}
button.multiselect_btn {
/* Matches the dropdown input margin so as to keep it aligned */
margin-left: 9px;
margin-top: 4px;
}
a.dropdown_list_reset_button {
/* Prevent night mode from overriding background. */
background: unset !important;

View File

@ -15,6 +15,12 @@
</li>
<div class="dropdown-list-wrapper" data-simplebar>
<span class="dropdown-list-body"></span>
{{#if filter_button_text }}
<button class="button rounded small sea-green multiselect_btn" tabindex="0">
<i class="fa fa-filter" aria-hidden="true"></i>
{{filter_button_text}}
</button>
{{/if}}
</div>
</ul>
</span>