Files
zulip/static/js/list_render.js
Steve Howell eb1344c41c list_render: Fix filtering/sorting.
This code has always been kind of convoluted
and buggy, starting with the first
sorting-related commit, which put filtering
before sorting for some reason:

    3706e2c6ba

This should fix bugs like the fact that
changing filter text would not respect
reversed sorts.

Now the scheme is simple:

    - external UI actions set `meta` values like
      filter_value, reverse_mode, and
      sorting_function, as needed, through
      simple setters

    - use `hard_redraw` to do a redraw and
      trigger external actions

    - all filtering/sorting/reverse logic on
      the *data* happens in a single, simple
      function called `filter_and_sort`
2020-04-15 15:13:26 -07:00

379 lines
11 KiB
JavaScript

const DEFAULTS = {
INITIAL_RENDER_COUNT: 80,
LOAD_COUNT: 20,
instances: new Map(),
};
exports.filter = (value, list, opts) => {
/*
This is used by the main object (see `create`),
but we split it out to make it a bit easier
to test.
*/
if (!opts.filter) {
return [...list];
}
if (opts.filter.filterer) {
return opts.filter.filterer(list, value);
}
const predicate = opts.filter.predicate;
return list.filter(function (item) {
return predicate(item, value);
});
};
exports.validate_filter = (opts) => {
if (!opts.filter) {
return;
}
if (opts.filter.predicate) {
if (typeof opts.filter.predicate !== 'function') {
blueslip.error('Filter predicate function is missing.');
return;
}
if (opts.filter.filterer) {
blueslip.error('Filterer and predicate are mutually exclusive.');
return;
}
} else {
if (typeof opts.filter.filterer !== 'function') {
blueslip.error('Filter filterer function is missing.');
return;
}
}
};
// @params
// container: jQuery object to append to.
// list: The list of items to progressively append.
// opts: An object of random preferences.
exports.create = function ($container, list, opts) {
// this memoizes the results and will return a previously invoked
// instance
if (opts.name && DEFAULTS.instances.get(opts.name)) {
// the false flag here means "don't run `init`". This is because a
// user is likely reinitializing and will have put .init() afterwards.
// This happens when the same codepath is hit multiple times.
return DEFAULTS.instances.get(opts.name)
.set_container($container)
.set_opts(opts)
.set_up_event_handlers()
.data(list)
.init();
}
const meta = {
sorting_function: null,
sorting_functions: new Map(),
generic_sorting_functions: new Map(),
offset: 0,
list: list,
filtered_list: list,
reverse_mode: false,
filter_value: '',
};
if (!opts) {
return;
}
exports.validate_filter(opts);
const widget = {};
widget.filter_and_sort = function () {
meta.filtered_list = exports.filter(
meta.filter_value,
meta.list,
opts
);
if (meta.sorting_function) {
meta.filtered_list.sort(
meta.sorting_function
);
}
if (meta.reverse_mode) {
meta.filtered_list.reverse();
}
};
// Reads the provided list (in the scope directly above)
// and renders the next block of messages automatically
// into the specified container.
widget.render = function (how_many) {
const load_count = how_many || DEFAULTS.LOAD_COUNT;
// Stop once the offset reaches the length of the original list.
if (meta.offset >= meta.filtered_list.length) {
return;
}
const slice = meta.filtered_list.slice(meta.offset, meta.offset + load_count);
const finish = blueslip.start_timing('list_render ' + opts.name);
let html = "";
for (const item of slice) {
let _item = opts.modifier(item);
// if valid jQuery selection, attempt to grab all elements within
// and string them together into a giant outerHTML fragment.
if (_item.constructor === jQuery) {
_item = (function ($nodes) {
let html = "";
$nodes.each(function () {
if (this.nodeType === 1) {
html += this.outerHTML;
}
});
return html;
}(_item));
}
// if is a valid element, get the outerHTML.
if (_item instanceof Element) {
_item = _item.outerHTML;
}
// append the HTML or nothing if corrupt (null, undef, etc.).
html += _item || "";
}
finish();
$container.append($(html));
meta.offset += load_count;
return this;
};
// Fills the container with an initial batch of items.
// Needs to be enough to exceed the max height, so that a
// scrollable area is created.
widget.init = function () {
this.clear();
this.render(DEFAULTS.INITIAL_RENDER_COUNT);
return this;
};
// reset the data associated with a list. This is so that instead of
// initializing a new progressive list render instance, you can just
// update the data of an existing one.
widget.data = function (...args) {
// if no args are provided then just return the existing data.
// this interface is similar to how many jQuery functions operate,
// where a call to the method without data returns the existing data.
if (args.length === 0) {
return meta.list;
}
const [data] = args;
if (Array.isArray(data)) {
meta.list = data;
meta.filtered_list = data;
widget.filter_and_sort();
widget.clear();
return this;
}
blueslip.warn("The data object provided to the progressive" +
" list render is invalid");
return this;
};
widget.clear = function () {
$container.html("");
meta.offset = 0;
return this;
};
widget.set_container = function ($new_container) {
if ($new_container) {
$container = $new_container;
}
return this;
};
widget.set_opts = function (new_opts) {
if (opts) {
opts = new_opts;
}
return this;
};
widget.set_filter_value = function (filter_value) {
meta.filter_value = filter_value;
};
widget.set_reverse_mode = function (reverse_mode) {
meta.reverse_mode = reverse_mode;
};
// the sorting function is either the function or string that calls the
// function to sort the list by. The prop is used for generic functions
// that can be called to sort with a particular prop.
widget.set_sorting_function = function (sorting_function, prop) {
if (typeof sorting_function === "function") {
meta.sorting_function = sorting_function;
} else if (typeof sorting_function === "string") {
if (typeof prop === "string") {
/* eslint-disable max-len */
meta.sorting_function = meta.generic_sorting_functions.get(sorting_function)(prop);
} else {
meta.sorting_function = meta.sorting_functions.get(sorting_function);
}
}
};
widget.add_sort_function = function (name, sorting_function) {
meta.sorting_functions.set(name, sorting_function);
};
// generic sorting functions are ones that will use a specified prop
// and perform a sort on it with the given sorting function.
widget.add_generic_sort_function = function (name, sorting_function) {
meta.generic_sorting_functions.set(name, sorting_function);
};
widget.set_up_event_handlers = function () {
meta.scroll_container = scroll_util.get_list_scrolling_container($container);
// on scroll of the nearest scrolling container, if it hits the bottom
// of the container then fetch a new block of items and render them.
meta.scroll_container.scroll(function () {
if (this.scrollHeight - (this.scrollTop + this.clientHeight) < 10) {
widget.render();
}
});
if (opts.parent_container) {
opts.parent_container.on("click", "[data-sort]", exports.handle_sort);
}
if (opts.filter && opts.filter.element) {
opts.filter.element.on("input", function () {
const value = this.value.toLocaleLowerCase();
widget.set_filter_value(value);
widget.hard_redraw();
});
}
return this;
};
widget.sort = function (sorting_function, prop) {
widget.set_sorting_function(sorting_function, prop);
widget.hard_redraw();
};
widget.hard_redraw = function () {
widget.filter_and_sort();
widget.clear();
widget.render(DEFAULTS.INITIAL_RENDER_COUNT);
if (opts.filter && opts.filter.onupdate) {
opts.filter.onupdate();
}
};
// add built-in generic sort functions.
widget.add_generic_sort_function("alphabetic", function (prop) {
return function (a, b) {
// The conversion to uppercase helps make the sorting case insensitive.
const str1 = a[prop].toUpperCase();
const str2 = b[prop].toUpperCase();
if (str1 === str2) {
return 0;
} else if (str1 > str2) {
return 1;
}
return -1;
};
});
widget.add_generic_sort_function("numeric", function (prop) {
return function (a, b) {
if (parseFloat(a[prop]) > parseFloat(b[prop])) {
return 1;
} else if (parseFloat(a[prop]) === parseFloat(b[prop])) {
return 0;
}
return -1;
};
});
widget.set_up_event_handlers();
// Save the instance for potential future retrieval if a name is provided.
if (opts.name) {
DEFAULTS.instances.set(opts.name, widget);
}
return widget;
};
exports.get = function (name) {
return DEFAULTS.instances.get(name) || false;
};
exports.handle_sort = function () {
/*
one would specify sort parameters like this:
- name => sort alphabetic.
- age => sort numeric.
you MUST specify the `data-list-render` in the `.progressive-table-wrapper`
<div class="progressive-table-wrapper" data-list-render="some-list">
<table>
<thead>
<th data-sort="alphabetic" data-sort-prop="name"></th>
<th data-sort="numeric" data-sort-prop="age"></th>
</thead>
<tbody></tbody>
</table>
</div>
*/
const $this = $(this);
const sort_type = $this.data("sort");
const prop_name = $this.data("sort-prop");
const list_name = $this.closest(".progressive-table-wrapper").data("list-render");
const list = exports.get(list_name);
if (!list) {
blueslip.error("Error. This `.progressive-table-wrapper` has no `data-list-render` attribute.");
return;
}
if ($this.hasClass("active")) {
if (!$this.hasClass("descend")) {
$this.addClass("descend");
} else {
$this.removeClass("descend");
}
} else {
$this.siblings(".active").removeClass("active");
$this.addClass("active");
}
list.set_reverse_mode($this.hasClass("descend"));
// if `prop_name` is defined, it will trigger the generic codepath,
// and not if it is undefined.
list.sort(sort_type, prop_name);
};
window.list_render = exports;