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

View File

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