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.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, prop: null, sorting_functions: new Map(), generic_sorting_functions: new Map(), offset: 0, list: list, filtered_list: list, }; function filter_list(value) { meta.filtered_list = exports.filter(value, meta.list, opts); } if (!opts) { return; } exports.validate_filter(opts); const widget = {}; // Reads the provided list (in the scope directly above) // and renders the next block of messages automatically // into the specified container. widget.render = function (load_count) { load_count = load_count || opts.load_count || 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; }; widget.filter = function (map_function) { meta.filtered_list = meta.list(map_function); }; // 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; if (opts.filter && opts.filter.element) { const value = $(opts.filter.element).val().toLocaleLowerCase(); filter_list(value); } 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.reverse = function () { meta.filtered_list.reverse(); widget.init(); return this; }; // 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. // the `map` will normalize the values with a function you provide to make // it easier to sort with. // `do_not_display` will signal to not update the DOM, likely because in // the next function it will be updated in the DOM. widget.sort = function (sorting_function, prop, do_not_display) { meta.prop = 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); } } // we do not want to sort if we are just looking to reverse // by calling with no sorting_function if (meta.sorting_function) { meta.filtered_list = meta.filtered_list.sort(meta.sorting_function); } if (!do_not_display) { // clear and re-initialize the list with the newly filtered subset // of items. widget.init(); if (opts.filter && opts.filter.onupdate) { opts.filter.onupdate(); } } }; 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.remove_sort = function () { meta.sorting_function = false; }; widget.set_up_event_handlers = function () { let $nearestScrollingContainer = $container; while ($nearestScrollingContainer.length) { if ($nearestScrollingContainer.is("body, html")) { blueslip.warn("Please wrap progressive scrolling lists in an element with 'max-height' attribute. Error found in:\n" + blueslip.preview_node($container)); break; } if ($nearestScrollingContainer.css("max-height") !== "none") { break; } $nearestScrollingContainer = $nearestScrollingContainer.parent(); } // 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. $nearestScrollingContainer.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(opts.filter.event || "input", function () { const value = this.value.toLocaleLowerCase(); // run the sort algorithm that was used last, which is done // by passing `undefined` -- which will make it use the params // from the last sort. // it will then also not run an update in the DOM (because we // pass `true`), because it will update regardless below at // `widget.init()`. widget.sort(undefined, meta.prop, true); filter_list(value); // clear and re-initialize the list with the newly filtered subset // of items. widget.init(); if (opts.filter.onupdate) { opts.filter.onupdate(); } }); } return this; }; // 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`