mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 12:09:04 +02:00
add server side sort and filter
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user