zrequire('scroll_util'); zrequire('list_render'); // We need these stubs to get by instanceof checks. // The list_render library allows you to insert objects // that are either jQuery, Element, or just raw HTML // strings. We initially test with raw strings. set_global('jQuery', 'stub'); set_global('Element', function () { return { }; }); // 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. set_global('$', (arg) => { if (arg.to_jquery) { return arg.to_jquery(); } return { replace: (regex, string) => { arg = arg.replace(regex, string); }, html: () => arg, }; }); // 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.length = () => 1; container.is = () => false; container.css = (prop) => { assert.equal(prop, 'max-height'); return 'none'; }; // 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(container) { const scroll_container = {}; scroll_container.is = () => false; scroll_container.length = () => 1; scroll_container.css = (prop) => { assert.equal(prop, 'max-height'); return 100; }; 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; }; container.parent = () => scroll_container; 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. $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(container); const items = []; for (let i = 0; i < 200; i += 1) { items.push('item ' + i); } const opts = { modifier: (item) => item, }; container.html = (html) => { assert.equal(html, ''); }; list_render.create(container, items, opts); assert.deepEqual( container.appended_data.html(), items.slice(0, 80).join('') ); // 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('filtering', () => { const container = make_container(); make_scroll_container(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) => { return item.includes(value); }, }, modifier: (item) => div(item), }; container.html = (html) => { assert.equal(html, ''); }; const widget = list_render.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(); 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(); make_scroll_container(container); container.html = () => {}; // Opts does not require a filter key. const opts = { modifier: (item) => div(item), }; const widget = list_render.create(container, ['apple', 'banana'], opts); widget.render(); 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; default: throw Error('unknown selector: ' + sel); } } function lookup(sel, value) { return (selector) => { assert.equal(sel, selector); return value; }; } const classList = new Set(); const button = { data: data, closest: lookup('.progressive-table-wrapper', { data: lookup('list-render', opts.list_name), }), addClass: (cls) => { classList.add(cls); }, hasClass: (cls) => { return 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(); make_scroll_container(container); const filter_element = make_filter_element(); // We don't care about what gets drawn initially. container.html = () => {}; const opts = { filter: { filterer: (list, value) => { return list.filter((item) => { return item.toLowerCase().includes(value); }); }, element: filter_element, }, modifier: (s) => '(' + s + ')', }; list_render.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(); make_scroll_container(container); const sort_container = make_sort_container(); let cleared; container.html = (html) => { assert.equal(html, ''); 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) => { return div(item.name) + div(item.salary); }, filter: { predicate: () => true, }, }; function html_for(people) { return people.map(opts.modifier).join(''); } list_render.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(cleared); assert(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(cleared); expected_html = html_for([ ellen, dave, cal, bob, alice, ]); assert.deepEqual(container.appended_data.html(), expected_html); assert(button.hasClass('descend')); // And then hit a third time to go back to the forward sort. cleared = false; sort_container.f.apply(button); assert(cleared); expected_html = html_for([ alice, bob, cal, dave, ellen, ]); assert.deepEqual(container.appended_data.html(), expected_html); assert(!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(cleared); assert(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(cleared); expected_html = html_for([ ellen, alice, bob, cal, dave, ]); assert.deepEqual(container.appended_data.html(), expected_html); assert(button.hasClass('descend')); }); run_test('custom sort', () => { const container = make_container(); make_scroll_container(container); container.html = () => {}; 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; } list_render.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, }); assert.deepEqual( container.appended_data.html(), '(6, 7)(1, 43)(4, 11)' ); const widget = list_render.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(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 = []; container.html = () => {}; const opts = { name: 'list-we-create-twice', parent_container: sort_container, modifier: () => {}, filter: { element: filter_element, predicate: () => true, }, }; // Create it the first time. list_render.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. list_render.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(); make_scroll_container(container); blueslip.expect('error', 'Need opts to create widget.'); list_render.create(container, list); blueslip.reset(); blueslip.expect('error', 'get_item should be a function'); list_render.create(container, list, { get_item: 'not a function', }); blueslip.reset(); blueslip.expect('error', 'Filter predicate is not a function.'); list_render.create(container, list, { filter: { predicate: 'wrong type', }, }); blueslip.reset(); blueslip.expect('error', 'Filterer and predicate are mutually exclusive.'); list_render.create(container, list, { filter: { filterer: () => true, predicate: () => true, }, }); blueslip.reset(); blueslip.expect('error', 'Filter filterer is not a function (or missing).'); list_render.create(container, list, { filter: { }, }); blueslip.reset(); container.html = () => {}; blueslip.expect('error', 'List item is not a string: 999'); list_render.create(container, list, { modifier: () => 999, }); 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 = list_render.alphabetic_sort('name'); const num_cmp = list_render.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(); make_scroll_container(container); container.html = () => {}; const list = [1, 2, 3, 4]; let num_updates = 0; list_render.create(container, list, { name: 'replace-list', modifier: (n) => '(' + n.toString() + ')', filter: { predicate: (n) => n % 2 === 0, onupdate: () => { num_updates += 1; }, }, }); assert.equal(num_updates, 0); assert.deepEqual( container.appended_data.html(), '(2)(4)' ); const widget = list_render.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( list_render.get_filtered_items( 'whatever', list, boring_opts ), ['one', 'two', 'three', 'four'] ); const predicate = (item, value) => { return item.startsWith(value); }; const predicate_opts = { get_item: (n) => items[n], filter: { predicate: predicate, }, }; assert.deepEqual( list_render.get_filtered_items( 't', list, predicate_opts ), ['two', 'three'] ); const filterer_opts = { get_item: (n) => items[n], filter: { filterer: (items, value) => { return items.filter((item) => { return predicate(item, value); }); }, }, }; assert.deepEqual( list_render.get_filtered_items( 't', list, filterer_opts ), ['two', 'three'] ); }); run_test('render item', () => { const container = make_container(); const scroll_container = make_scroll_container(container); const INITIAL_RENDER_COUNT = 80; // Keep this in sync with the actual code. container.html = () => {}; 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(expected_queries.includes(query)); if (query.includes(`data-item='${INITIAL_RENDER_COUNT}'`)) { return; // 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(100).keys()]; let text = 'initial'; const get_item = (item) => ({text: `${text}: ${item}`, value: item}); const widget = list_render.create(container, list, { name: 'replace-list', modifier: (item) => `${item.text}\n`, get_item: get_item, html_selector: (item) => `tr[data-item='${item}']`, }); const item = INITIAL_RENDER_COUNT - 1; assert(container.appended_data.html().includes('initial: 2')); assert(container.appended_data.html().includes('initial: 3')); text = 'updated'; called = false; widget.render_item(INITIAL_RENDER_COUNT - 1); assert(called); assert(container.appended_data.html().includes('initial: 2')); assert(container.appended_data.html().includes(`updated: ${item}`)); // Item 80 should not be in the rendered list. (0 indexed) assert(!container.appended_data.html().includes(`initial: ${INITIAL_RENDER_COUNT}`)); called = false; widget.render_item(INITIAL_RENDER_COUNT); assert(!called); widget.render_item(INITIAL_RENDER_COUNT - 1); assert(called); // Tests below this are for the corner cases, where we abort the rerender. blueslip.expect('error', 'html_selector should be a function.'); list_render.create(container, list, { name: 'replace-list', modifier: (item) => `${item.text}\n`, get_item: get_item, html_selector: 'hello world', }); blueslip.reset(); let get_item_called; const widget_2 = list_render.create(container, list, { name: 'replace-list', modifier: (item) => `${item.text}\n`, get_item: (item) => { get_item_called = true; return item; }, }); get_item_called = false; widget_2.render_item(item); // Test that we didn't try to render the item. assert(!get_item_called); let rendering_item = false; const widget_3 = list_render.create(container, list, { name: 'replace-list', modifier: (item) => { return rendering_item ? undefined : `${item}\n`; }, get_item: get_item, html_selector: (item) => `tr[data-item='${item}']`, }); // 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(); });