mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-03 21:43:21 +00:00 
			
		
		
		
	Previously, we had to fiddle with the generated HTML to update individual values. Now, we can simply ask the widget to rerender the row that we updated. This is done by passing an html_selector function that returns a selector for the rendered item. If: - we do not provide html_selector function - item is not currently rendered - new html is not a string. then the render_item() call is a noop.
		
			
				
	
	
		
			400 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			400 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
const DEFAULTS = {
 | 
						|
    INITIAL_RENDER_COUNT: 80,
 | 
						|
    LOAD_COUNT: 20,
 | 
						|
    instances: new Map(),
 | 
						|
};
 | 
						|
 | 
						|
 | 
						|
// ----------------------------------------------------
 | 
						|
// This function describes (programatically) how to use
 | 
						|
// the list_render widget.
 | 
						|
// ----------------------------------------------------
 | 
						|
 | 
						|
exports.validate_opts = (opts) => {
 | 
						|
    if (opts.html_selector && typeof opts.html_selector !== 'function') {
 | 
						|
        // We have an html_selector, but it is not a function.
 | 
						|
        // This is a programming error.
 | 
						|
        blueslip.error('html_selector should be a function.');
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
    return true;
 | 
						|
};
 | 
						|
 | 
						|
exports.get_filtered_items = (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.
 | 
						|
    */
 | 
						|
    const get_item = opts.get_item;
 | 
						|
 | 
						|
    if (!opts.filter) {
 | 
						|
        if (get_item) {
 | 
						|
            return list.map(get_item);
 | 
						|
        }
 | 
						|
        return [...list];
 | 
						|
    }
 | 
						|
 | 
						|
    if (opts.filter.filterer) {
 | 
						|
        if (get_item) {
 | 
						|
            return opts.filter.filterer(
 | 
						|
                list.map(get_item),
 | 
						|
                value
 | 
						|
            );
 | 
						|
        }
 | 
						|
        return opts.filter.filterer(list, value);
 | 
						|
    }
 | 
						|
 | 
						|
    const predicate = (item) => {
 | 
						|
        return opts.filter.predicate(item, value);
 | 
						|
    };
 | 
						|
 | 
						|
    if (get_item) {
 | 
						|
        const result = [];
 | 
						|
 | 
						|
        for (const key of list) {
 | 
						|
            const item = get_item(key);
 | 
						|
            if (predicate(item)) {
 | 
						|
                result.push(item);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    return list.filter(predicate);
 | 
						|
};
 | 
						|
 | 
						|
exports.alphabetic_sort = (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;
 | 
						|
    };
 | 
						|
};
 | 
						|
 | 
						|
exports.numeric_sort = (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;
 | 
						|
    };
 | 
						|
};
 | 
						|
 | 
						|
exports.valid_filter_opts = (opts) => {
 | 
						|
    if (!opts.filter) {
 | 
						|
        return true;
 | 
						|
    }
 | 
						|
    if (opts.filter.predicate) {
 | 
						|
        if (typeof opts.filter.predicate !== 'function') {
 | 
						|
            blueslip.error('Filter predicate is not a function.');
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        if (opts.filter.filterer) {
 | 
						|
            blueslip.error('Filterer and predicate are mutually exclusive.');
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
    } else {
 | 
						|
        if (typeof opts.filter.filterer !== 'function') {
 | 
						|
            blueslip.error('Filter filterer is not a function (or missing).');
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    return true;
 | 
						|
};
 | 
						|
 | 
						|
// @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) {
 | 
						|
    if (!opts) {
 | 
						|
        blueslip.error('Need opts to create widget.');
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (!exports.validate_opts(opts)) {
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (opts.name && DEFAULTS.instances.get(opts.name)) {
 | 
						|
        // Clear event handlers for prior widget.
 | 
						|
        const old_widget = DEFAULTS.instances.get(opts.name);
 | 
						|
        old_widget.clear_event_handlers();
 | 
						|
    }
 | 
						|
 | 
						|
    const meta = {
 | 
						|
        sorting_function: null,
 | 
						|
        sorting_functions: new Map(),
 | 
						|
        generic_sorting_functions: {
 | 
						|
            alphabetic: exports.alphabetic_sort,
 | 
						|
            numeric: exports.numeric_sort,
 | 
						|
        },
 | 
						|
        offset: 0,
 | 
						|
        list: list,
 | 
						|
        filtered_list: list,
 | 
						|
        reverse_mode: false,
 | 
						|
        filter_value: '',
 | 
						|
    };
 | 
						|
 | 
						|
    if (!exports.valid_filter_opts(opts)) {
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (opts.get_item && typeof opts.get_item !== 'function') {
 | 
						|
        blueslip.error('get_item should be a function');
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    const widget = {};
 | 
						|
 | 
						|
    widget.filter_and_sort = function () {
 | 
						|
        meta.filtered_list = exports.get_filtered_items(
 | 
						|
            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) {
 | 
						|
            const s = opts.modifier(item);
 | 
						|
 | 
						|
            if (typeof s !== 'string') {
 | 
						|
                blueslip.error('List item is not a string: ' + s);
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
 | 
						|
            // append the HTML or nothing if corrupt (null, undef, etc.).
 | 
						|
            if (s) {
 | 
						|
                html += s;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        finish();
 | 
						|
 | 
						|
        $container.append($(html));
 | 
						|
        meta.offset += load_count;
 | 
						|
    };
 | 
						|
 | 
						|
    widget.render_item = (item) => {
 | 
						|
        if (!opts.html_selector) {
 | 
						|
            // We don't have any way to find the existing item.
 | 
						|
            return;
 | 
						|
        }
 | 
						|
        const html_item = meta.scroll_container.find(opts.html_selector(item));
 | 
						|
        if (!html_item) {
 | 
						|
            // We don't have the item in the current scroll container; it'll be
 | 
						|
            // rendered with updated data when it is scrolled to.
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        if (opts.get_item) {
 | 
						|
            item = opts.get_item(item);
 | 
						|
        }
 | 
						|
        const html = opts.modifier(item);
 | 
						|
        if (typeof html !== 'string') {
 | 
						|
            blueslip.error('List item is not a string: ' + html);
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        // At this point, we have asserted we have all the information to replace
 | 
						|
        // the html now.
 | 
						|
        html_item.replaceWith(html);
 | 
						|
    };
 | 
						|
 | 
						|
    widget.clear = function () {
 | 
						|
        $container.html("");
 | 
						|
        meta.offset = 0;
 | 
						|
    };
 | 
						|
 | 
						|
    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[sorting_function](prop);
 | 
						|
            } else {
 | 
						|
                meta.sorting_function = meta.sorting_functions.get(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.on('scroll.list_widget_container', function () {
 | 
						|
            if (this.scrollHeight - (this.scrollTop + this.clientHeight) < 10) {
 | 
						|
                widget.render();
 | 
						|
            }
 | 
						|
        });
 | 
						|
 | 
						|
        if (opts.parent_container) {
 | 
						|
            opts.parent_container.on('click.list_widget_sort', "[data-sort]", function () {
 | 
						|
                exports.handle_sort($(this), widget);
 | 
						|
            });
 | 
						|
        }
 | 
						|
 | 
						|
        if (opts.filter && opts.filter.element) {
 | 
						|
            opts.filter.element.on('input.list_widget_filter', function () {
 | 
						|
                const value = this.value.toLocaleLowerCase();
 | 
						|
                widget.set_filter_value(value);
 | 
						|
                widget.hard_redraw();
 | 
						|
            });
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    widget.clear_event_handlers = function () {
 | 
						|
        meta.scroll_container.off('scroll.list_widget_container');
 | 
						|
 | 
						|
        if (opts.parent_container) {
 | 
						|
            opts.parent_container.off('click.list_widget_sort', "[data-sort]");
 | 
						|
        }
 | 
						|
 | 
						|
        if (opts.filter && opts.filter.element) {
 | 
						|
            opts.filter.element.off('input.list_widget_filter');
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    widget.sort = function (sorting_function, prop) {
 | 
						|
        widget.set_sorting_function(sorting_function, prop);
 | 
						|
        widget.hard_redraw();
 | 
						|
    };
 | 
						|
 | 
						|
    widget.clean_redraw = function () {
 | 
						|
        widget.filter_and_sort();
 | 
						|
        widget.clear();
 | 
						|
        widget.render(DEFAULTS.INITIAL_RENDER_COUNT);
 | 
						|
    };
 | 
						|
 | 
						|
    widget.hard_redraw = function () {
 | 
						|
        widget.clean_redraw();
 | 
						|
        if (opts.filter && opts.filter.onupdate) {
 | 
						|
            opts.filter.onupdate();
 | 
						|
        }
 | 
						|
    };
 | 
						|
 | 
						|
    widget.replace_list_data = function (list) {
 | 
						|
        /*
 | 
						|
            We mostly use this widget for lists where you are
 | 
						|
            not adding or removing rows, so when you do modify
 | 
						|
            the list, we have a brute force solution.
 | 
						|
        */
 | 
						|
        meta.list = list;
 | 
						|
        widget.hard_redraw();
 | 
						|
    };
 | 
						|
 | 
						|
    widget.set_up_event_handlers();
 | 
						|
 | 
						|
    if (opts.sort_fields) {
 | 
						|
        for (const [name, sorting_function] of Object.entries(opts.sort_fields)) {
 | 
						|
            meta.sorting_functions.set(name, sorting_function);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    if (opts.init_sort) {
 | 
						|
        widget.set_sorting_function(...opts.init_sort);
 | 
						|
    }
 | 
						|
 | 
						|
    widget.clean_redraw();
 | 
						|
 | 
						|
    // 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 (th, list) {
 | 
						|
    /*
 | 
						|
        one would specify sort parameters like this:
 | 
						|
            - name => sort alphabetic.
 | 
						|
            - age  => sort numeric.
 | 
						|
            - status => look up `status` in sort_fields
 | 
						|
                        to find custom sort function
 | 
						|
 | 
						|
        <thead>
 | 
						|
            <th data-sort="alphabetic" data-sort-prop="name"></th>
 | 
						|
            <th data-sort="numeric" data-sort-prop="age"></th>
 | 
						|
            <th data-sort="status"></th>
 | 
						|
        </thead>
 | 
						|
        */
 | 
						|
    const sort_type = th.data("sort");
 | 
						|
    const prop_name = th.data("sort-prop");
 | 
						|
 | 
						|
    if (th.hasClass("active")) {
 | 
						|
        if (!th.hasClass("descend")) {
 | 
						|
            th.addClass("descend");
 | 
						|
        } else {
 | 
						|
            th.removeClass("descend");
 | 
						|
        }
 | 
						|
    } else {
 | 
						|
        th.siblings(".active").removeClass("active");
 | 
						|
        th.addClass("active");
 | 
						|
    }
 | 
						|
 | 
						|
    list.set_reverse_mode(th.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;
 |