zulip/frontend_tests/node_tests/list_render.js

820 lines
22 KiB
JavaScript

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');
function Element() {
return { };
}
set_global('Element', Element);
// 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 '<div>' + item + '</div>';
}
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) => item.includes(value),
},
modifier: (item) => div(item),
};
container.html = (html) => { assert.equal(html, ''); };
const widget = list_render.create(container, list, opts);
let expected_html =
'<div>apple</div>' +
'<div>banana</div>' +
'<div>carrot</div>' +
'<div>dog</div>' +
'<div>egg</div>' +
'<div>fence</div>' +
'<div>grape</div>';
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 = '<div>dog</div><div>egg</div><div>grape</div>';
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 =
'<div>greta</div>' +
'<div>gary</div>' +
'<div>giraffe</div>';
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 =
'<div>apple</div>' +
'<div>banana</div>';
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) => 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) => list.filter((item) => 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) => 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) => 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) => items.filter((item) => 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 = `<tr data-item=${item}>updated: ${item}</tr>\n`;
const regex = new RegExp(`\\<tr data-item=${item}\\>.*?\<\\/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) => `<tr data-item=${item.value}>${item.text}</tr>\n`,
get_item: get_item,
html_selector: (item) => `tr[data-item='${item}']`,
});
const item = INITIAL_RENDER_COUNT - 1;
assert(container.appended_data.html().includes('<tr data-item=2>initial: 2</tr>'));
assert(container.appended_data.html().includes('<tr data-item=3>initial: 3</tr>'));
text = 'updated';
called = false;
widget.render_item(INITIAL_RENDER_COUNT - 1);
assert(called);
assert(container.appended_data.html().includes('<tr data-item=2>initial: 2</tr>'));
assert(container.appended_data.html().includes(`<tr data-item=${item}>updated: ${item}</tr>`));
// Item 80 should not be in the rendered list. (0 indexed)
assert(!container.appended_data.html().includes(`<tr data-item=${INITIAL_RENDER_COUNT}>initial: ${INITIAL_RENDER_COUNT}</tr>`));
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) => `<tr data-item=${item.value}>${item.text}</tr>\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) => `<tr data-item=${item.value}>${item.text}</tr>\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) => 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();
});