3308739619
tab content classes (e.g. settingsList, activityList) can implement "AsTab", "Navigatable" and or "PageEventBindable", the first giving them a tab name, a subpath and a reloader function, the second an "isURL" and "navigate" function for loading resources, the last giving them bind/unbindPageEvent methods. These are looped through in ts/admin.ts still crudely, maybe tabs.ts could accept "AsTab" implementers directly. "Search" class now has a ?search query param which just encodes the search box content, set when you perform a server search (hit enter or press the button). ?user queries from the accounts or activity tab will be converted to this form on loading.
791 lines
28 KiB
TypeScript
791 lines
28 KiB
TypeScript
import { ListItem } from "./list";
|
|
import { parseDateString } from "./common";
|
|
|
|
declare var window: GlobalWindow;
|
|
|
|
export enum QueryOperator {
|
|
Greater = ">",
|
|
Lower = "<",
|
|
Equal = "=",
|
|
}
|
|
|
|
export function QueryOperatorToDateText(op: QueryOperator): string {
|
|
switch (op) {
|
|
case QueryOperator.Greater:
|
|
return window.lang.strings("after");
|
|
case QueryOperator.Lower:
|
|
return window.lang.strings("before");
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export interface QueryType {
|
|
name: string;
|
|
description?: string;
|
|
getter: string;
|
|
bool: boolean;
|
|
string: boolean;
|
|
date: boolean;
|
|
dependsOnElement?: string; // Format for querySelector
|
|
show?: boolean;
|
|
localOnly?: boolean; // Indicates can't be performed server-side.
|
|
}
|
|
|
|
export interface SearchConfiguration {
|
|
filterArea?: HTMLElement;
|
|
sortingByButton?: HTMLButtonElement;
|
|
searchOptionsHeader?: HTMLElement;
|
|
notFoundPanel?: HTMLElement;
|
|
notFoundLocallyText?: HTMLElement;
|
|
notFoundCallback?: (notFound: boolean) => void;
|
|
filterList?: HTMLElement;
|
|
clearSearchButtonSelector?: string;
|
|
serverSearchButtonSelector?: string;
|
|
search?: HTMLInputElement;
|
|
queries: { [field: string]: QueryType };
|
|
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
|
|
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void) => void;
|
|
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
|
|
clearServerSearch: () => void;
|
|
loadMore?: () => void;
|
|
}
|
|
|
|
export interface ServerSearchReqDTO extends PaginatedReqDTO {
|
|
searchTerms: string[];
|
|
queries: QueryDTO[];
|
|
}
|
|
|
|
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;
|
|
protected _id: string;
|
|
public type: string;
|
|
|
|
constructor(id: string, subject: QueryType | null, operator: QueryOperator) {
|
|
this._id = id;
|
|
this._subject = subject;
|
|
this._operator = operator;
|
|
if (subject != null) {
|
|
this._card = document.createElement("span");
|
|
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
|
}
|
|
}
|
|
|
|
set onclick(v: () => void) {
|
|
this._card.addEventListener("click", v);
|
|
}
|
|
|
|
asElement(): HTMLElement {
|
|
return this._card;
|
|
}
|
|
|
|
public abstract compare(subjectValue: any): boolean;
|
|
|
|
asDTO(): QueryDTO | null {
|
|
if (this.localOnly) return null;
|
|
let out = {} as QueryDTO;
|
|
out.field = this._subject.getter;
|
|
out.operator = this._operator;
|
|
return out;
|
|
}
|
|
|
|
get subject(): QueryType {
|
|
return this._subject;
|
|
}
|
|
|
|
getValueFromItem(item: SearchableItem): any {
|
|
return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(item), this.subject.getter).get.call(item);
|
|
}
|
|
|
|
compareItem(item: SearchableItem): boolean {
|
|
return this.compare(this.getValueFromItem(item));
|
|
}
|
|
|
|
get localOnly(): boolean {
|
|
return this._subject.localOnly ? true : false;
|
|
}
|
|
}
|
|
|
|
export class BoolQuery extends Query {
|
|
protected _value: boolean;
|
|
constructor(id: string, subject: QueryType, value: boolean) {
|
|
super(id, subject, QueryOperator.Equal);
|
|
this.type = "bool";
|
|
this._value = value;
|
|
this._card.classList.add("button", "@high", "center", "flex", "flex-row", "gap-2");
|
|
if (this._value) {
|
|
this._card.classList.add("~positive");
|
|
} else {
|
|
this._card.classList.add("~critical");
|
|
}
|
|
this._card.innerHTML = `
|
|
<span class="font-bold">${subject.name}</span>
|
|
<i class="text-2xl ri-${this._value ? "checkbox" : "close"}-circle-fill"></i>
|
|
`;
|
|
}
|
|
|
|
public static paramsFromString(valueString: string): [boolean, boolean] {
|
|
let isBool = false;
|
|
let boolState = false;
|
|
if (valueString == "true" || valueString == "yes" || valueString == "t" || valueString == "y") {
|
|
isBool = true;
|
|
boolState = true;
|
|
} else if (valueString == "false" || valueString == "no" || valueString == "f" || valueString == "n") {
|
|
isBool = true;
|
|
boolState = false;
|
|
}
|
|
return [boolState, isBool];
|
|
}
|
|
|
|
get value(): boolean {
|
|
return this._value;
|
|
}
|
|
|
|
// Ripped from old code. Why it's like this, I don't know
|
|
public compare(subjectBool: boolean): boolean {
|
|
return (subjectBool && this._value) || (!subjectBool && !this._value);
|
|
}
|
|
|
|
asDTO(): QueryDTO | null {
|
|
let out = super.asDTO();
|
|
if (out === null) return null;
|
|
out.class = "bool";
|
|
out.value = this._value;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
export class StringQuery extends Query {
|
|
protected _value: string;
|
|
constructor(id: string, subject: QueryType, value: string) {
|
|
super(id, subject, QueryOperator.Equal);
|
|
this.type = "string";
|
|
this._value = value.toLowerCase();
|
|
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
|
|
this._card.innerHTML = `
|
|
<span class="font-bold">${subject.name}:</span> "${this._value}"
|
|
`;
|
|
}
|
|
|
|
get value(): string {
|
|
return this._value;
|
|
}
|
|
|
|
public compare(subjectString: string): boolean {
|
|
return subjectString.toLowerCase().includes(this._value);
|
|
}
|
|
|
|
asDTO(): QueryDTO | null {
|
|
let out = super.asDTO();
|
|
if (out === null) return null;
|
|
out.class = "string";
|
|
out.value = this._value;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
const dateGetters: Map<string, () => number> = (() => {
|
|
let m = new Map<string, () => number>();
|
|
m.set("year", Date.prototype.getFullYear);
|
|
m.set("month", Date.prototype.getMonth);
|
|
m.set("day", Date.prototype.getDate);
|
|
m.set("hour", Date.prototype.getHours);
|
|
m.set("minute", Date.prototype.getMinutes);
|
|
return m;
|
|
})();
|
|
const dateSetters: Map<string, (v: number) => void> = (() => {
|
|
let m = new Map<string, (v: number) => void>();
|
|
m.set("year", Date.prototype.setFullYear);
|
|
m.set("month", Date.prototype.setMonth);
|
|
m.set("day", Date.prototype.setDate);
|
|
m.set("hour", Date.prototype.setHours);
|
|
m.set("minute", Date.prototype.setMinutes);
|
|
return m;
|
|
})();
|
|
|
|
export class DateQuery extends Query {
|
|
protected _value: ParsedDate;
|
|
|
|
constructor(id: string, subject: QueryType, operator: QueryOperator, value: ParsedDate) {
|
|
super(id, subject, operator);
|
|
this.type = "date";
|
|
this._value = value;
|
|
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
|
|
let dateText = QueryOperatorToDateText(operator);
|
|
this._card.innerHTML = `
|
|
<span class="font-bold">${subject.name}:</span> ${dateText != "" ? dateText + " " : ""}${value.text}
|
|
`;
|
|
}
|
|
|
|
public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] {
|
|
let op = QueryOperator.Equal;
|
|
if ((Object.values(QueryOperator) as string[]).includes(valueString.charAt(0))) {
|
|
op = valueString.charAt(0) as QueryOperator;
|
|
// Trim the operator from the string
|
|
valueString = valueString.substring(1);
|
|
}
|
|
let out = parseDateString(valueString);
|
|
let isValid = true;
|
|
if (out.invalid) isValid = false;
|
|
|
|
return [out, op, isValid];
|
|
}
|
|
|
|
get value(): ParsedDate {
|
|
return this._value;
|
|
}
|
|
|
|
public compare(subjectDate: Date): boolean {
|
|
// We want to compare only the fields given in this._value,
|
|
// so we copy subjectDate and apply on those fields from this._value.
|
|
const temp = new Date(subjectDate.valueOf());
|
|
for (let [field] of dateGetters) {
|
|
if (field in this._value.attempt) {
|
|
dateSetters.get(field).call(temp, dateGetters.get(field).call(this._value.date));
|
|
}
|
|
}
|
|
|
|
if (this._operator == QueryOperator.Equal) {
|
|
return subjectDate.getTime() == temp.getTime();
|
|
} else if (this._operator == QueryOperator.Lower) {
|
|
return subjectDate < temp;
|
|
}
|
|
return subjectDate > temp;
|
|
}
|
|
|
|
asDTO(): QueryDTO | null {
|
|
let out = super.asDTO();
|
|
if (out === null) return null;
|
|
out.class = "date";
|
|
out.value = this._value.attempt;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
export interface SearchableItem extends ListItem {
|
|
matchesSearch: (query: string) => boolean;
|
|
}
|
|
|
|
export const SearchableItemDataAttribute = "data-search-item";
|
|
|
|
export type SearchableItems = Map<string, SearchableItem>;
|
|
|
|
export class Search implements Navigatable {
|
|
private _c: SearchConfiguration;
|
|
private _sortField: string = "";
|
|
private _ascending: boolean = true;
|
|
private _ordering: string[] = [];
|
|
private _items: SearchableItems = new Map<string, SearchableItem>();
|
|
// 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) {
|
|
const previous = this._inServerSearch;
|
|
this._inServerSearch = v;
|
|
if (!v && previous != v) {
|
|
this._c.clearServerSearch();
|
|
}
|
|
}
|
|
|
|
// Intended to be set from the JS console, if true searches are timed.
|
|
timeSearches: boolean = false;
|
|
|
|
private _serverSearchButtons: HTMLElement[];
|
|
|
|
static tokenizeSearch = (query: string): string[] => {
|
|
query = query.toLowerCase();
|
|
|
|
let words: string[] = [];
|
|
let quoteSymbol = ``;
|
|
let queryStart = -1;
|
|
let lastQuote = -1;
|
|
for (let i = 0; i < query.length; i++) {
|
|
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
|
|
queryStart = i;
|
|
}
|
|
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
|
|
if (lastQuote != -1) {
|
|
lastQuote = -1;
|
|
quoteSymbol = ``;
|
|
} else {
|
|
lastQuote = i;
|
|
quoteSymbol = query[i];
|
|
}
|
|
}
|
|
|
|
if (query[i] == " " || i == query.length - 1) {
|
|
if (lastQuote != -1) {
|
|
continue;
|
|
} else {
|
|
let end = i + 1;
|
|
if (query[i] == " ") {
|
|
end = i;
|
|
while (i + 1 < query.length && query[i + 1] == " ") {
|
|
i += 1;
|
|
}
|
|
}
|
|
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
|
queryStart = -1;
|
|
}
|
|
}
|
|
}
|
|
return words;
|
|
};
|
|
|
|
parseTokens = (tokens: string[]): [string[], Query[]] => {
|
|
let queries: Query[] = [];
|
|
let searchTerms: string[] = [];
|
|
|
|
for (let word of tokens) {
|
|
// 1. Normal search text, no filters or anything
|
|
if (!word.includes(":")) {
|
|
searchTerms.push(word);
|
|
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 q: Query | null = null;
|
|
|
|
if (queryFormat.bool) {
|
|
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
|
|
if (isBool) {
|
|
q = new BoolQuery(split[0], queryFormat, boolState);
|
|
q.onclick = () => {
|
|
for (let quote of [`"`, `'`, ``]) {
|
|
this._c.search.value = this._c.search.value.replace(
|
|
split[0] + ":" + quote + split[1] + quote,
|
|
"",
|
|
);
|
|
}
|
|
this._c.search.oninput(null as Event);
|
|
};
|
|
queries.push(q);
|
|
continue;
|
|
}
|
|
}
|
|
if (queryFormat.string) {
|
|
q = new StringQuery(split[0], queryFormat, split[1]);
|
|
|
|
q.onclick = () => {
|
|
for (let quote of [`"`, `'`, ``]) {
|
|
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
|
this._c.search.value = this._c.search.value.replace(regex, "");
|
|
}
|
|
this._c.search.oninput(null as Event);
|
|
};
|
|
queries.push(q);
|
|
continue;
|
|
}
|
|
if (queryFormat.date) {
|
|
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
|
if (!isDate) continue;
|
|
q = new DateQuery(split[0], queryFormat, op, parsedDate);
|
|
|
|
q.onclick = () => {
|
|
for (let quote of [`"`, `'`, ``]) {
|
|
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
|
this._c.search.value = this._c.search.value.replace(regex, "");
|
|
}
|
|
|
|
this._c.search.oninput(null as Event);
|
|
};
|
|
queries.push(q);
|
|
continue;
|
|
}
|
|
// if (q != null) queries.push(q);
|
|
}
|
|
return [searchTerms, queries];
|
|
};
|
|
|
|
// Returns a list of identifiers (used as keys in items, values in ordering).
|
|
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
|
|
let result: string[] = [...this._ordering];
|
|
// If we didn't care about rendering the query cards, we could run this to (maybe) return early.
|
|
// if (this.inServerSearch) {
|
|
// let hasLocalOnlyQueries = false;
|
|
// for (const q of queries) {
|
|
// if (q.localOnly) {
|
|
// hasLocalOnlyQueries = true;
|
|
// break;
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// Normal searches can be evaluated by the server, so skip this if we've already ran one.
|
|
if (!this.inServerSearch) {
|
|
for (let term of searchTerms) {
|
|
let cachedResult = [...result];
|
|
for (let id of cachedResult) {
|
|
const u = this.items.get(id);
|
|
if (!u.matchesSearch(term)) {
|
|
result.splice(result.indexOf(id), 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let q of queries) {
|
|
this._c.filterArea?.appendChild(q.asElement());
|
|
// Skip if this query has already been performed by the server.
|
|
if (this.inServerSearch && !q.localOnly) continue;
|
|
|
|
let cachedResult = [...result];
|
|
if (q.type == "bool") {
|
|
for (let id of cachedResult) {
|
|
const u = this.items.get(id);
|
|
// Remove from result if not matching query
|
|
if (!q.compareItem(u)) {
|
|
// console.log("not matching, result is", result);
|
|
result.splice(result.indexOf(id), 1);
|
|
}
|
|
}
|
|
} else if (q.type == "string") {
|
|
for (let id of cachedResult) {
|
|
const u = this.items.get(id);
|
|
// We want to compare case-insensitively, so we get value, lower-case it then compare,
|
|
// rather than doing both with compareItem.
|
|
const value = q.getValueFromItem(u).toLowerCase();
|
|
if (!q.compare(value)) {
|
|
result.splice(result.indexOf(id), 1);
|
|
}
|
|
}
|
|
} else if (q.type == "date") {
|
|
for (let id of cachedResult) {
|
|
const u = this.items.get(id);
|
|
// Getter here returns a unix timestamp rather than a date, so we can't use compareItem.
|
|
const unixValue = q.getValueFromItem(u);
|
|
if (unixValue == 0) {
|
|
result.splice(result.indexOf(id), 1);
|
|
continue;
|
|
}
|
|
let value = new Date(unixValue * 1000);
|
|
|
|
if (!q.compare(value)) {
|
|
result.splice(result.indexOf(id), 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// Returns a list of identifiers (used as keys in items, values in ordering).
|
|
search = (query: string): string[] => {
|
|
let timer = this.timeSearches ? performance.now() : null;
|
|
if (this._c.filterArea) this._c.filterArea.textContent = "";
|
|
|
|
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
|
|
|
|
let result = this.searchParsed(searchTerms, queries);
|
|
|
|
this._queries = queries;
|
|
this._searchTerms = searchTerms;
|
|
|
|
if (this.timeSearches) {
|
|
const totalTime = performance.now() - timer;
|
|
console.debug(`Search took ${totalTime}ms`);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// postServerSearch performs local-only queries after a server search if necessary.
|
|
postServerSearch = () => {
|
|
this.searchParsed(this._searchTerms, this._queries);
|
|
};
|
|
|
|
showHideSearchOptionsHeader = () => {
|
|
let sortingBy = false;
|
|
if (this._c.sortingByButton) sortingBy = !this._c.sortingByButton.classList.contains("hidden");
|
|
const hasFilters = this._c.filterArea ? this._c.filterArea.textContent != "" : false;
|
|
if (sortingBy || hasFilters) {
|
|
this._c.searchOptionsHeader?.classList.remove("hidden");
|
|
} else {
|
|
this._c.searchOptionsHeader?.classList.add("hidden");
|
|
}
|
|
};
|
|
|
|
// -all- elements.
|
|
get items(): Map<string, SearchableItem> {
|
|
return this._items;
|
|
}
|
|
// 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 sortField(): string {
|
|
return this._sortField;
|
|
}
|
|
get ascending(): boolean {
|
|
return this._ascending;
|
|
}
|
|
|
|
onSearchBoxChange = (
|
|
newItems: boolean = false,
|
|
appendedItems: boolean = false,
|
|
loadAll: boolean = false,
|
|
callback?: (resp: paginatedDTO) => void,
|
|
) => {
|
|
const query = this._c.search.value;
|
|
if (!query) {
|
|
this.inSearch = false;
|
|
} else {
|
|
this.inSearch = true;
|
|
}
|
|
const results = this.search(query);
|
|
this._c.setVisibility(results, true, appendedItems);
|
|
this._c.onSearchCallback(newItems, loadAll, callback);
|
|
if (this.inSearch) {
|
|
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();
|
|
this.setNotFoundPanelVisibility(results.length == 0);
|
|
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
|
};
|
|
|
|
setNotFoundPanelVisibility = (visible: boolean) => {
|
|
if (this._inServerSearch || !this.inSearch) {
|
|
this._c.notFoundLocallyText?.classList.add("unfocused");
|
|
} else if (this.inSearch) {
|
|
this._c.notFoundLocallyText?.classList.remove("unfocused");
|
|
}
|
|
if (visible) {
|
|
this._c.notFoundPanel?.classList.remove("unfocused");
|
|
} else {
|
|
this._c.notFoundPanel?.classList.add("unfocused");
|
|
}
|
|
};
|
|
|
|
fillInFilter = (name: string, value: string, offset?: number) => {
|
|
this._c.search.value = name + ":" + value + " " + this._c.search.value;
|
|
this._c.search.focus();
|
|
let newPos = name.length + 1 + value.length;
|
|
if (typeof offset !== "undefined") newPos += offset;
|
|
this._c.search.setSelectionRange(newPos, newPos);
|
|
this._c.search.oninput(null as any);
|
|
};
|
|
|
|
generateFilterList = () => {
|
|
if (!this._c.filterList) return;
|
|
const filterListContainer = document.createElement("div");
|
|
filterListContainer.classList.add("flex", "flex-row", "flex-wrap", "gap-2");
|
|
// Generate filter buttons
|
|
for (let queryName of Object.keys(this._c.queries)) {
|
|
const query = this._c.queries[queryName];
|
|
if ("show" in query && !query.show) continue;
|
|
if ("dependsOnElement" in query && query.dependsOnElement) {
|
|
const el = document.querySelector(query.dependsOnElement);
|
|
if (el === null) continue;
|
|
}
|
|
|
|
const container = document.createElement("span") as HTMLSpanElement;
|
|
container.classList.add(
|
|
"button",
|
|
"button-xl",
|
|
"~neutral",
|
|
"@low",
|
|
"align-bottom",
|
|
"flex",
|
|
"flex-row",
|
|
"gap-2",
|
|
);
|
|
container.innerHTML = `
|
|
<div class="flex flex-col">
|
|
<span>${query.name}</span>
|
|
<span class="support">${query.description || ""}</span>
|
|
</div>
|
|
`;
|
|
if (query.bool) {
|
|
const pos = document.createElement("button") as HTMLButtonElement;
|
|
pos.type = "button";
|
|
pos.ariaLabel = `Filter by "${query.name}": True`;
|
|
pos.classList.add("button", "~positive");
|
|
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
|
|
pos.addEventListener("click", () => this.fillInFilter(queryName, "true"));
|
|
const neg = document.createElement("button") as HTMLButtonElement;
|
|
neg.type = "button";
|
|
neg.ariaLabel = `Filter by "${query.name}": False`;
|
|
neg.classList.add("button", "~critical");
|
|
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
|
|
neg.addEventListener("click", () => this.fillInFilter(queryName, "false"));
|
|
|
|
container.appendChild(pos);
|
|
container.appendChild(neg);
|
|
}
|
|
if (query.string) {
|
|
const button = document.createElement("button") as HTMLButtonElement;
|
|
button.type = "button";
|
|
button.classList.add("button", "~urge", "flex", "flex-row", "gap-2");
|
|
button.innerHTML = `<i class="ri-equal-line"></i>${window.lang.strings("matchText")}`;
|
|
|
|
// Position cursor between quotes
|
|
button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1));
|
|
|
|
container.appendChild(button);
|
|
}
|
|
if (query.date) {
|
|
const onDate = document.createElement("button") as HTMLButtonElement;
|
|
onDate.type = "button";
|
|
onDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2");
|
|
onDate.innerHTML = `<i class="ri-calendar-check-line"></i>On Date`;
|
|
onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1));
|
|
|
|
const beforeDate = document.createElement("button") as HTMLButtonElement;
|
|
beforeDate.type = "button";
|
|
beforeDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2");
|
|
beforeDate.innerHTML = `<i class="ri-calendar-check-line"></i>Before Date`;
|
|
beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1));
|
|
|
|
const afterDate = document.createElement("button") as HTMLButtonElement;
|
|
afterDate.type = "button";
|
|
afterDate.classList.add("button", "~urge", "flex", "flex-row", "gap-2");
|
|
afterDate.innerHTML = `<i class="ri-calendar-check-line"></i>After Date`;
|
|
afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1));
|
|
|
|
container.appendChild(onDate);
|
|
container.appendChild(beforeDate);
|
|
container.appendChild(afterDate);
|
|
}
|
|
|
|
filterListContainer.appendChild(container);
|
|
}
|
|
this._c.filterList.appendChild(filterListContainer);
|
|
};
|
|
|
|
onServerSearch = () => {
|
|
const newServerSearch = !this.inServerSearch;
|
|
this.inServerSearch = true;
|
|
this.setQueryParam();
|
|
this.searchServer(newServerSearch);
|
|
};
|
|
|
|
searchServer = (newServerSearch: boolean) => {
|
|
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()) won't work as localOnly queries return null
|
|
limit: -1,
|
|
page: 0,
|
|
sortByField: this.sortField,
|
|
ascending: this.ascending,
|
|
};
|
|
for (const q of queries) {
|
|
const dto = q.asDTO();
|
|
if (dto !== null) req.queries.push(dto);
|
|
}
|
|
return req;
|
|
};
|
|
|
|
// setQueryParam sets the ?search query param to the current searchbox content.
|
|
setQueryParam = () => {
|
|
const url = new URL(window.location.href);
|
|
// FIXME: do better and make someone else clear this
|
|
url.searchParams.delete("user");
|
|
url.searchParams.set("search", this._c.search.value);
|
|
window.history.pushState(null, "", url.toString());
|
|
};
|
|
|
|
setServerSearchButtonsDisabled = (disabled: boolean) => {
|
|
this._serverSearchButtons.forEach((v: HTMLButtonElement) => (v.disabled = disabled));
|
|
};
|
|
|
|
isURL = (url?: string) => {
|
|
const urlParams = new URLSearchParams(url || window.location.search);
|
|
const searchContent = urlParams.get("search");
|
|
return Boolean(searchContent);
|
|
};
|
|
|
|
navigate = (url?: string) => {
|
|
const urlParams = new URLSearchParams(url || window.location.search);
|
|
const searchContent = urlParams.get("search");
|
|
this._c.search.value = searchContent;
|
|
this.onSearchBoxChange();
|
|
this.onServerSearch();
|
|
};
|
|
|
|
constructor(c: SearchConfiguration) {
|
|
this._c = c;
|
|
if (!this._c.search) {
|
|
// Make a dummy one
|
|
this._c.search = document.createElement("input") as HTMLInputElement;
|
|
}
|
|
|
|
this._c.search.oninput = () => {
|
|
this.inServerSearch = false;
|
|
this.onSearchBoxChange();
|
|
};
|
|
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
|
if (ev.key == "Enter") {
|
|
this.onServerSearch();
|
|
}
|
|
});
|
|
|
|
if (this._c.clearSearchButtonSelector) {
|
|
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 = this._c.serverSearchButtonSelector
|
|
? (Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>)
|
|
: [];
|
|
for (let b of this._serverSearchButtons) {
|
|
b.addEventListener("click", () => {
|
|
this.onServerSearch();
|
|
});
|
|
}
|
|
}
|
|
}
|