admin: tab system improvement, search: ?search qp

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.
This commit is contained in:
Harvey Tindall
2025-12-24 12:56:02 +00:00
parent 748acc13c0
commit 3308739619
10 changed files with 145 additions and 129 deletions
+22 -13
View File
@@ -246,7 +246,7 @@ class User extends TableRow implements UserDTO, SearchableItem {
setSelected(state: boolean, dispatchEvent: boolean) {
this._selected = state;
this._check.checked = state;
if (dispatchEvent && !(this.notInList))
if (dispatchEvent && !this.notInList)
state ? document.dispatchEvent(this._checkEvent()) : document.dispatchEvent(this._uncheckEvent());
}
@@ -933,7 +933,7 @@ class User extends TableRow implements UserDTO, SearchableItem {
};
remove = () => {
if (this.selected && !(this.notInList)) {
if (this.selected && !this.notInList) {
document.dispatchEvent(this._uncheckEvent());
}
super.remove();
@@ -956,7 +956,9 @@ declare interface ExtendExpiryDTO {
try_extend_from_previous_expiry?: boolean;
}
export class accountsList extends PaginatedList {
export class accountsList extends PaginatedList implements Navigatable, AsTab {
readonly tabName = "accounts";
readonly pagePath = "accounts";
private _details: UserInfo;
private _table = document.getElementById("accounts-table") as HTMLTableElement;
protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement;
@@ -1216,7 +1218,7 @@ export class accountsList extends PaginatedList {
this._applyJellyseerr?.parentElement.classList.add("unfocused");
},
},
)
);
if (window.referralsEnabled) {
this._enableReferralsSource = new RadioBasedTabSelector(
@@ -2467,16 +2469,23 @@ export class accountsList extends PaginatedList {
this.focusAccount(event.detail);
});
isAccountURL = () => {
const urlParams = new URLSearchParams(window.location.search);
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const userID = urlParams.get("user");
return Boolean(userID);
return Boolean(userID) || this._search.isURL(url);
};
loadAccountURL = () => {
const urlParams = new URLSearchParams(window.location.search);
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const userID = urlParams.get("user");
this.focusAccount(userID);
let search = urlParams.get("search") || "";
if (userID) {
search = `id:${userID}" ` + search;
urlParams.set("search", search);
// Get rid of it, as it'll now be included in the "search" param anyway
urlParams.delete("user");
}
this._search.navigate(urlParams.toString());
};
}
@@ -2981,10 +2990,10 @@ class UserInfo extends PaginatedList {
this.jfId = user.id;
const clone = new User(user);
clone.notInList = true;
clone.setSelected(true, false)
clone.setSelected(true, false);
this._rowArea.replaceChildren(clone.asElement());
this._link.setAttribute("data-id", this.jfId)
this._link.href = `${window.pages.Base}${window.pages.Admin}/activity?user=${this.username}`
this._link.setAttribute("data-id", this.jfId);
this._link.href = `${window.pages.Base}${window.pages.Admin}/activity?user=${this.username}`;
if (onBack) {
this._back.classList.remove("unfocused");
+17 -10
View File
@@ -558,7 +558,9 @@ interface ActivitiesDTO extends paginatedDTO {
activities: activity[];
}
export class activityList extends PaginatedList {
export class activityList extends PaginatedList implements Navigatable, AsTab {
readonly tabName = "activity";
readonly pagePath = "activity";
protected _container: HTMLElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
@@ -687,18 +689,23 @@ export class activityList extends PaginatedList {
this._keepSearchingDescription.classList.add("unfocused");
}
};*/
isActivityURL = () => {
const urlParams = new URLSearchParams(window.location.search);
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const username = urlParams.get("user");
return Boolean(username);
return Boolean(username) || this._search.isURL(url);
};
loadActivityURL = () => {
const urlParams = new URLSearchParams(window.location.search);
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const username = urlParams.get("user");
this._c.searchBox.value = `user:"${username}"`;
this._search.onSearchBoxChange();
this._search.onServerSearch();
let search = urlParams.get("search") || "";
if (username) {
search = `user:"${username}" ` + search;
urlParams.set("search", search);
// Get rid of it, as it'll now be included in the "search" param anyway
urlParams.delete("user");
}
this._search.navigate(urlParams.toString());
};
}
+6 -4
View File
@@ -784,6 +784,8 @@ class DOMInvite implements Invite {
}
export class DOMInviteList implements InviteList {
readonly tabName = "invites";
readonly pagePath = "";
private _list: HTMLDivElement;
private _empty: boolean;
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
@@ -804,14 +806,14 @@ export class DOMInviteList implements InviteList {
this.focusInvite(event.detail);
});
isInviteURL = () => {
const urlParams = new URLSearchParams(window.location.search);
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const inviteCode = urlParams.get("invite");
return Boolean(inviteCode);
};
loadInviteURL = () => {
const urlParams = new URLSearchParams(window.location.search);
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const inviteCode = urlParams.get("invite");
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
};
+1 -1
View File
@@ -110,7 +110,7 @@ export interface PaginatedListConfig {
hideButtonsOnLastPage?: boolean;
}
export abstract class PaginatedList {
export abstract class PaginatedList implements PageEventBindable {
protected _c: PaginatedListConfig;
// Container to append items to.
+37 -11
View File
@@ -68,9 +68,11 @@ export abstract class Query {
protected _subject: QueryType;
protected _operator: QueryOperator;
protected _card: HTMLElement;
protected _id: string;
public type: string;
constructor(subject: QueryType | null, operator: QueryOperator) {
constructor(id: string, subject: QueryType | null, operator: QueryOperator) {
this._id = id;
this._subject = subject;
this._operator = operator;
if (subject != null) {
@@ -116,8 +118,8 @@ export abstract class Query {
export class BoolQuery extends Query {
protected _value: boolean;
constructor(subject: QueryType, value: boolean) {
super(subject, QueryOperator.Equal);
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");
@@ -165,8 +167,8 @@ export class BoolQuery extends Query {
export class StringQuery extends Query {
protected _value: string;
constructor(subject: QueryType, value: string) {
super(subject, QueryOperator.Equal);
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");
@@ -214,8 +216,8 @@ const dateSetters: Map<string, (v: number) => void> = (() => {
export class DateQuery extends Query {
protected _value: ParsedDate;
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
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");
@@ -278,7 +280,7 @@ export const SearchableItemDataAttribute = "data-search-item";
export type SearchableItems = Map<string, SearchableItem>;
export class Search {
export class Search implements Navigatable {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
@@ -368,7 +370,7 @@ export class Search {
if (queryFormat.bool) {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
q = new BoolQuery(queryFormat, boolState);
q = new BoolQuery(split[0], queryFormat, boolState);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(
@@ -383,7 +385,7 @@ export class Search {
}
}
if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]);
q = new StringQuery(split[0], queryFormat, split[1]);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -398,7 +400,7 @@ export class Search {
if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
q = new DateQuery(queryFormat, op, parsedDate);
q = new DateQuery(split[0], queryFormat, op, parsedDate);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -695,6 +697,7 @@ export class Search {
onServerSearch = () => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this.setQueryParam();
this.searchServer(newServerSearch);
};
@@ -718,10 +721,33 @@ export class Search {
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) {
+3 -1
View File
@@ -1067,7 +1067,9 @@ interface Settings {
order?: Member[];
}
export class settingsList {
export class settingsList implements AsTab {
readonly tabName = "settings";
readonly pagePath = "settings";
private _saveButton = document.getElementById("settings-save") as HTMLSpanElement;
private _saveNoRestart = document.getElementById("settings-apply-no-restart") as HTMLSpanElement;
private _saveRestart = document.getElementById("settings-apply-restart") as HTMLSpanElement;
+8
View File
@@ -1,5 +1,13 @@
import { PageManager, Page } from "../modules/pages.js";
export function isPageEventBindable(object: any): object is PageEventBindable {
return "bindPageEvents" in object;
}
export function isNavigatable(object: any): object is Navigatable {
return "isURL" in object && "navigate" in object;
}
export interface Tab {
page: Page;
tabEl: HTMLDivElement;
+7 -6
View File
@@ -112,7 +112,6 @@ export class HiddenInputField {
}
}
export interface RadioBasedTab {
name: string;
id?: string;
@@ -135,7 +134,7 @@ export class RadioBasedTabSelector {
private _container: HTMLElement;
private _tabs: RadioBasedTabItem[];
private _selected: string;
constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) {
constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) {
this._container = container;
this._container.classList.add("flex", "flex-row", "gap-2");
this._tabs = [];
@@ -143,7 +142,7 @@ export class RadioBasedTabSelector {
let i = 0;
const frag = document.createDocumentFragment();
for (let tab of tabs) {
if (!(tab.id)) tab.id = tab.name;
if (!tab.id) tab.id = tab.name;
const label = document.createElement("label");
label.classList.add("grow");
label.innerHTML = `
@@ -153,7 +152,7 @@ export class RadioBasedTabSelector {
let ft: RadioBasedTabItem = {
tab: tab,
input: label.getElementsByTagName("input")[0] as HTMLInputElement,
button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement
button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement,
};
ft.input.onclick = () => {
ft.input.checked = true;
@@ -185,8 +184,10 @@ export class RadioBasedTabSelector {
}
};
get selected(): string { return this._selected; }
set selected(id: string|number) {
get selected(): string {
return this._selected;
}
set selected(id: string | number) {
if (typeof id !== "string") {
id = this._tabs[id as number].tab.id;
}