toggle column sorting & pagination

This commit is contained in:
Fred KISSIE
2026-01-31 00:45:14 +01:00
parent 89695df012
commit 066305b095
4 changed files with 135 additions and 124 deletions

View File

@@ -4,7 +4,17 @@ import { remoteExitNodes } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { and, count, eq, ilike, inArray, or, sql } from "drizzle-orm";
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -88,28 +98,15 @@ const listSitesSchema = z.object({
.optional()
.catch(1)
.default(1),
query: z.string().optional()
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined),
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc")
});
function querySites(
orgId: string,
accessibleSiteIds: number[],
query: string = ""
) {
let conditions = and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
);
if (query) {
conditions = and(
conditions,
or(
ilike(sites.name, "%" + query + "%"),
ilike(sites.niceId, "%" + query + "%")
)
);
}
function querySitesBase() {
return db
.select({
siteId: sites.siteId,
@@ -136,11 +133,10 @@ function querySites(
.leftJoin(
remoteExitNodes,
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
)
.where(conditions);
);
}
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
newtUpdateAvailable?: boolean;
};
@@ -176,7 +172,7 @@ export async function listSites(
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
const parsedParams = listSitesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -220,7 +216,7 @@ export async function listSites(
}
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const baseQuery = querySites(orgId, accessibleSiteIds, query);
const baseQuery = querySitesBase();
let conditions = and(
inArray(sites.siteId, accessibleSiteIds),
@@ -241,23 +237,30 @@ export async function listSites(
.from(sites)
.where(conditions);
const sitesList = await baseQuery
const siteListQuery = baseQuery
.where(conditions)
.limit(pageSize)
.offset(pageSize * (page - 1));
if (sort_by) {
siteListQuery.orderBy(
order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by])
);
}
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
// Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion();
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
(site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false;
return siteWithUpdate;
}
);
const sitesWithUpdates: SiteWithUpdateAvailable[] = (
await siteListQuery
).map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false;
return siteWithUpdate;
});
// Try to get the latest version, but don't block if it fails
try {

View File

@@ -12,15 +12,18 @@ import {
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSortColumn } from "@app/hooks/useSortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { parseDataSize } from "@app/lib/dataSize";
import { build } from "@server/build";
import { Column, type PaginationState } from "@tanstack/react-table";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUpDown,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -71,6 +74,8 @@ export default function SitesTable({
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition();
const [getSortDirection, toggleSorting] = useSortColumn();
const api = createApiClient(useEnvContext());
const t = useTranslations();
@@ -102,22 +107,15 @@ export default function SitesTable({
});
};
const dataInOrder = getSortDirection("megabytesIn");
const dataOutOrder = getSortDirection("megabytesOut");
const columns: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("name")}</span>;
}
},
{
@@ -125,18 +123,8 @@ export default function SitesTable({
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("identifier")}</span>;
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
@@ -145,18 +133,8 @@ export default function SitesTable({
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("online")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -187,16 +165,20 @@ export default function SitesTable({
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: ({ column }) => {
header: () => {
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
onClick={() => toggleSorting("megabytesIn")}
>
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
@@ -207,16 +189,20 @@ export default function SitesTable({
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: ({ column }) => {
header: () => {
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
onClick={() => toggleSorting("megabytesOut")}
>
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
@@ -227,18 +213,8 @@ export default function SitesTable({
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("type")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -283,18 +259,8 @@ export default function SitesTable({
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("exitNode")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -347,18 +313,8 @@ export default function SitesTable({
},
{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("address")}</span>;
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
@@ -435,11 +391,6 @@ export default function SitesTable({
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}, 300);
console.log({
pagination,
rowCount
});
return (
<>
{selectedSite && (

View File

@@ -0,0 +1,56 @@
import type { SortOrder } from "@app/lib/types/sort";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { startTransition } from "react";
export function useSortColumn() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const toggleSorting = (column: string) => {
const sp = new URLSearchParams(searchParams);
let nextDirection: SortOrder = "indeterminate";
if (sp.get("sort_by") === column) {
nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate";
}
switch (nextDirection) {
case "indeterminate": {
nextDirection = "asc";
break;
}
case "asc": {
nextDirection = "desc";
break;
}
default: {
nextDirection = "indeterminate";
break;
}
}
sp.delete("sort_by");
sp.delete("order");
if (nextDirection !== "indeterminate") {
sp.set("sort_by", column);
sp.set("order", nextDirection);
}
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
};
function getSortDirection(column: string) {
let currentDirection: SortOrder = "indeterminate";
if (searchParams.get("sort_by") === column) {
currentDirection =
(searchParams.get("order") as SortOrder) ?? "indeterminate";
}
return currentDirection;
}
return [getSortDirection, toggleSorting] as const;
}

1
src/lib/types/sort.ts Normal file
View File

@@ -0,0 +1 @@
export type SortOrder = "asc" | "desc" | "indeterminate";