From a0db685af2337a1bdfb24008c3a7817b40afb1fc Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 21 Oct 2023 16:13:39 +0100 Subject: [PATCH] activity: functional search (client-side) search with filters for each type of card, and all the info in them. Gonna somehow need to figure out what to do about pagination. --- html/admin.html | 63 +++-------- lang/admin/en-us.json | 17 ++- ts/modules/accounts.ts | 80 +------------ ts/modules/activity.ts | 247 ++++++++++++++++++++++++++++++++++++++--- ts/modules/search.ts | 88 ++++++++++++++- 5 files changed, 353 insertions(+), 142 deletions(-) diff --git a/html/admin.html b/html/admin.html index 6dd523e..ed3fa62 100644 --- a/html/admin.html +++ b/html/admin.html @@ -725,48 +725,20 @@
{{ .strings.activity }} - +
- - -
-
{{ .strings.actions }}
-
- {{ .quantityStrings.addUser.Singular }} - - {{ .strings.modifySettings }} - {{ if .referralsEnabled }} - {{ .strings.enableReferrals }} - {{ end }} - {{ .strings.extendExpiry }} - - {{ .strings.sendPWR }} - {{ .quantityStrings.deleteUser.Singular }} + +
@@ -778,19 +750,14 @@ {{ .strings.aboutProgram }} {{ .strings.userProfiles }}
-
-
-
- Account Created: "hrfee" - 26/10/23 14:32 -
-
-
- From InviteBdBmpGDzuJhHSsboAsYgrE -
-
- Referrerusername -
+
+
+
+
+ {{ .strings.noResultsFound }} +
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 6b9e16f..86c4872 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -151,7 +151,22 @@ "fromInvite": "From Invite", "byAdmin": "By Admin", "byUser": "By User", - "byJfaGo": "By jfa-go" + "byJfaGo": "By jfa-go", + "activityID": "Activity ID", + "title": "Title", + "usersMentioned": "User mentioned", + "actor": "Actor", + "actorDescription": "The thing that caused this action.
\"user\"/\"admin\"/\"daemon\" or a username.", + "accountCreationFilter": "Account Creation", + "accountDeletionFilter": "Account Deletion", + "accountDisabledFilter": "Account Disabled", + "accountEnabledFilter": "Account Enabled", + "contactLinkedFilter": "Contact Linked", + "contactUnlinkedFilter": "Contact Unlinked", + "passwordChangeFilter": "Password Changed", + "passwordResetFilter": "Password Reset", + "inviteCreatedFilter": "Invite Created", + "inviteDeletedFilter": "Invite Deleted/Expired" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index cb1c757..df1da8d 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -1803,6 +1803,7 @@ export class accountsList { sortingByButton: this._sortingByButton, searchOptionsHeader: this._searchOptionsHeader, notFoundPanel: this._notFoundPanel, + filterList: document.getElementById("accounts-filter-list"), search: this._searchBox, queries: this._queries, setVisibility: this.setVisibility, @@ -1883,84 +1884,7 @@ export class accountsList { defaultSort(); this.showHideSearchOptionsHeader(); - const filterList = document.getElementById("accounts-filter-list"); - - const fillInFilter = (name: string, value: string, offset?: number) => { - this._searchBox.value = name + ":" + value + " " + this._searchBox.value; - this._searchBox.focus(); - let newPos = name.length + 1 + value.length; - if (typeof offset !== 'undefined') - newPos += offset; - this._searchBox.setSelectionRange(newPos, newPos); - this._searchBox.oninput(null as any); - }; - - // Generate filter buttons - for (let queryName of Object.keys(this._queries)) { - const query = this._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", "mb-1", "mr-2"); - container.innerHTML = `${query.name}`; - 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", "ml-2"); - pos.innerHTML = ``; - pos.addEventListener("click", () => 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", "ml-2"); - neg.innerHTML = ``; - neg.addEventListener("click", () => 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", "ml-2"); - button.innerHTML = `${window.lang.strings("matchText")}`; - - // Position cursor between quotes - button.addEventListener("click", () => fillInFilter(queryName, `""`, -1)); - - container.appendChild(button); - } - if (query.date) { - const onDate = document.createElement("button") as HTMLButtonElement; - onDate.type = "button"; - onDate.classList.add("button", "~urge", "ml-2"); - onDate.innerHTML = `On Date`; - onDate.addEventListener("click", () => fillInFilter(queryName, `"="`, -1)); - - const beforeDate = document.createElement("button") as HTMLButtonElement; - beforeDate.type = "button"; - beforeDate.classList.add("button", "~urge", "ml-2"); - beforeDate.innerHTML = `Before Date`; - beforeDate.addEventListener("click", () => fillInFilter(queryName, `"<"`, -1)); - - const afterDate = document.createElement("button") as HTMLButtonElement; - afterDate.type = "button"; - afterDate.classList.add("button", "~urge", "ml-2"); - afterDate.innerHTML = `After Date`; - afterDate.addEventListener("click", () => fillInFilter(queryName, `">"`, -1)); - - container.appendChild(onDate); - container.appendChild(beforeDate); - container.appendChild(afterDate); - } - - filterList.appendChild(container); - } + this._search.generateFilterList(); } reload = () => { diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 16146e5..b09d42e 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,14 +1,15 @@ import { _post, _delete, toDateString } from "../modules/common.js"; +import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; export interface activity { - id: string; - type: string; - user_id: string; - source_type: string; - source: string; - invite_code: string; - value: string; - time: number; + id: string; + type: string; + user_id: string; + source_type: string; + source: string; + invite_code: string; + value: string; + time: number; username: string; source_username: string; } @@ -26,11 +27,11 @@ var activityTypeMoods = { "deleteInvite": -1 }; -var moodColours = ["~warning", "~neutral", "~urge"]; +// var moodColours = ["~warning", "~neutral", "~urge"]; export var activityReload = new CustomEvent("activity-reload"); -export class Activity { // FIXME: Add "implements" +export class Activity implements activity, SearchableItem { // FIXME: Add "implements" private _card: HTMLElement; private _title: HTMLElement; private _time: HTMLElement; @@ -64,6 +65,33 @@ export class Activity { // FIXME: Add "implements" return `${this._renderInvText()}`; } + + get accountCreation(): boolean { return this.type == "creation"; } + get accountDeletion(): boolean { return this.type == "deletion"; } + get accountDisabled(): boolean { return this.type == "disabled"; } + get accountEnabled(): boolean { return this.type == "enabled"; } + get contactLinked(): boolean { return this.type == "contactLinked"; } + get contactUnlinked(): boolean { return this.type == "contactUnlinked"; } + get passwordChange(): boolean { return this.type == "changePassword"; } + get passwordReset(): boolean { return this.type == "resetPassword"; } + get inviteCreated(): boolean { return this.type == "createInvite"; } + get inviteDeleted(): boolean { return this.type == "deleteInvite"; } + + get mentionedUsers(): string { + return (this.username + " " + this.source_username).toLowerCase(); + } + + get actor(): string { + let out = this.source_type + " "; + if (this.source_type == "admin" || this.source_type == "user") out += this.source_username; + return out.toLowerCase(); + } + + get referrer(): string { + if (this.type != "creation" || this.source_type != "user") return ""; + return this.source_username.toLowerCase(); + } + get type(): string { return this._act.type; } set type(v: string) { this._act.type = v; @@ -195,6 +223,29 @@ export class Activity { // FIXME: Add "implements" } } + get id(): string { return this._act.id; } + set id(v: string) { this._act.id = v; } + + get user_id(): string { return this._act.user_id; } + set user_id(v: string) { this._act.user_id = v; } + + get username(): string { return this._act.username; } + set username(v: string) { this._act.username = v; } + + get source_username(): string { return this._act.source_username; } + set source_username(v: string) { this._act.source_username = v; } + + get title(): string { return this._title.textContent; } + + matchesSearch = (query: string): boolean => { + // console.log(this.title, "matches", query, ":", this.title.includes(query)); + return ( + this.title.toLowerCase().includes(query) || + this.username.toLowerCase().includes(query) || + this.source_username.toLowerCase().includes(query) + ); + } + constructor(act: activity) { this._card = document.createElement("div"); @@ -265,6 +316,26 @@ interface ActivitiesDTO { 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 _search: Search; + + + setVisibility = (activities: string[], visible: boolean) => { + this._activityList.textContent = ``; + for (let id of this._ordering) { + if (visible && activities.indexOf(id) != -1) { + this._activityList.appendChild(this._activities[id].asElement()); + } else if (!visible && activities.indexOf(id) == -1) { + this._activityList.appendChild(this._activities[id].asElement()); + } + } + } reload = () => { let send = { @@ -281,17 +352,165 @@ export class activityList { } let resp = req.response as ActivitiesDTO; - this._activityList.textContent = ``; - + // FIXME: Don't destroy everything each reload! + this._activities = {}; for (let act of resp.activities) { - const activity = new Activity(act); - this._activityList.appendChild(activity.asElement()); + this._activities[act.id] = new Activity(act); + this._activityList.appendChild(this._activities[act.id].asElement()); + } + this._search.items = this._activities; + // FIXME: Actually implement sorting + this._ordering = Object.keys(this._activities); + this._search.ordering = this._ordering; + + if (this._search.inSearch) { + const results = this._search.search(this._searchBox.value); + this.setVisibility(results, true); + if (results.length == 0) { + this._notFoundPanel.classList.remove("unfocused"); + } else { + this._notFoundPanel.classList.add("unfocused"); + } + } else { + this.setVisibility(this._ordering, true); + this._notFoundPanel.classList.add("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 + } + }; constructor() { this._activityList = document.getElementById("activity-card-list"); document.addEventListener("activity-reload", this.reload); + + 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"), + onSearchCallback: () => {} + } + this._search = new Search(conf); + this._search.generateFilterList(); } } diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 81b8e16..0260f60 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -2,6 +2,7 @@ const dateParser = require("any-date-parser"); export interface QueryType { name: string; + description?: string; getter: string; bool: boolean; string: boolean; @@ -15,6 +16,7 @@ export interface SearchConfiguration { sortingByButton: HTMLButtonElement; searchOptionsHeader: HTMLElement; notFoundPanel: HTMLElement; + filterList: HTMLElement; clearSearchButtonSelector: string; search: HTMLInputElement; queries: { [field: string]: QueryType }; @@ -158,7 +160,7 @@ export class Search { let cachedResult = [...result]; for (let id of cachedResult) { const u = this._items[id]; - const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); + const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase(); if (!(value.includes(split[1]))) { result.splice(result.indexOf(id), 1); } @@ -283,6 +285,90 @@ export class Search { } } + 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 = () => { + // 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", "mb-1", "mr-2"); + container.innerHTML = ` +
+ ${query.name} + ${query.description || ""} +
+ `; + 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", "ml-2"); + pos.innerHTML = ``; + 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", "ml-2"); + neg.innerHTML = ``; + 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", "ml-2"); + button.innerHTML = `${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", "ml-2"); + onDate.innerHTML = `On Date`; + onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1)); + + const beforeDate = document.createElement("button") as HTMLButtonElement; + beforeDate.type = "button"; + beforeDate.classList.add("button", "~urge", "ml-2"); + beforeDate.innerHTML = `Before Date`; + beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1)); + + const afterDate = document.createElement("button") as HTMLButtonElement; + afterDate.type = "button"; + afterDate.classList.add("button", "~urge", "ml-2"); + afterDate.innerHTML = `After Date`; + afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1)); + + container.appendChild(onDate); + container.appendChild(beforeDate); + container.appendChild(afterDate); + } + + this._c.filterList.appendChild(container); + } + } + constructor(c: SearchConfiguration) { this._c = c;