diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 0b214f4b5..c59d03b4e 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -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(res, { diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index d51a88c66..5b35aafeb 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -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: () => {t("name")}, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, cell: ({ row }) => ( {t("identifier")}, - cell: ({ row }) => ( - {row.original.niceId} - ) + cell: ({ row }) => {row.original.niceId} }, { id: "attachedTo", @@ -308,7 +357,29 @@ export default function RedirectsTable({ { accessorKey: "priority", friendlyName: t("priority"), - header: () => {t("priority")}, + header: () => { + const priorityOrder = getSortDirection( + "priority", + searchParams + ); + const Icon = + priorityOrder === "asc" + ? ArrowDown01Icon + : priorityOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, 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: () => {t("redirectType")}, + header: () => ( + + handleFilterChange("type", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("redirectType")} + className="p-3" + /> + ), cell: ({ row }) => ( {row.original.permanent @@ -342,7 +434,24 @@ export default function RedirectsTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => (