import { _get, _post, addLoader, removeLoader, throttle } from "./common"; import { Search, SearchConfiguration } from "./search"; import "@af-utils/scrollend-polyfill"; declare var window: GlobalWindow; export interface ListItem { asElement: () => HTMLElement; } export class RecordCounter { private _container: HTMLElement; private _totalRecords: HTMLElement; private _loadedRecords: HTMLElement; private _shownRecords: HTMLElement; private _selectedRecords: HTMLElement; private _total: number; private _loaded: number; private _shown: number; private _selected: number; constructor(container: HTMLElement) { this._container = container; this._container.innerHTML = ` `; this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement; this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement; this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement; this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement; this.total = 0; this.loaded = 0; this.shown = 0; } reset() { this.total = 0; this.loaded = 0; this.shown = 0; this.selected = 0; } // Sets the total using a PageCountDTO-returning API endpoint. getTotal(endpoint: string) { _get(endpoint, null, (req: XMLHttpRequest) => { if (req.readyState != 4 || req.status != 200) return; this.total = req.response["count"] as number; }); } get total(): number { return this._total; } set total(v: number) { this._total = v; this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); } get loaded(): number { return this._loaded; } set loaded(v: number) { this._loaded = v; this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); } get shown(): number { return this._shown; } set shown(v: number) { this._shown = v; this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); } get selected(): number { return this._selected; } set selected(v: number) { this._selected = v; if (v == 0) this._selectedRecords.textContent = ``; else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`); } } export interface PaginatedListConfig { loader: HTMLElement; loadMoreButtons: Array; loadAllButtons: Array; refreshButton: HTMLButtonElement; filterArea: HTMLElement; searchOptionsHeader: HTMLElement; searchBox: HTMLInputElement; recordCounter: HTMLElement; totalEndpoint: string; getPageEndpoint: string; itemsPerPage: number; maxItemsLoadedForSearch: number; appendNewItems: (resp: paginatedDTO) => void; replaceWithNewItems: (resp: paginatedDTO) => void; defaultSortField: string; defaultSortAscending: boolean; pageLoadCallback?: (req: XMLHttpRequest) => void; } export abstract class PaginatedList { protected _c: PaginatedListConfig; // Container to append items to. protected _container: HTMLElement; // List of visible IDs (i.e. those set with setVisibility). protected _visible: string[]; // Infinite-scroll related data. // Implementation partially based on this blog post, thank you Miina Lervik: // https://www.bekk.christmas/post/2021/02/how-to-lazy-render-large-data-tables-to-up-performance protected _scroll = { rowHeight: 0, screenHeight: 0, // Render this many screen's worth of content below the viewport. renderNExtraScreensWorth: 3, rendered: 0, initialRenderCount: 0, scrollLoading: false, // Used to calculate scroll speed, so more pages are loaded when scrolling fast. lastScrollY: 0, }; protected _search: Search; protected _counter: RecordCounter; protected _hasLoaded: boolean; protected _lastLoad: number; protected _page: number = 0; protected _lastPage: boolean; get lastPage(): boolean { return this._lastPage; } set lastPage(v: boolean) { this._lastPage = v; if (v) { this._c.loadAllButtons.forEach((v) => v.classList.add("unfocused")); this._c.loadMoreButtons.forEach((v) => { v.textContent = window.lang.strings("noMoreResults"); v.disabled = true; }); } else { this._c.loadMoreButtons.forEach((v) => { v.textContent = window.lang.strings("loadMore"); v.disabled = false; }); this._c.loadAllButtons.forEach((v) => v.classList.remove("unfocused")); } this.autoSetServerSearchButtonsDisabled(); } protected _previousVisibleItemCount = 0; // Stores a PaginatedReqDTO-implementing thing. // A standard PaginatedReqDTO will be overridden entirely, // but a ServerSearchDTO will keep it's fields. protected _searchParams: PaginatedReqDTO; defaultParams = (): PaginatedReqDTO => { return { limit: 0, page: 0, sortByField: "", ascending: false, }; }; constructor(c: PaginatedListConfig) { this._c = c; this._counter = new RecordCounter(this._c.recordCounter); this._hasLoaded = false; this._c.loadMoreButtons.forEach((v) => { v.onclick = () => this.loadMore(false); }); this._c.loadAllButtons.forEach((v) => { v.onclick = () => this.loadAll(); }); /* this._keepSearchingButton.onclick = () => { addLoader(this._keepSearchingButton, true); this.loadMore(() => removeLoader(this._keepSearchingButton, true)); }; */ // Since this.reload doesn't exist, we need an arrow function to wrap it. this._c.refreshButton.onclick = () => this.reload(); } autoSetServerSearchButtonsDisabled = () => { const serverSearchSortChanged = this._search.inServerSearch && (this._searchParams.sortByField != this._search.sortField || this._searchParams.ascending != this._search.ascending); if (this._search.inServerSearch) { if (serverSearchSortChanged) { this._search.setServerSearchButtonsDisabled(false); } else { this._search.setServerSearchButtonsDisabled(this.lastPage); } return; } if ( !this._search.inSearch && this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending ) { this._search.setServerSearchButtonsDisabled(true); return; } this._search.setServerSearchButtonsDisabled(false); }; initSearch = (searchConfig: SearchConfiguration) => { const previousCallback = searchConfig.onSearchCallback; searchConfig.onSearchCallback = ( newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void, ) => { // if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused"); // else this._c.loadAllButton.classList.add("unfocused"); this.autoSetServerSearchButtonsDisabled(); // FIXME: Figure out why this makes sense and make it clearer. if ( (this._visible.length < this._c.itemsPerPage && this._counter.loaded < this._c.maxItemsLoadedForSearch && !this.lastPage) || loadAll ) { if ( !newItems || this._previousVisibleItemCount != this._visible.length || (this._visible.length == 0 && !this.lastPage) || loadAll ) { this.loadMore(loadAll, callback); } } this._previousVisibleItemCount = this._visible.length; if (previousCallback) previousCallback(newItems, loadAll); }; const previousServerSearch = searchConfig.searchServer; searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => { this._searchParams = params; if (newSearch) this.reload(); else this.loadMore(false); if (previousServerSearch) previousServerSearch(params, newSearch); }; searchConfig.clearServerSearch = () => { console.trace("Clearing server search"); this._page = 0; this.reload(); }; searchConfig.setVisibility = this.setVisibility; this._search = new Search(searchConfig); this._search.generateFilterList(); this.lastPage = false; }; // Sets the elements with "name"s in "elements" as visible or not. // setVisibilityNaive = (elements: string[], visible: boolean) => { // let timer = this._search.timeSearches ? performance.now() : null; // if (visible) this._visible = elements; // else this._visible = this._search.ordering.filter(v => !elements.includes(v)); // const frag = document.createDocumentFragment() // for (let i = 0; i < this._visible.length; i++) { // frag.appendChild(this._search.items[this._visible[i]].asElement()) // } // this._container.replaceChildren(frag); // if (this._search.timeSearches) { // const totalTime = performance.now() - timer; // console.log(`setVisibility took ${totalTime}ms`); // } // } // FIXME: Might have broken _counter.shown! // Sets the elements with "name"s in "elements" as visible or not. // appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset. setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => { let timer = this._search.timeSearches ? performance.now() : null; if (visible) this._visible = elements; else this._visible = this._search.ordering.filter((v) => !elements.includes(v)); // console.log(elements.length, visible, this._visible.length); this._counter.shown = this._visible.length; if (this._visible.length == 0) { this._container.textContent = ``; return; } if (!appendedItems) { // Wipe old elements and render 1 new one, so we can take the element height. this._container.replaceChildren(this._search.items[this._visible[0]].asElement()); } this._computeScrollInfo(); // Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it. this._scroll.initialRenderCount = Math.floor( Math.min( this._visible.length, Math.max( ((this._scroll.renderNExtraScreensWorth + 1) * this._scroll.screenHeight) / this._scroll.rowHeight, this._c.itemsPerPage, ), ), ); let baseIndex = 1; if (appendedItems) { baseIndex = this._scroll.rendered; } const frag = document.createDocumentFragment(); for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) { frag.appendChild(this._search.items[this._visible[i]].asElement()); } this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount); // appendChild over replaceChildren because there's already elements on the DOM this._container.appendChild(frag); if (this._search.timeSearches) { const totalTime = performance.now() - timer; console.debug(`setVisibility took ${totalTime}ms`); } }; // Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change. _computeScrollInfo = () => { if (this._visible.length == 0) return; this._scroll.screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight; }; // returns the item index to render up to for the given scroll position. // might return a value greater than this._visible.length, indicating a need for a page load. maximumItemsToRender = (scrollY: number): number => { const bottomScroll = scrollY + (this._scroll.renderNExtraScreensWorth + 1) * this._scroll.screenHeight; const bottomIdx = Math.floor(bottomScroll / this._scroll.rowHeight); return bottomIdx; }; private _load = ( itemLimit: number, page: number, appendFunc: (resp: paginatedDTO) => void, // Function to append/put items in storage. pre?: (resp: paginatedDTO) => void, post?: (resp: paginatedDTO) => void, failCallback?: (req: XMLHttpRequest) => void, ) => { this._lastLoad = Date.now(); let params = this._search.inServerSearch ? this._searchParams : this.defaultParams(); params.limit = itemLimit; params.page = page; if (params.sortByField == "") { params.sortByField = this._c.defaultSortField; params.ascending = this._c.defaultSortAscending; } _post( this._c.getPageEndpoint, params, (req: XMLHttpRequest) => { if (req.readyState != 4) return; if (req.status != 200) { if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); if (failCallback) failCallback(req); return; } this._hasLoaded = true; let resp = req.response as paginatedDTO; if (pre) pre(resp); this.lastPage = resp.last_page; appendFunc(resp); this._counter.loaded = this._search.ordering.length; if (post) post(resp); if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); }, true, ); }; // Removes all elements, and reloads the first page. public abstract reload: (callback?: (resp: paginatedDTO) => void) => void; protected _reload = (callback?: (resp: paginatedDTO) => void) => { this.lastPage = false; this._counter.reset(); this._counter.getTotal(this._c.totalEndpoint); // Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)). let limit = this._c.itemsPerPage; if (this._page != 0) { limit *= this._page + 1; } this._load( limit, 0, this._c.replaceWithNewItems, (_0: paginatedDTO) => { // Allow refreshes every 15s this._c.refreshButton.disabled = true; setTimeout(() => (this._c.refreshButton.disabled = false), 15000); }, (resp: paginatedDTO) => { this._search.onSearchBoxChange(true, false, false); if (this._search.inSearch) { // this._c.loadAllButton.classList.remove("unfocused"); } else { this._counter.shown = this._counter.loaded; this.setVisibility(this._search.ordering, true); // this._search.showHideNotFoundPanel(false); } if (callback) callback(resp); }, ); }; // Loads the next page. If "loadAll", all pages will be loaded until the last is reached. public abstract loadMore: (loadAll?: boolean, callback?: (resp?: paginatedDTO) => void) => void; protected _loadMore = (loadAll: boolean = false, callback?: (resp: paginatedDTO) => void) => { this._c.loadMoreButtons.forEach((v) => (v.disabled = true)); const timeout = setTimeout(() => { this._c.loadMoreButtons.forEach((v) => (v.disabled = false)); }, 1000); this._page += 1; this._load( this._c.itemsPerPage, this._page, this._c.appendNewItems, (resp: paginatedDTO) => { // Check before setting this.lastPage so we have a chance to cancel the timeout. if (resp.last_page) { clearTimeout(timeout); this._c.loadAllButtons.forEach((v) => removeLoader(v)); } }, (resp: paginatedDTO) => { if (this._search.inSearch || loadAll) { if (this.lastPage) { loadAll = false; } this._search.onSearchBoxChange(true, true, loadAll, callback); } else { // Since results come to us ordered already, we can assume "ordering" // will be identical to pre-page-load but with extra elements at the end, // allowing infinite scroll to continue this.setVisibility(this._search.ordering, true, true); this._search.setNotFoundPanelVisibility(false); } if (callback) callback(resp); }, ); }; public abstract loadAll: (callback?: (resp?: paginatedDTO) => void) => void; protected _loadAll = (callback?: (resp?: paginatedDTO) => void) => { this._c.loadAllButtons.forEach((v) => { addLoader(v, true); }); this.loadMore(true, callback); }; loadNItems = (n: number) => { const cb = () => { if (this._counter.loaded > n) return; this.loadMore(false, cb); }; cb(); }; // As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially. public reloadIfNotInScroll = () => { if (this._visible.length == 0 || this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) { return this.reload(); } }; _detectScroll = () => { if (!this._hasLoaded || this._scroll.scrollLoading || this._visible.length == 0) return; const scrollY = window.scrollY; const scrollSpeed = scrollY - this._scroll.lastScrollY; this._scroll.lastScrollY = scrollY; // If you've scrolled back up, do nothing if (scrollSpeed < 0) return; let endIdx = this.maximumItemsToRender(scrollY); // Throttling this function means we might not catch up in time if the user scrolls fast, // so we calculate the scroll speed (in rows/call) from the previous scrollY value. // This still might not be enough, so hackily we'll just scale it up. // With onscrollend, this is less necessary, but with both I wasn't able to hit the bottom of the page on my mouse. const rowsPerScroll = Math.round(scrollSpeed / this._scroll.rowHeight); // Render extra pages depending on scroll speed endIdx += rowsPerScroll * 2; const realEndIdx = Math.min(endIdx, this._visible.length); const frag = document.createDocumentFragment(); for (let i = this._scroll.rendered; i < realEndIdx; i++) { frag.appendChild(this._search.items[this._visible[i]].asElement()); } this._scroll.rendered = realEndIdx; this._container.appendChild(frag); if (endIdx >= this._visible.length) { if (this.lastPage || this._lastLoad + 500 > Date.now()) return; this._scroll.scrollLoading = true; const cb = () => { if (this._visible.length < endIdx && !this.lastPage) { // FIXME: This causes scroll-to-top when in search. this.loadMore(false, cb); return; } this._scroll.scrollLoading = false; this._detectScroll(); }; cb(); return; } }; detectScroll = throttle(this._detectScroll, 200); computeScrollInfo = throttle(this._computeScrollInfo, 200); redrawScroll = this.computeScrollInfo; // bindPageEvents binds window event handlers for when this list/tab containing it is visible. bindPageEvents = () => { window.addEventListener("scroll", this.detectScroll); // Not available on safari, we include a polyfill though. window.addEventListener("scrollend", this.detectScroll); window.addEventListener("resize", this.redrawScroll); }; unbindPageEvents = () => { window.removeEventListener("scroll", this.detectScroll); window.removeEventListener("scrollend", this.detectScroll); window.removeEventListener("resize", this.redrawScroll); }; }