diff --git a/frontend/src/html/pages/friends.html b/frontend/src/html/pages/friends.html
index 7c842bfc7..5f34914a6 100644
--- a/frontend/src/html/pages/friends.html
+++ b/frontend/src/html/pages/friends.html
@@ -66,34 +66,27 @@
Something went wrong
You don't have any friends :(
+
- | name |
-
- friend since
- |
- level |
+ name |
+ friend since |
+ level |
tests
|
-
- time typing
- |
-
- streak
- |
-
+ | time typing |
+ streak |
+
15s wpm
accuracy
|
-
+ |
60s wpm
accuracy
|
diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts
index 3f64e08a3..c617aa507 100644
--- a/frontend/src/ts/pages/friends.ts
+++ b/frontend/src/ts/pages/friends.ts
@@ -13,25 +13,10 @@ import { PersonalBest } from "@monkeytype/contracts/schemas/shared";
import Format from "../utils/format";
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
import { Friend } from "@monkeytype/contracts/schemas/friends";
+import { SortedTable } from "../utils/sorted-table";
const pageElement = $(".page.pageFriends");
-type Sort = {
- property: string;
- descending: boolean;
-};
-
-type State = {
- friends: {
- data: Array;
- sort: Sort;
- };
-};
-
-const state: State = {
- friends: { data: [], sort: { property: "name", descending: false } },
-};
-
const addFriendModal = new SimpleModal({
id: "addFriend",
title: "Add a friend",
@@ -101,44 +86,51 @@ async function updatePendingRequests(): Promise {
async function fetchFriends(): Promise {
$(".pageFriends .friends .loading").removeClass("hidden");
const result = await Ape.friends.getFriends();
+ $(".pageFriends .friends .loading").addClass("hidden");
+
if (result.status !== 200) {
$(".pageFriends .friends .error").removeClass("hidden");
$(".pageFriends .friends .error p").html(result.body.message);
- } else {
- $(".pageFriends .friends .error").addClass("hidden");
- state.friends.data = result.body.data;
+ return;
}
- $(".pageFriends .friends .loading").addClass("hidden");
-}
-async function updateFriends(): Promise {
- if (state.friends.data.length === 0) {
+ $(".pageFriends .friends .error").addClass("hidden");
+
+ if (result.body.data.length === 0) {
$(".pageFriends .friends table").addClass("hidden");
$(".pageFriends .friends .nodata").removeClass("hidden");
} else {
$(".pageFriends .friends table").removeClass("hidden");
$(".pageFriends .friends .nodata").addClass("hidden");
- const html = state.friends.data
- .map((entry) => {
- let avatar = `
`;
- if (entry.discordAvatar !== undefined) {
- avatar = `
`;
- }
- const xpDetails = getXpDetails(entry.xp ?? 0);
+ new SortedTable({
+ table: ".pageFriends .friends table",
+ data: result.body.data,
+ buildRow: buildFriendRow,
+ initialSort: { property: "name", descending: false },
+ });
+ }
+}
- const top15 = formatPb(entry.top15);
- const top60 = formatPb(entry.top60);
+function buildFriendRow(entry: Friend): HTMLTableRowElement {
+ let avatar = `
`;
+ if (entry.discordAvatar !== undefined) {
+ avatar = `
`;
+ }
+ const xpDetails = getXpDetails(entry.xp ?? 0);
- return `
+ const top15 = formatPb(entry.top15);
+ const top60 = formatPb(entry.top60);
+
+ const element = document.createElement("tr");
+ element.dataset["id"] = entry.friendRequestId;
+ element.innerHTML = `
${avatar}
${
- entry.name
- }
+ entry.uid
+ }?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}
${getHtmlByUserFlags(entry)}
${
@@ -169,11 +161,7 @@ async function updateFriends(): Promise {
|
`;
- })
- .join("\n");
-
- $(".pageFriends .friends tbody").html(html);
- }
+ return element;
}
function formatAge(timestamp?: number): string {
@@ -204,50 +192,6 @@ function formatPb(entry?: PersonalBest):
};
}
-function getValueByPath(obj: unknown, path: string): unknown {
- return path.split(".").reduce((acc, key) => {
- // oxlint-disable-next-line no-explicit-any
- // @ts-expect-error this is fine
- return acc !== null && acc !== undefined ? acc[key] : undefined;
- }, obj);
-}
-
-function sortFriends({ property, descending }: Sort): void {
- // Removes styling from previous sorting requests:
- $(".friends td").removeClass("headerSorted");
- $(".friends td").children("i").remove();
- $(`.friends td[data-property="${property}"]`)
- .addClass("headerSorted")
- .append(
- ``
- );
-
- state.friends.data.sort((a, b) => {
- const valA = getValueByPath(a, property);
- const valB = getValueByPath(b, property);
-
- let result = 0;
-
- if (valA === undefined && valB !== undefined) {
- return descending ? 1 : -1;
- } else if (valA !== undefined && valB === undefined) {
- return descending ? -1 : 1;
- }
-
- if (typeof valA === "string" && typeof valB === "string") {
- result = valA.localeCompare(valB);
- }
-
- if (typeof valA === "number" && typeof valB === "number") {
- result = valA - valB;
- }
-
- return descending ? -result : result;
- });
-}
-
$("#friendAdd").on("click", () => {
addFriendModal.show(undefined, {});
});
@@ -299,22 +243,6 @@ $(".pageFriends .pendingRequests table").on("click", async (e) => {
}
});
-$(".pageFriends .friends thead td.sortable").on("click", async (e) => {
- const property = e.currentTarget.dataset["property"];
- if (property === undefined) return;
-
- if (property === state.friends.sort.property) {
- state.friends.sort.descending = !state.friends.sort.descending;
- } else {
- state.friends.sort = {
- property,
- descending: false,
- };
- }
- sortFriends(state.friends.sort);
- await updateFriends();
-});
-
export const page = new Page({
id: "friends",
display: "Friends",
@@ -328,8 +256,6 @@ export const page = new Page({
await updatePendingRequests();
await fetchFriends();
- sortFriends({ property: "name", descending: false });
- await updateFriends();
},
});
diff --git a/frontend/src/ts/utils/sorted-table.ts b/frontend/src/ts/utils/sorted-table.ts
new file mode 100644
index 000000000..bf6c62286
--- /dev/null
+++ b/frontend/src/ts/utils/sorted-table.ts
@@ -0,0 +1,106 @@
+type Sort = { property: string; descending: boolean };
+
+export class SortedTable {
+ private data: { source: T; element: HTMLTableRowElement }[];
+ private table: JQuery;
+ private sort?: Sort;
+
+ constructor({
+ table,
+ data,
+ buildRow,
+ initialSort,
+ }: {
+ table: string;
+ data: T[];
+ buildRow: (entry: T) => HTMLTableRowElement;
+ initialSort?: Sort;
+ }) {
+ this.table = $(table);
+ if (this.table === undefined)
+ throw new Error(`No element found for ${table}`);
+
+ //render content
+ this.data = data.map((source) => ({ source, element: buildRow(source) }));
+ if (initialSort !== undefined) {
+ this.sort = initialSort;
+ this.doSort();
+ }
+
+ //init rows
+ for (const col of this.table.find(`td[data-sort-property]`)) {
+ col.classList.add("sortable");
+ col.setAttribute("type", "button");
+ col.onclick = (e: MouseEvent) => {
+ const target = e.currentTarget as HTMLElement;
+ const property = target.dataset["sortProperty"] as string;
+ if (property === undefined) return;
+
+ if (this.sort === undefined || property !== this.sort.property) {
+ this.sort = { property, descending: false };
+ } else {
+ this.sort.descending = !this.sort?.descending;
+ }
+
+ this.doSort();
+ this.updateBody();
+ };
+ }
+
+ //fill table body
+ this.updateBody();
+ }
+
+ private doSort(): void {
+ if (this.sort === undefined) return;
+
+ const { property, descending } = this.sort;
+ // Removes styling from previous sorting requests:
+ this.table.find("thead td").removeClass("headerSorted");
+ this.table.find("thead td").children("i").remove();
+ this.table
+ .find(`thead td[data-sort-property="${property}"]`)
+ .addClass("headerSorted")
+ .append(
+ ``
+ );
+
+ this.data.sort((a, b) => {
+ const valA = getValueByPath(a.source, property);
+ const valB = getValueByPath(b.source, property);
+
+ let result = 0;
+
+ if (valA === undefined && valB !== undefined) {
+ return descending ? 1 : -1;
+ } else if (valA !== undefined && valB === undefined) {
+ return descending ? -1 : 1;
+ }
+
+ if (typeof valA === "string" && typeof valB === "string") {
+ result = valA.localeCompare(valB);
+ }
+
+ if (typeof valA === "number" && typeof valB === "number") {
+ result = valA - valB;
+ }
+
+ return descending ? -result : result;
+ });
+ }
+ private updateBody(): void {
+ const body = this.table.find("tbody");
+ body.empty();
+ body.append(this.data.map((data) => data.element));
+ }
+}
+
+function getValueByPath(obj: unknown, path: string): unknown {
+ return path.split(".").reduce((acc, key) => {
+ // oxlint-disable-next-line no-explicit-any
+ // @ts-expect-error this is fine
+ return acc !== null && acc !== undefined ? acc[key] : undefined;
+ }, obj);
+}