"use strict"; const {strict: assert} = require("assert"); const {mock_esm, mock_jquery, zrequire} = require("../zjsunit/namespace"); const {run_test} = require("../zjsunit/test"); const blueslip = require("../zjsunit/zblueslip"); // We need these stubs to get by instanceof checks. // The ListWidget library allows you to insert objects // that are either jQuery, Element, or just raw HTML // strings. We initially test with raw strings. const ui = mock_esm("../../static/js/ui"); // We only need very simple jQuery wrappers for when the // "real" code wraps html or sets up click handlers. // We'll simulate most other objects ourselves. mock_jquery((arg) => { if (arg.to_jquery) { return arg.to_jquery(); } return { addClass() { return this; }, replace: (regex, string) => { arg = arg.replace(regex, string); }, html: () => arg, }; }); const ListWidget = zrequire("list_widget"); // We build objects here that simulate jQuery containers. // The main thing to do at first is simulate that our // scroll container is the nearest ancestor to our main // container that has a max-height attribute, and then // the scroll container will have a scroll event attached to // it. This is a good time to read set_up_event_handlers // in the real code. function make_container() { const $container = {}; $container.empty = () => {}; // Make our append function just set a field we can // check in our tests. $container.append = ($data) => { $container.$appended_data = $data; }; return $container; } function make_scroll_container() { const $scroll_container = {}; $scroll_container.cleared = false; // Capture the scroll callback so we can call it in // our tests. $scroll_container.on = (ev, f) => { assert.equal(ev, "scroll.list_widget_container"); $scroll_container.call_scroll = () => { f.call($scroll_container); }; }; $scroll_container.off = (ev) => { assert.equal(ev, "scroll.list_widget_container"); $scroll_container.cleared = true; }; return $scroll_container; } function make_sort_container() { const $sort_container = {}; $sort_container.cleared = false; $sort_container.on = (ev, sel, f) => { assert.equal(ev, "click.list_widget_sort"); assert.equal(sel, "[data-sort]"); $sort_container.f = f; }; $sort_container.off = (ev) => { assert.equal(ev, "click.list_widget_sort"); $sort_container.cleared = true; }; return $sort_container; } function make_filter_element() { const $element = {}; $element.cleared = false; $element.on = (ev, f) => { assert.equal(ev, "input.list_widget_filter"); $element.f = f; }; $element.off = (ev) => { assert.equal(ev, "input.list_widget_filter"); $element.cleared = true; }; return $element; } function make_search_input() { const $element = {}; // Allow ourselves to be wrapped by $(...) and // return ourselves. /* istanbul ignore next */ $element.to_jquery = () => $element; $element.on = (event_name, f) => { assert.equal(event_name, "input.list_widget_filter"); $element.simulate_input_event = () => { const elem = { value: $element.val(), }; f.call(elem); }; }; return $element; } function div(item) { return "
" + item + "
"; } run_test("scrolling", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const items = []; let get_scroll_element_called = false; ui.get_scroll_element = ($element) => { get_scroll_element_called = true; return $element; }; for (let i = 0; i < 200; i += 1) { items.push("item " + i); } const opts = { modifier: (item) => item, $simplebar_container: $scroll_container, }; ListWidget.create($container, items, opts); assert.deepEqual($container.$appended_data.html(), items.slice(0, 80).join("")); assert.equal(get_scroll_element_called, true); // Set up our fake geometry so it forces a scroll action. $scroll_container.scrollTop = 180; $scroll_container.clientHeight = 100; $scroll_container.scrollHeight = 260; // Scrolling gets the next two elements from the list into // our widget. $scroll_container.call_scroll(); assert.deepEqual($container.$appended_data.html(), items.slice(80, 100).join("")); }); run_test("not_scrolling", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const items = []; let get_scroll_element_called = false; ui.get_scroll_element = ($element) => { get_scroll_element_called = true; return $element; }; let post_scroll__pre_render_callback_called = false; const post_scroll__pre_render_callback = () => { post_scroll__pre_render_callback_called = true; }; let get_min_load_count_called = false; const get_min_load_count = (offset, load_count) => { get_min_load_count_called = true; return load_count; }; for (let i = 0; i < 200; i += 1) { items.push("item " + i); } const opts = { modifier: (item) => item, $simplebar_container: $scroll_container, is_scroll_position_for_render: () => false, post_scroll__pre_render_callback, get_min_load_count, }; ListWidget.create($container, items, opts); assert.deepEqual($container.$appended_data.html(), items.slice(0, 80).join("")); assert.equal(get_scroll_element_called, true); // Set up our fake geometry. $scroll_container.scrollTop = 180; $scroll_container.clientHeight = 100; $scroll_container.scrollHeight = 260; // Since `should_render` is always false, no elements will be // added regardless of scrolling. $scroll_container.call_scroll(); // $appended_data remains the same. assert.deepEqual($container.$appended_data.html(), items.slice(0, 80).join("")); assert.equal(post_scroll__pre_render_callback_called, true); assert.equal(get_min_load_count_called, true); }); run_test("filtering", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const $search_input = make_search_input(); const list = ["apple", "banana", "carrot", "dog", "egg", "fence", "grape"]; const opts = { filter: { $element: $search_input, predicate: (item, value) => item.includes(value), }, modifier: (item) => div(item), $simplebar_container: $scroll_container, }; const widget = ListWidget.create($container, list, opts); let expected_html = "
apple
" + "
banana
" + "
carrot
" + "
dog
" + "
egg
" + "
fence
" + "
grape
"; assert.deepEqual($container.$appended_data.html(), expected_html); // Filtering will pick out dog/egg/grape when we put "g" // into our search input. (This uses the default filter, which // is a glorified indexOf call.) $search_input.val = () => "g"; $search_input.simulate_input_event(); assert.deepEqual(widget.get_current_list(), ["dog", "egg", "grape"]); expected_html = "
dog
egg
grape
"; assert.deepEqual($container.$appended_data.html(), expected_html); // We can insert new data into the widget. const new_data = ["greta", "faye", "gary", "frank", "giraffe", "fox"]; widget.replace_list_data(new_data); expected_html = "
greta
gary
giraffe
"; assert.deepEqual($container.$appended_data.html(), expected_html); }); run_test("no filtering", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); let callback_called = false; // Opts does not require a filter key. const opts = { modifier: (item) => div(item), $simplebar_container: $scroll_container, callback_after_render: () => { callback_called = true; }, }; const widget = ListWidget.create($container, ["apple", "banana"], opts); widget.render(); assert.deepEqual(callback_called, true); const expected_html = "
apple
banana
"; assert.deepEqual($container.$appended_data.html(), expected_html); }); function sort_button(opts) { // The complications here are due to needing to find // the list via complicated HTML assumptions. Also, we // don't have any abstraction for the button and its // siblings other than direct jQuery actions. function data(sel) { switch (sel) { case "sort": return opts.sort_type; case "sort-prop": return opts.prop_name; /* istanbul ignore next */ default: throw new Error("unknown selector: " + sel); } } function lookup(sel, value) { return (selector) => { assert.equal(sel, selector); return value; }; } const classList = new Set(); const $button = { data, closest: lookup(".progressive-table-wrapper", { data: lookup("list-widget", opts.list_name), }), addClass: (cls) => { classList.add(cls); }, hasClass: (cls) => classList.has(cls), removeClass: (cls) => { classList.delete(cls); }, siblings: lookup(".active", { removeClass: (cls) => { assert.equal(cls, "active"); $button.siblings_deactivated = true; }, }), siblings_deactivated: false, to_jquery: () => $button, }; return $button; } run_test("wire up filter element", () => { const lst = ["alice", "JESSE", "moses", "scott", "Sean", "Xavier"]; const $container = make_container(); const $scroll_container = make_scroll_container(); const $filter_element = make_filter_element(); const opts = { filter: { filterer: (list, value) => list.filter((item) => item.toLowerCase().includes(value)), $element: $filter_element, }, modifier: (s) => "(" + s + ")", $simplebar_container: $scroll_container, }; ListWidget.create($container, lst, opts); $filter_element.f.apply({value: "se"}); assert.equal($container.$appended_data.html(), "(JESSE)(moses)(Sean)"); }); run_test("sorting", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const $sort_container = make_sort_container(); let cleared; $container.empty = () => { cleared = true; }; const alice = {name: "alice", salary: 50}; const bob = {name: "Bob", salary: 40}; const cal = {name: "cal", salary: 30}; const dave = {name: "dave", salary: 25}; const ellen = {name: "ellen", salary: 95}; const list = [bob, ellen, dave, alice, cal]; const opts = { name: "sorting-list", $parent_container: $sort_container, modifier: (item) => div(item.name) + div(item.salary), filter: { predicate: () => true, }, $simplebar_container: $scroll_container, }; function html_for(people) { return people.map((item) => opts.modifier(item)).join(""); } ListWidget.create($container, list, opts); let button_opts; let $button; let expected_html; button_opts = { sort_type: "alphabetic", prop_name: "name", list_name: "my-list", active: false, }; $button = sort_button(button_opts); $sort_container.f.apply($button); assert.ok(cleared); assert.ok($button.siblings_deactivated); expected_html = html_for([alice, bob, cal, dave, ellen]); assert.deepEqual($container.$appended_data.html(), expected_html); // Hit same button again to reverse the data. cleared = false; $sort_container.f.apply($button); assert.ok(cleared); expected_html = html_for([ellen, dave, cal, bob, alice]); assert.deepEqual($container.$appended_data.html(), expected_html); assert.ok($button.hasClass("descend")); // And then hit a third time to go back to the forward sort. cleared = false; $sort_container.f.apply($button); assert.ok(cleared); expected_html = html_for([alice, bob, cal, dave, ellen]); assert.deepEqual($container.$appended_data.html(), expected_html); assert.ok(!$button.hasClass("descend")); // Now try a numeric sort. button_opts = { sort_type: "numeric", prop_name: "salary", list_name: "my-list", active: false, }; $button = sort_button(button_opts); cleared = false; $button.siblings_deactivated = false; $sort_container.f.apply($button); assert.ok(cleared); assert.ok($button.siblings_deactivated); expected_html = html_for([dave, cal, bob, alice, ellen]); assert.deepEqual($container.$appended_data.html(), expected_html); // Hit same button again to reverse the numeric sort. cleared = false; $sort_container.f.apply($button); assert.ok(cleared); expected_html = html_for([ellen, alice, bob, cal, dave]); assert.deepEqual($container.$appended_data.html(), expected_html); assert.ok($button.hasClass("descend")); }); run_test("custom sort", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const n42 = {x: 6, y: 7}; const n43 = {x: 1, y: 43}; const n44 = {x: 4, y: 11}; const list = [n42, n43, n44]; function sort_by_x(a, b) { return a.x - b.x; } function sort_by_product(a, b) { return a.x * a.y - b.x * b.y; } ListWidget.create($container, list, { name: "custom-sort-list", modifier: (n) => "(" + n.x + ", " + n.y + ")", sort_fields: { product: sort_by_product, x_value: sort_by_x, }, init_sort: [sort_by_product], $simplebar_container: $scroll_container, }); assert.deepEqual($container.$appended_data.html(), "(6, 7)(1, 43)(4, 11)"); const widget = ListWidget.get("custom-sort-list"); widget.sort("x_value"); assert.deepEqual($container.$appended_data.html(), "(1, 43)(4, 11)(6, 7)"); // We can sort without registering the function, too. function sort_by_y(a, b) { return a.y - b.y; } widget.sort(sort_by_y); assert.deepEqual($container.$appended_data.html(), "(6, 7)(4, 11)(1, 43)"); }); run_test("clear_event_handlers", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const $sort_container = make_sort_container(); const $filter_element = make_filter_element(); // We don't care about actual data for this test. const list = []; const opts = { name: "list-we-create-twice", $parent_container: $sort_container, modifier: () => {}, filter: { $element: $filter_element, predicate: /* istanbul ignore next */ () => true, }, $simplebar_container: $scroll_container, }; // Create it the first time. ListWidget.create($container, list, opts); assert.equal($sort_container.cleared, false); assert.equal($scroll_container.cleared, false); assert.equal($filter_element.cleared, false); // The second time we'll clear the old events. ListWidget.create($container, list, opts); assert.equal($sort_container.cleared, true); assert.equal($scroll_container.cleared, true); assert.equal($filter_element.cleared, true); }); run_test("errors", () => { // We don't care about actual data for this test. const list = ["stub"]; const $container = make_container(); const $scroll_container = make_scroll_container(); blueslip.expect("error", "Need opts to create widget."); ListWidget.create($container, list); blueslip.reset(); blueslip.expect("error", "$simplebar_container is missing."); ListWidget.create($container, list, { modifier: "hello world", }); blueslip.reset(); blueslip.expect("error", "get_item should be a function"); ListWidget.create($container, list, { get_item: "not a function", $simplebar_container: $scroll_container, }); blueslip.reset(); blueslip.expect("error", "Filter predicate is not a function."); ListWidget.create($container, list, { filter: { predicate: "wrong type", }, $simplebar_container: $scroll_container, }); blueslip.reset(); blueslip.expect("error", "Filterer and predicate are mutually exclusive."); ListWidget.create($container, list, { filter: { filterer: /* istanbul ignore next */ () => true, predicate: /* istanbul ignore next */ () => true, }, $simplebar_container: $scroll_container, }); blueslip.reset(); blueslip.expect("error", "Filter filterer is not a function (or missing)."); ListWidget.create($container, list, { filter: {}, $simplebar_container: $scroll_container, }); blueslip.reset(); blueslip.expect("error", "List item is not a string: 999"); ListWidget.create($container, list, { modifier: () => 999, $simplebar_container: $scroll_container, }); blueslip.reset(); }); run_test("sort helpers", () => { /* We mostly test our sorting helpers using the actual widget, but this test gets us a bit more line coverage. */ const alice2 = {name: "alice", id: 2}; const alice10 = {name: "alice", id: 10}; const bob2 = {name: "bob", id: 2}; const bob10 = {name: "bob", id: 10}; const alpha_cmp = ListWidget.alphabetic_sort("name"); const num_cmp = ListWidget.numeric_sort("id"); assert.equal(alpha_cmp(alice2, alice10), 0); assert.equal(alpha_cmp(alice2, bob2), -1); assert.equal(alpha_cmp(bob2, alice10), 1); assert.equal(num_cmp(alice2, bob2), 0); assert.equal(num_cmp(alice2, bob10), -1); assert.equal(num_cmp(alice10, bob2), 1); }); run_test("replace_list_data w/filter update", () => { const $container = make_container(); const $scroll_container = make_scroll_container(); const list = [1, 2, 3, 4]; let num_updates = 0; ListWidget.create($container, list, { name: "replace-list", modifier: (n) => "(" + n.toString() + ")", filter: { predicate: (n) => n % 2 === 0, onupdate: () => { num_updates += 1; }, }, $simplebar_container: $scroll_container, }); assert.equal(num_updates, 0); assert.deepEqual($container.$appended_data.html(), "(2)(4)"); const widget = ListWidget.get("replace-list"); widget.replace_list_data([5, 6, 7, 8]); assert.equal(num_updates, 1); assert.deepEqual($container.$appended_data.html(), "(6)(8)"); }); run_test("opts.get_item", () => { const items = {}; items[1] = "one"; items[2] = "two"; items[3] = "three"; items[4] = "four"; const list = [1, 2, 3, 4]; const boring_opts = { get_item: (n) => items[n], }; assert.deepEqual(ListWidget.get_filtered_items("whatever", list, boring_opts), [ "one", "two", "three", "four", ]); const predicate = (item, value) => item.startsWith(value); const predicate_opts = { get_item: (n) => items[n], filter: { predicate, }, }; assert.deepEqual(ListWidget.get_filtered_items("t", list, predicate_opts), ["two", "three"]); const filterer_opts = { get_item: (n) => items[n], filter: { filterer: (items, value) => items.filter((item) => predicate(item, value)), }, }; assert.deepEqual(ListWidget.get_filtered_items("t", list, filterer_opts), ["two", "three"]); }); run_test("render item", () => { const $container = make_container(); 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) => { const expected_queries = [ `tr[data-item='${INITIAL_RENDER_COUNT}']`, `tr[data-item='${INITIAL_RENDER_COUNT - 1}']`, ]; const item = INITIAL_RENDER_COUNT - 1; const new_html = `updated: ${item}\n`; const regex = new RegExp(`\\.*?<\\/tr\\>`); assert.ok(expected_queries.includes(query)); if (query.includes(`data-item='${INITIAL_RENDER_COUNT}'`)) { return undefined; // This item is not rendered, so we find nothing } return { // Return a JQuery stub for the original HTML. // We want this to be called when we replace // the existing HTML with newly rendered HTML. replaceWith: (html) => { assert.equal(new_html, html); called = true; $container.$appended_data.replace(regex, new_html); }, }; }; const list = [...Array.from({length: 100}).keys()]; let text = "initial"; const get_item = (item) => ({text: `${text}: ${item}`, value: item}); const widget = ListWidget.create($container, list, { name: "replace-list", modifier: (item) => `${item.text}\n`, get_item, html_selector: (item) => `tr[data-item='${item}']`, $simplebar_container: $scroll_container, }); const item = INITIAL_RENDER_COUNT - 1; assert.ok($container.$appended_data.html().includes("initial: 2")); assert.ok($container.$appended_data.html().includes("initial: 3")); text = "updated"; called = false; widget.render_item(INITIAL_RENDER_COUNT - 1); assert.ok(called); assert.ok($container.$appended_data.html().includes("initial: 2")); assert.ok( $container.$appended_data.html().includes(`updated: ${item}`), ); // Item 80 should not be in the rendered list. (0 indexed) assert.ok( !$container.$appended_data .html() .includes( `initial: ${INITIAL_RENDER_COUNT}`, ), ); called = false; widget.render_item(INITIAL_RENDER_COUNT); assert.ok(!called); widget.render_item(INITIAL_RENDER_COUNT - 1); assert.ok(called); // Tests below this are for the corner cases, where we abort the rerender. blueslip.expect("error", "html_selector should be a function."); ListWidget.create($container, list, { name: "replace-list", modifier: /* istanbul ignore next */ (item) => `${item.text}\n`, get_item, html_selector: "hello world", $simplebar_container: $scroll_container, }); blueslip.reset(); let get_item_called; const widget_2 = ListWidget.create($container, list, { name: "replace-list", modifier: (item) => `${item.text}\n`, get_item: (item) => { get_item_called = true; return item; }, $simplebar_container: $scroll_container, }); get_item_called = false; widget_2.render_item(item); // Test that we didn't try to render the item. assert.ok(!get_item_called); let rendering_item = false; const widget_3 = ListWidget.create($container, list, { name: "replace-list", modifier: (item) => (rendering_item ? undefined : `${item}\n`), get_item, html_selector: (item) => `tr[data-item='${item}']`, $simplebar_container: $scroll_container, }); // Once we have initially rendered the widget, change the // behavior of the modifier function. rendering_item = true; blueslip.expect("error", "List item is not a string: undefined"); 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.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; } /* istanbul ignore next */ 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) => `
  • ${item.name}
  • \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: "", }, ]; 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); });