add server side sort and filter

This commit is contained in:
miloschwartz
2026-10-02 16:41:56 -04:00
parent daf8cf16a3
commit 9746ba5741
2 changed files with 184 additions and 14 deletions
+66 -3
View File
@@ -65,7 +65,46 @@ const listSchema = z.object({
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
query: z.string().optional(),
sort_by: z
.enum(["name", "priority"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name", "priority"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
type: z
.enum(["permanent", "temporary"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["permanent", "temporary"],
description:
"Filter redirects by type. `permanent` is a 308 redirect and `temporary` is a 307 redirect."
}),
enabled: z
.enum(["true", "false"])
.transform((value) => value === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter redirects based on enabled status"
})
});
registry.registerPath({
@@ -121,9 +160,20 @@ export async function listRedirects(
);
}
const { pageSize, page, query } = parsedQuery.data;
const { pageSize, page, query, sort_by, order, type, enabled } =
parsedQuery.data;
const conditions = [eq(redirects.orgId, orgId)];
if (type === "permanent") {
conditions.push(eq(redirects.permanent, true));
} else if (type === "temporary") {
conditions.push(eq(redirects.permanent, false));
}
if (typeof enabled !== "undefined") {
conditions.push(eq(redirects.enabled, enabled));
}
if (query) {
const term = "%" + query.toLowerCase() + "%";
conditions.push(
@@ -172,12 +222,25 @@ export async function listRedirects(
.as("filtered_redirects")
);
// Null priority is shown as 100, so sort on that same value.
const prioritySort = sql`COALESCE(${redirects.priority}, 100)`;
const orderBy =
sort_by === "name"
? order === "asc"
? [asc(redirects.name), desc(redirects.redirectId)]
: [desc(redirects.name), desc(redirects.redirectId)]
: sort_by === "priority"
? order === "asc"
? [asc(prioritySort), desc(redirects.redirectId)]
: [desc(prioritySort), desc(redirects.redirectId)]
: [desc(prioritySort), desc(redirects.redirectId)];
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(desc(redirects.priority), desc(redirects.redirectId))
.orderBy(...orderBy)
]);
return response<ListRedirectsResponse>(res, {
+118 -11
View File
@@ -19,12 +19,16 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown,
ArrowDown01Icon,
ArrowRight,
ArrowUp,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon,
GlobeIcon,
MinusIcon,
MoreHorizontal,
@@ -33,6 +37,7 @@ import {
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { z } from "zod";
import {
useMemo,
useOptimistic,
@@ -42,8 +47,14 @@ import {
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { InfoPopup } from "./ui/info-popup";
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
export type RedirectRow = {
redirectId: number;
niceId: string;
@@ -119,6 +130,26 @@ export default function RedirectsTable({
filter({ searchParams });
}, 300);
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
}
filter({ searchParams });
}
function toggleSort(column: string) {
filter({ searchParams: getNextSortOrder(column, searchParams) });
}
// Prefix matches/rewrites cover everything beneath the path, so show the
// implied glob rather than the bare prefix.
function withPrefixGlob(path: string) {
@@ -170,7 +201,27 @@ export default function RedirectsTable({
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
@@ -185,9 +236,7 @@ export default function RedirectsTable({
accessorKey: "niceId",
friendlyName: t("identifier"),
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => (
<code className="text-sm">{row.original.niceId}</code>
)
cell: ({ row }) => <span>{row.original.niceId}</span>
},
{
id: "attachedTo",
@@ -308,7 +357,29 @@ export default function RedirectsTable({
{
accessorKey: "priority",
friendlyName: t("priority"),
header: () => <span className="p-3">{t("priority")}</span>,
header: () => {
const priorityOrder = getSortDirection(
"priority",
searchParams
);
const Icon =
priorityOrder === "asc"
? ArrowDown01Icon
: priorityOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("priority")}
>
{t("priority")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
// 100 is the automatic default; anything else was set
// deliberately, so flag which way it deviates.
@@ -330,7 +401,28 @@ export default function RedirectsTable({
{
accessorKey: "permanent",
friendlyName: t("redirectType"),
header: () => <span className="p-3">{t("redirectType")}</span>,
header: () => (
<ColumnFilterButton
options={[
{
value: "permanent",
label: t("redirectTypePermanent")
},
{
value: "temporary",
label: t("redirectTypeTemporary")
}
]}
selectedValue={searchParams.get("type") ?? undefined}
onValueChange={(value) =>
handleFilterChange("type", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("redirectType")}
className="p-3"
/>
),
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.permanent
@@ -342,7 +434,24 @@ export default function RedirectsTable({
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<RedirectEnabledForm
redirect={row.original}
@@ -397,7 +506,7 @@ export default function RedirectsTable({
)
}
],
[orgId, t]
[orgId, t, searchParams]
);
return (
@@ -445,9 +554,7 @@ export default function RedirectsTable({
rowCount={rowCount}
columnVisibility={{
attachedTo: false,
niceId: false,
permanent: false,
priority: false
niceId: false
}}
enableColumnVisibility
stickyLeftColumn="name"