Files
jfa-go/ts/modules/list.ts
Harvey Tindall 817107622a ts: format finally
formatted with biome, a config file is provided.
2025-12-08 20:38:30 +00:00

553 lines
21 KiB
TypeScript

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 = `
<span class="records-total"></span>
<span class="records-loaded"></span>
<span class="records-shown"></span>
<span class="records-selected"></span>
`;
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<HTMLButtonElement>;
loadAllButtons: Array<HTMLButtonElement>;
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);
};
}