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

View File

@@ -1,7 +1,7 @@
import { ThemeManager } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js";
import { Tabs, Tab } from "./modules/tabs.js";
import { Tabs, Tab, isPageEventBindable, isNavigatable } from "./modules/tabs.js";
import { DOMInviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
@@ -143,91 +143,37 @@ var profiles = new ProfileEditor();
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
/*const modifySettingsSource = function () {
const profile = document.getElementById('radio-use-profile') as HTMLInputElement;
const user = document.getElementById('radio-use-user') as HTMLInputElement;
const profileSelect = document.getElementById('modify-user-profiles') as HTMLDivElement;
const userSelect = document.getElementById('modify-user-users') as HTMLDivElement;
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
profileSelect.classList.toggle('unfocused');
userSelect.classList.toggle('unfocused');
}*/
// Determine if url references an invite or account
let isInviteURL = window.invites.isInviteURL();
let isAccountURL = accounts.isAccountURL();
let isActivityURL = activity.isActivityURL();
// only use a navigatable URL once
let navigated = false;
// load tabs
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [
{
id: "invites",
url: "",
reloader: () =>
window.invites.reload(() => {
if (isInviteURL) {
window.invites.loadInviteURL();
// Don't keep loading the same item on every tab refresh
isInviteURL = false;
}
}),
},
{
id: "accounts",
url: "accounts",
reloader: () =>
accounts.reload(() => {
if (isAccountURL) {
accounts.loadAccountURL();
// Don't keep loading the same item on every tab refresh
isAccountURL = false;
// Since accounts and activity accept ?user=x, wipe the other one.
isActivityURL = false;
}
accounts.bindPageEvents();
}),
unloader: accounts.unbindPageEvents,
},
{
id: "activity",
url: "activity",
reloader: () => {
activity.reload(() => {
if (isActivityURL) {
activity.loadActivityURL();
// Don't keep loading the same item on every tab refresh
isActivityURL = false;
// Since accounts and activity accept ?user=x, wipe the other one.
isAccountURL = false;
}
});
activity.bindPageEvents();
},
unloader: activity.unbindPageEvents,
},
{
id: "settings",
url: "settings",
reloader: settings.reload,
},
];
const defaultTab = tabs[0];
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
window.tabs = new Tabs();
for (let tab of tabs) {
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
let t: { id: string; url: string; reloader: () => void; unloader?: () => void } = {
id: p.tabName,
url: p.pagePath,
reloader: () =>
p.reload(() => {
if (!navigated && isNavigatable(p)) {
if (p.isURL()) {
navigated = true;
p.navigate();
}
}
if (isPageEventBindable(p)) p.bindPageEvents();
}),
};
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
tabs.push(t);
window.tabs.addTab(
tab.id,
window.pages.Base + window.pages.Admin + "/" + tab.url,
t.id,
window.pages.Base + window.pages.Admin + "/" + t.url,
null,
tab.reloader,
tab.unloader || null,
t.reloader,
t.unloader || null,
);
}
});
let matchedTab = false;
for (const tab of tabs) {

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");

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());
};
}

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"));
};

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.

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) {

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;

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;

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;
}

View File

@@ -61,13 +61,28 @@ declare interface GlobalWindow extends Window {
loginAppearance: string;
}
declare interface InviteList {
declare interface PageEventBindable {
bindPageEvents(): void;
unbindPageEvents(): void;
}
declare interface AsTab {
readonly tabName: string;
readonly pagePath: string;
reload(callback: () => void): void;
}
declare interface Navigatable {
// isURL will return whether the given url (or the current page url if not passed) is a valid link to some resource(s) in the class.
isURL(url?: string): boolean;
// navigate will load and focus the resource(s) in the class referenced by the given url (or current page url if not passed).
navigate(url?: string): void;
}
declare interface InviteList extends Navigatable, AsTab {
empty: boolean;
invites: { [code: string]: Invite };
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
declare interface Invite {