mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-10 14:10:59 +08:00
extract SortedTable
This commit is contained in:
parent
8ee4cd179e
commit
4e91285844
3 changed files with 145 additions and 120 deletions
|
|
@ -66,34 +66,27 @@
|
|||
<p>Something went wrong</p>
|
||||
</div>
|
||||
<div class="nodata hidden">You don't have any friends :(</div>
|
||||
|
||||
<table width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<td type="button" class="sortable" data-property="name">name</td>
|
||||
<td type="button" class="sortable" data-property="addedAt">
|
||||
friend since
|
||||
</td>
|
||||
<td type="button" class="sortable" data-property="xp">level</td>
|
||||
<td data-sort-property="name">name</td>
|
||||
<td data-sort-property="addedAt">friend since</td>
|
||||
<td data-sort-property="xp">level</td>
|
||||
<td
|
||||
type="button"
|
||||
class="sortable"
|
||||
data-property="completedTests"
|
||||
data-sort-property="completedTests"
|
||||
aria-label="completed / started"
|
||||
data-balloon-pos="up"
|
||||
>
|
||||
tests
|
||||
</td>
|
||||
<td type="button" class="sortable" data-property="timeTyping">
|
||||
time typing
|
||||
</td>
|
||||
<td type="button" class="sortable" data-property="streak.length">
|
||||
streak
|
||||
</td>
|
||||
<td type="button" class="sortable" data-property="top15.wpm">
|
||||
<td data-sort-property="timeTyping">time typing</td>
|
||||
<td data-sort-property="streak.length">streak</td>
|
||||
<td data-sort-property="top15.wpm">
|
||||
15s wpm
|
||||
<div class="sub">accuracy</div>
|
||||
</td>
|
||||
<td type="button" class="sortable" data-property="top60.wpm">
|
||||
<td data-sort-property="top60.wpm">
|
||||
60s wpm
|
||||
<div class="sub">accuracy</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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<Friend>;
|
||||
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<void> {
|
|||
async function fetchFriends(): Promise<void> {
|
||||
$(".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<void> {
|
||||
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 = `<div class="avatarPlaceholder"><i class="fas fa-user-circle"></i></div>`;
|
||||
if (entry.discordAvatar !== undefined) {
|
||||
avatar = `<div class="avatarPlaceholder"><i class="fas fa-circle-notch fa-spin"></i></div>`;
|
||||
}
|
||||
const xpDetails = getXpDetails(entry.xp ?? 0);
|
||||
new SortedTable<Friend>({
|
||||
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 = `<div class="avatarPlaceholder"><i class="fas fa-user-circle"></i></div>`;
|
||||
if (entry.discordAvatar !== undefined) {
|
||||
avatar = `<div class="avatarPlaceholder"><i class="fas fa-circle-notch fa-spin"></i></div>`;
|
||||
}
|
||||
const xpDetails = getXpDetails(entry.xp ?? 0);
|
||||
|
||||
return `<tr data-id="${entry.friendRequestId}">
|
||||
const top15 = formatPb(entry.top15);
|
||||
const top60 = formatPb(entry.top60);
|
||||
|
||||
const element = document.createElement("tr");
|
||||
element.dataset["id"] = entry.friendRequestId;
|
||||
element.innerHTML = `<tr data-id="${entry.friendRequestId}">
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="lbav">${avatar}</div>
|
||||
<a href="${location.origin}/profile/${
|
||||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${
|
||||
entry.name
|
||||
}</a>
|
||||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
|
||||
<div class="flagsAndBadge">
|
||||
${getHtmlByUserFlags(entry)}
|
||||
${
|
||||
|
|
@ -169,11 +161,7 @@ async function updateFriends(): Promise<void> {
|
|||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.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(
|
||||
`<i class="fas ${
|
||||
descending ? "fa-sort-down" : "fa-sort-up"
|
||||
} aria-hidden="true"></i>`
|
||||
);
|
||||
|
||||
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<undefined>({
|
||||
id: "friends",
|
||||
display: "Friends",
|
||||
|
|
@ -328,8 +256,6 @@ export const page = new Page<undefined>({
|
|||
|
||||
await updatePendingRequests();
|
||||
await fetchFriends();
|
||||
sortFriends({ property: "name", descending: false });
|
||||
await updateFriends();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
106
frontend/src/ts/utils/sorted-table.ts
Normal file
106
frontend/src/ts/utils/sorted-table.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
type Sort = { property: string; descending: boolean };
|
||||
|
||||
export class SortedTable<T> {
|
||||
private data: { source: T; element: HTMLTableRowElement }[];
|
||||
private table: JQuery<HTMLTableElement>;
|
||||
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(
|
||||
`<i class="fas ${
|
||||
descending ? "fa-sort-down" : "fa-sort-up"
|
||||
} aria-hidden="true"></i>`
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue