mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-16 09:56:36 +00:00
✨ toggle column sorting & pagination
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
56
src/hooks/useSortColumn.ts
Normal file
56
src/hooks/useSortColumn.ts
Normal 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
1
src/lib/types/sort.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type SortOrder = "asc" | "desc" | "indeterminate";
|
||||
Reference in New Issue
Block a user