mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-03-18 21:50:33 +01:00
Navigatable has clearURL, which for Search clears "search" qp, and invites clears "invite" qp. Tab interfaces optionally include "contentObject: AsTab", and show/hide funcs are passed the contentObject of the previously loaded tab if one is available, so that they can call it's clearURL method. This means searches you typed for the accounts tab won't pop up when switching to activity.
823 lines
30 KiB
TypeScript
823 lines
30 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, then?: () => void) => 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;
|
|
}
|
|
|
|
// FIXME: This is being called by navigate, and triggering a "load more" when we haven't loaded at all, and loading without a searchc when we have one!
|
|
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 = (then?: () => void) => {
|
|
const newServerSearch = !this.inServerSearch;
|
|
this.inServerSearch = true;
|
|
// this.setQueryParam();
|
|
this.searchServer(newServerSearch, then);
|
|
};
|
|
|
|
searchServer = (newServerSearch: boolean, then?: () => void) => {
|
|
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch, then);
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
private _qps: URLSearchParams = new URLSearchParams();
|
|
private _clearWithoutNavigate = false;
|
|
// clearQueryParam removes the "search" query parameter --without-- triggering a navigate call.
|
|
clearQueryParam = () => {
|
|
if (!this._qps.has("search")) return;
|
|
this._clearWithoutNavigate = true;
|
|
this.setQueryParam("");
|
|
};
|
|
|
|
clearURL() {
|
|
this.clearQueryParam();
|
|
}
|
|
|
|
// setQueryParam sets the ?search query param to the current searchbox content,
|
|
// or value if given. If everything is set up correctly, this should trigger a search when it is
|
|
// set to a new value.
|
|
setQueryParam = (value?: string) => {
|
|
let triggerManually = false;
|
|
if (value === undefined || value == null) value = this._c.search.value;
|
|
const url = new URL(window.location.href);
|
|
// FIXME: do better and make someone else clear this
|
|
if (value.trim()) {
|
|
url.searchParams.delete("user");
|
|
url.searchParams.set("search", value);
|
|
} else {
|
|
// If the query param is already blank, no change will mean no call to navigate()
|
|
triggerManually = !url.searchParams.has("search");
|
|
url.searchParams.delete("search");
|
|
}
|
|
console.log("pushing", url.toString());
|
|
window.history.pushState(null, "", url.toString());
|
|
if (triggerManually) this.navigate();
|
|
};
|
|
|
|
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 pulls the current "search" query param, puts it in the search box and searches it.
|
|
navigate = (url?: string, then?: () => void) => {
|
|
this._qps = new URLSearchParams(url || window.location.search);
|
|
if (this._clearWithoutNavigate) {
|
|
this._clearWithoutNavigate = false;
|
|
return;
|
|
}
|
|
|
|
const searchContent = this._qps.get("search") || "";
|
|
this._c.search.value = searchContent;
|
|
this.onSearchBoxChange();
|
|
this.onServerSearch(then);
|
|
};
|
|
|
|
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.clearQueryParam();
|
|
this.onSearchBoxChange();
|
|
};
|
|
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
|
if (ev.key == "Enter") {
|
|
this.setQueryParam();
|
|
}
|
|
});
|
|
|
|
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.inServerSearch = false;
|
|
this.setQueryParam("");
|
|
});
|
|
}
|
|
}
|
|
|
|
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.setQueryParam();
|
|
});
|
|
}
|
|
}
|
|
}
|