accounts: pagination, server-side search

Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
This commit is contained in:
Harvey Tindall
2025-05-20 18:57:16 +01:00
parent fb83094532
commit 94e076401e
13 changed files with 1577 additions and 1011 deletions
+503 -484
View File
File diff suppressed because it is too large Load Diff
+212 -382
View File
@@ -1,7 +1,8 @@
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
import { _get, _post, _delete, toDateString } from "../modules/common.js";
import { SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js";
import { PaginatedList } from "./list.js";
declare var window: GlobalWindow;
@@ -32,6 +33,123 @@ var activityTypeMoods = {
"deleteInvite": -1
};
// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
const queries = (): { [field: string]: QueryType } => { return {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"date": {
name: window.lang.strings("date"),
getter: "date",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
}};
// var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload");
@@ -346,369 +464,130 @@ export class Activity implements activity, SearchableItem {
asElement = () => { return this._card; };
}
export class RecordCounter {
private _container: HTMLElement;
private _totalRecords: HTMLElement;
private _loadedRecords: HTMLElement;
private _shownRecords: HTMLElement;
private _total: number;
private _loaded: number;
private _shown: 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>
`;
this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
this.total = 0;
this.loaded = 0;
this.shown = 0;
}
reset() {
this.total = 0;
this.loaded = 0;
this.shown = 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}`);
}
}
interface ActivitiesReqDTO extends PaginatedReqDTO {
type: string[];
};
interface ActivitiesDTO extends paginatedDTO {
activities: activity[];
}
export class activityList {
private _activityList: HTMLElement;
private _activities: { [id: string]: Activity } = {};
private _ordering: string[] = [];
private _filterArea = document.getElementById("activity-filter-area");
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
private _notFoundPanel = document.getElementById("activity-not-found");
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
private _loader = document.getElementById("activity-loader");
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
private _keepSearchingButton = document.getElementById("activity-keep-searching");
export class activityList extends PaginatedList {
protected _activityList: HTMLElement;
// protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
private _counter: RecordCounter;
protected _ascending: boolean;
get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
constructor() {
super({
loader: document.getElementById("activity-loader"),
loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement,
keepSearchingDescription: document.getElementById("activity-keep-searching-description"),
keepSearchingButton: document.getElementById("activity-keep-searching"),
filterArea: document.getElementById("activity-filter-area"),
searchOptionsHeader: document.getElementById("activity-search-options-header"),
searchBox: document.getElementById("activity-search") as HTMLInputElement,
notFoundPanel: document.getElementById("activity-not-found"),
recordCounter: document.getElementById("activity-record-counter"),
totalEndpoint: "/activity/count",
getPageEndpoint: "/activity",
limit: 10,
newElementsFromPage: (resp: paginatedDTO) => {
let ordering: string[] = this._search.ordering;
for (let act of ((resp as ActivitiesDTO).activities || [])) {
this.activities[act.id] = new Activity(act);
ordering.push(act.id);
}
},
updateExistingElementsFromPage: (resp: paginatedDTO) => {
// FIXME: Implement updates to existing elements!
for (let id of Object.keys(this.activities)) {
delete this.activities[id];
}
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
this._c.newElementsFromPage(resp);
},
defaultSortField: "time",
pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
}
});
private _search: Search;
private _ascending: boolean;
private _hasLoaded: boolean;
private _lastLoad: number;
private _page: number = 0;
private _lastPage: boolean;
this._activityList = document.getElementById("activity-card-list")
document.addEventListener("activity-reload", this.reload);
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
// Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
// sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader,
notFoundPanel: this._c.notFoundPanel,
search: this._c.searchBox,
clearSearchButtonSelector: ".activity-search-clear",
serverSearchButtonSelector: ".activity-search-server",
queries: queries(),
setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,
onSearchCallback: null,
searchServer: null,
clearServerSearch: null,
}
this.initSearch(searchConfig);
this.ascending = false;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
}
setVisibility = (activities: string[], visible: boolean) => {
this._activityList.textContent = ``;
for (let id of this._ordering) {
for (let id of this._search.ordering) {
if (visible && activities.indexOf(id) != -1) {
this._activityList.appendChild(this._activities[id].asElement());
this._activityList.appendChild(this.activities[id].asElement());
} else if (!visible && activities.indexOf(id) == -1) {
this._activityList.appendChild(this._activities[id].asElement());
this._activityList.appendChild(this.activities[id].asElement());
}
}
}
reload = () => {
this._lastLoad = Date.now();
this._lastPage = false;
this._loadMoreButton.textContent = window.lang.strings("loadMore");
this._loadMoreButton.disabled = false;
this._loadAllButton.classList.remove("unfocused");
this._loadAllButton.disabled = false;
this._counter.reset();
this._counter.getTotal("/activity/count");
// this._page = 0;
let limit = 10;
if (this._page != 0) {
limit *= this._page+1;
};
let send = {
"type": [],
"limit": limit,
"page": 0,
"ascending": this.ascending
}
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
this._hasLoaded = true;
// Allow refreshes every 15s
this._refreshButton.disabled = true;
setTimeout(() => this._refreshButton.disabled = false, 15000);
let resp = req.response as ActivitiesDTO;
// FIXME: Don't destroy everything each reload!
this._activities = {};
this._ordering = [];
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
this._search.items = this._activities;
this._search.ordering = this._ordering;
this._counter.loaded = this._ordering.length;
if (this._search.inSearch) {
this._search.onSearchBoxChange(true);
this._loadAllButton.classList.remove("unfocused");
} else {
this._counter.shown = this._counter.loaded;
this.setVisibility(this._ordering, true);
this._loadAllButton.classList.add("unfocused");
this._notFoundPanel.classList.add("unfocused");
}
}, true);
this._reload();
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
this._lastLoad = Date.now();
this._loadMoreButton.disabled = true;
// this._loadAllButton.disabled = true;
const timeout = setTimeout(() => {
this._loadMoreButton.disabled = false;
// this._loadAllButton.disabled = false;
}, 1000);
this._page += 1;
let send = {
"type": [],
"limit": 10,
"page": this._page,
"ascending": this._ascending
};
// this._activityList.classList.add("unfocused");
// addLoader(this._loader, false, true);
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
this._loadMore(
loadAll,
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) return;
if (callback) callback();
}
let resp = req.response as ActivitiesDTO;
this._lastPage = resp.last_page;
if (this._lastPage) {
clearTimeout(timeout);
this._loadMoreButton.disabled = true;
removeLoader(this._loadAllButton);
this._loadAllButton.classList.add("unfocused");
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
}
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
// this._search.items = this._activities;
// this._search.ordering = this._ordering;
this._counter.loaded = this._ordering.length;
if (this._search.inSearch || loadAll) {
if (this._lastPage) {
loadAll = false;
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
if (callback) callback();
// removeLoader(this._loader);
// this._activityList.classList.remove("unfocused");
}, true);
);
}
private _queries: { [field: string]: QueryType } = {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"date": {
name: window.lang.strings("date"),
getter: "date",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
};
get ascending(): boolean { return this._ascending; }
set ascending(v: boolean) {
this._ascending = v;
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
// FIXME?: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
this._search.setOrdering(this._search.ordering, this._c.defaultSortField, this.ascending);
if (this._hasLoaded) {
this.reload();
}
}
detectScroll = () => {
if (!this._hasLoaded) return;
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
// window.notifications.customSuccess("scroll", "Reached bottom.");
// Wait .5s between loads
if (this._lastLoad + 500 > Date.now()) return;
this.loadMore();
}
}
private _prevResultCount = 0;
private _notFoundCallback = (notFound: boolean) => {
/*private _notFoundCallback = (notFound: boolean) => {
if (notFound) this._loadMoreButton.classList.add("unfocused");
else this._loadMoreButton.classList.remove("unfocused");
@@ -719,55 +598,6 @@ export class activityList {
this._keepSearchingButton.classList.add("unfocused");
this._keepSearchingDescription.classList.add("unfocused");
}
};
};*/
constructor() {
this._activityList = document.getElementById("activity-card-list");
document.addEventListener("activity-reload", this.reload);
this._counter = new RecordCounter(document.getElementById("activity-record-counter"));
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
search: this._searchBox,
clearSearchButtonSelector: ".activity-search-clear",
queries: this._queries,
setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
this._counter.shown = visibleCount;
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
else this._loadAllButton.classList.add("unfocused");
if (visibleCount < 10 || loadAll) {
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
}
this._prevResultCount = visibleCount;
}
}
this._search = new Search(conf);
this._search.generateFilterList();
this._hasLoaded = false;
this.ascending = false;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
this._loadMoreButton.onclick = () => this.loadMore();
this._loadAllButton.onclick = () => {
addLoader(this._loadAllButton, true);
this.loadMore(() => {}, true);
};
/* this._keepSearchingButton.onclick = () => {
addLoader(this._keepSearchingButton, true);
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
this._refreshButton.onclick = this.reload;
window.onscroll = this.detectScroll;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { _get, _post, addLoader, removeLoader } from "./common";
import { Search, SearchConfiguration, SearchableItems } from "./search";
declare var window: GlobalWindow;
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 = document.getElementsByClassName("records-total")[0] as HTMLElement;
this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
this._selectedRecords = document.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;
loadMoreButton: HTMLButtonElement;
loadAllButton: HTMLButtonElement;
refreshButton: HTMLButtonElement;
keepSearchingDescription: HTMLElement;
keepSearchingButton: HTMLElement;
notFoundPanel: HTMLElement;
filterArea: HTMLElement;
searchOptionsHeader: HTMLElement;
searchBox: HTMLInputElement;
recordCounter: HTMLElement;
totalEndpoint: string;
getPageEndpoint: string;
limit: number;
newElementsFromPage: (resp: paginatedDTO) => void;
updateExistingElementsFromPage: (resp: paginatedDTO) => void;
defaultSortField: string;
pageLoadCallback?: (req: XMLHttpRequest) => void;
}
export abstract class PaginatedList {
protected _c: PaginatedListConfig;
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.loadAllButton.classList.add("unfocused");
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
this._c.loadMoreButton.disabled = true;
} else {
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
this._c.loadMoreButton.disabled = false;
if (this._search.inSearch) {
this._c.loadAllButton.classList.remove("unfocused");
}
}
}
protected _previousPageSize = 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.loadMoreButton.onclick = () => this.loadMore(null, false);
this._c.loadAllButton.onclick = () => {
addLoader(this._c.loadAllButton, true);
this.loadMore(null, true);
};
/* 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.
// FIXME: Make sure it works though!
this._c.refreshButton.onclick = () => this.reload();
}
initSearch = (searchConfig: SearchConfiguration) => {
const previousCallback = searchConfig.onSearchCallback;
searchConfig.onSearchCallback = (visibleCount: number, newItems: boolean, loadAll: boolean) => {
this._counter.shown = visibleCount;
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
// else this._c.loadAllButton.classList.add("unfocused");
// FIXME: Figure out why this makes sense and make it clearer.
if ((visibleCount < this._c.limit && !this.lastPage) || loadAll) {
if (!newItems ||
this._previousPageSize != visibleCount ||
(visibleCount == 0 && !this.lastPage) ||
loadAll
) {
this.loadMore(() => {}, loadAll);
}
}
this._previousPageSize = visibleCount;
if (previousCallback) previousCallback(visibleCount, newItems, loadAll);
};
const previousServerSearch = searchConfig.searchServer;
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
this._searchParams = params;
if (newSearch) this.reload();
else this.loadMore(null, false);
if (previousServerSearch) previousServerSearch(params, newSearch);
};
searchConfig.clearServerSearch = () => {
this._page = 0;
this.reload();
}
this._search = new Search(searchConfig);
this._search.generateFilterList();
this.lastPage = false;
};
// Sets the elements with "name"s in "elements" as visible or not.
public abstract setVisibility: (elements: string[], visible: boolean) => void;
// Removes all elements, and reloads the first page.
public abstract reload: () => void;
protected _reload = (
callback?: (req: XMLHttpRequest) => void
) => {
this._lastLoad = Date.now();
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.limit;
if (this._page != 0) {
limit *= this._page+1;
}
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = limit;
params.page = 0;
if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField;
params.ascending = true;
}
_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 (callback) callback(req);
return;
}
this._hasLoaded = true;
// Allow refreshes every 15s
this._c.refreshButton.disabled = true;
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
let resp = req.response as paginatedDTO;
this.lastPage = resp.last_page;
this._c.updateExistingElementsFromPage(resp);
this._counter.loaded = this._search.ordering.length;
this._search.onSearchBoxChange(true);
if (this._search.inSearch) {
// this._c.loadAllButton.classList.remove("unfocused");
} else {
this._counter.shown = this._counter.loaded;
this.setVisibility(this._search.ordering, true);
this._c.loadAllButton.classList.add("unfocused");
this._c.notFoundPanel.classList.add("unfocused");
}
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
}, true);
}
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
public abstract loadMore: (callback: () => void, loadAll: boolean) => void;
protected _loadMore = (
loadAll: boolean = false,
callback?: (req: XMLHttpRequest) => void
) => {
this._lastLoad = Date.now();
this._c.loadMoreButton.disabled = true;
const timeout = setTimeout(() => {
this._c.loadMoreButton.disabled = false;
}, 1000);
this._page += 1;
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = this._c.limit;
params.page = this._page;
if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField;
params.ascending = true;
}
_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 (callback) callback(req);
return;
}
let resp = req.response as paginatedDTO;
// Check before setting this.lastPage so we have a chance to cancel the timeout.
if (resp.last_page) {
clearTimeout(timeout);
removeLoader(this._c.loadAllButton);
}
this.lastPage = resp.last_page;
this._c.newElementsFromPage(resp);
this._counter.loaded = this._search.ordering.length;
if (this._search.inSearch || loadAll) {
if (this.lastPage) {
loadAll = false;
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._search.ordering, true);
this._c.notFoundPanel.classList.add("unfocused");
}
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
}, true)
}
// Should be assigned to window.onscroll whenever the list is in view.
detectScroll = () => {
if (!this._hasLoaded || this.lastPage) return;
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
// window.notifications.customSuccess("scroll", "Reached bottom.");
// Wait .5s between loads
if (this._lastLoad + 500 > Date.now()) return;
this.loadMore(null, false);
}
}
}
+177 -44
View File
@@ -32,29 +32,49 @@ export interface QueryType {
export interface SearchConfiguration {
filterArea: HTMLElement;
sortingByButton: HTMLButtonElement;
sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
serverSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
clearServerSearch: () => void;
loadMore?: () => void;
}
export interface ServerSearchReqDTO extends PaginatedReqDTO {
searchTerms: string[];
queries: QueryDTO[];
}
// FIXME: Generate ServerSearchReqDTO using Query.asDTO methods in serverSearch()!
export interface QueryDTO {
class: "bool" | "string" | "date";
// QueryType.getter
field: string;
operator: QueryOperator;
value: boolean | string | DateAttempt;
};
export abstract class Query {
protected _subject: QueryType;
protected _operator: QueryOperator;
protected _card: HTMLElement;
constructor(subject: QueryType, operator: QueryOperator) {
constructor(subject: QueryType | null, operator: QueryOperator) {
this._subject = subject;
this._operator = operator;
this._card = document.createElement("span");
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
if (subject != null) {
this._card = document.createElement("span");
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
}
}
set onclick(v: () => void) {
@@ -62,8 +82,16 @@ export abstract class Query {
}
asElement(): HTMLElement { return this._card; }
}
public abstract compare(subjectValue: any): boolean;
asDTO(): QueryDTO {
let out = {} as QueryDTO;
out.field = this._subject.getter;
out.operator = this._operator;
return out;
}
}
export class BoolQuery extends Query {
protected _value: boolean;
@@ -96,13 +124,20 @@ export class BoolQuery extends Query {
public compare(subjectBool: boolean): boolean {
return ((subjectBool && this._value) || (!subjectBool && !this._value))
}
asDTO(): QueryDTO {
let out = super.asDTO();
out.class = "bool";
out.value = this._value;
return out;
}
}
export class StringQuery extends Query {
protected _value: string;
constructor(subject: QueryType, value: string) {
super(subject, QueryOperator.Equal);
this._value = value;
this._value = value.toLowerCase();
this._card.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}:</span> "${this._value}"
@@ -110,6 +145,17 @@ export class StringQuery extends Query {
}
get value(): string { return this._value; }
public compare(subjectString: string): boolean {
return subjectString.toLowerCase().includes(this._value);
}
asDTO(): QueryDTO {
let out = super.asDTO();
out.class = "string";
out.value = this._value;
return out;
}
}
export interface DateAttempt {
@@ -151,7 +197,6 @@ export class DateQuery extends Query {
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
this._value = value;
console.log("op:", operator, "date:", value);
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
let dateText = QueryOperatorToDateText(operator);
this._card.innerHTML = `
@@ -204,30 +249,58 @@ export class DateQuery extends Query {
}
return subjectDate > temp;
}
asDTO(): QueryDTO {
let out = super.asDTO();
out.class = "date";
out.value = this._value.attempt;
return out;
}
}
// FIXME: Continue taking stuff from search function, making XQuery classes!
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
}
export type SearchableItems = { [id: string]: SearchableItem };
export class Search {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = [];
private _items: { [id: string]: SearchableItem };
inSearch: boolean;
private _items: SearchableItems = {};
// Search queries (filters)
private _queries: Query[] = [];
// Plain-text search terms
private _searchTerms: string[] = [];
inSearch: boolean = false;
private _inServerSearch: boolean = false;
get inServerSearch(): boolean { return this._inServerSearch; }
set inServerSearch(v: boolean) {
this._inServerSearch = v;
if (!v) {
this._c.clearServerSearch();
}
}
private _serverSearchButtons: HTMLElement[];
// Returns a list of identifiers (keys in items, values in ordering).
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
// If we're in a server search already, the results are already correct.
if (this.inServerSearch) return result;
let words: string[] = [];
let queries = [];
let searchTerms = [];
let quoteSymbol = ``;
let queryStart = -1;
@@ -258,7 +331,6 @@ export class Search {
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
@@ -266,28 +338,31 @@ export class Search {
query = "";
for (let word of words) {
// 1. Normal search text, no filters or anything
if (!word.includes(":")) {
searchTerms.push(word);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const u = this.items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
// 2. A filter query of some sort.
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]];
let formattedQuery = []
let q: Query | null = null;
if (queryFormat.bool) {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
let q = new BoolQuery(queryFormat, boolState);
q = new BoolQuery(queryFormat, boolState);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
@@ -297,24 +372,21 @@ export class Search {
this._c.filterArea.appendChild(q.asElement());
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const u = this.items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!q.compare(value)) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
} else {
}
}
continue
}
}
if (queryFormat.string) {
const q = new StringQuery(queryFormat, split[1]);
} else if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -328,18 +400,16 @@ export class Search {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const u = this.items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
if (!(value.includes(split[1]))) {
if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
if (queryFormat.date) {
} else if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
const q = new DateQuery(queryFormat, op, parsedDate);
q = new DateQuery(queryFormat, op, parsedDate);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -354,7 +424,7 @@ export class Search {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const u = this.items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
@@ -362,20 +432,24 @@ export class Search {
}
let value = new Date(unixValue*1000);
let match = q.compare(value);
if (!match) {
if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
}
if (q != null) queries.push(q);
}
this._queries = queries;
this._searchTerms = searchTerms;
return result;
}
showHideSearchOptionsHeader = () => {
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
let sortingBy = false;
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
@@ -383,14 +457,24 @@ export class Search {
}
}
// -all- elements.
get items(): { [id: string]: SearchableItem } { return this._items; }
set items(v: { [id: string]: SearchableItem }) {
this._items = v;
// set items(v: { [id: string]: SearchableItem }) {
// this._items = v;
// }
// The order of -all- elements (even those hidden), by their identifier.
get ordering(): string[] { return this._ordering; }
// Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params).
// set ordering(v: string[]) { this._ordering = v; }
setOrdering = (v: string[], field: string, ascending: boolean) => {
this._ordering = v;
this._sortField = field;
this._ascending = ascending;
}
get ordering(): string[] { return this._ordering; }
set ordering(v: string[]) { this._ordering = v; }
get sortField(): string { return this._sortField; }
get ascending(): boolean { return this._ascending; }
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
const query = this._c.search.value;
@@ -402,11 +486,23 @@ export class Search {
const results = this.search(query);
this._c.setVisibility(results, true);
this._c.onSearchCallback(results.length, newItems, loadAll);
if (this.inServerSearch) {
this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.add("@low");
v.classList.remove("@high");
});
} else {
this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.add("@high");
v.classList.remove("@low");
});
}
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
}
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
}
@@ -420,9 +516,8 @@ export class Search {
this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any);
};
// FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
generateFilterList = () => {
// Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) {
@@ -497,17 +592,55 @@ export class Search {
}
}
onServerSearch = () => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
}
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
let req: ServerSearchReqDTO = {
searchTerms: searchTerms,
queries: queries.map((q: Query) => q.asDTO()),
limit: -1,
page: 0,
sortByField: this.sortField,
ascending: this.ascending
};
return req;
}
constructor(c: SearchConfiguration) {
// FIXME: Remove!
if (c.search.id.includes("accounts")) {
(window as any).s = this;
}
this._c = c;
this._c.search.oninput = () => this.onSearchBoxChange();
this._c.search.oninput = () => {
this.inServerSearch = false;
this.onSearchBoxChange();
}
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
if (ev.key == "Enter") {
this.onServerSearch();
}
});
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
this.inServerSearch = false;
this.onSearchBoxChange();
});
}
this._serverSearchButtons = Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of this._serverSearchButtons) {
b.addEventListener("click", () => {
this.onServerSearch();
});
}
}
}
-2
View File
@@ -1,7 +1,5 @@
import { PageManager, Page } from "../modules/pages.js";
declare var window: GlobalWindow;
export interface Tab {
page: Page;
tabEl: HTMLDivElement;