diff --git a/messages/en-US.json b/messages/en-US.json index f74f72444..d1486cbe6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2438,6 +2438,7 @@ "domains": "Domains", "domainsDescription": "Create and manage domains available in the organization", "domainsSearch": "Search domains...", + "domainsNotFound": "No domains found", "domainAdd": "Add Domain", "domainAddDescription": "Register a new domain with to the organization", "domainCreate": "Create Domain", @@ -4428,6 +4429,10 @@ "redirectSource": "Source", "redirectDestination": "Destination", "redirectAttachedTo": "Attached To", + "redirectFilterAnyDomain": "All domains", + "redirectFilterAnyResource": "All resources", + "redirectFilterDomainFallback": "Domain {id}", + "redirectFilterResourceFallback": "Resource {id}", "redirectType": "Type", "redirectTypePermanent": "Permanent (308)", "redirectTypeTemporary": "Temporary (307)", diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index c59d03b4e..518d27298 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -104,7 +104,21 @@ const listSchema = z.object({ .openapi({ type: "boolean", description: "Filter redirects based on enabled status" - }) + }), + resourceId: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined) + .openapi({ + type: "integer", + description: "Filter redirects attached to this resource" + }), + domainId: z.string().nonempty().optional().catch(undefined).openapi({ + type: "string", + description: "Filter redirects attached to this domain" + }) }); registry.registerPath({ @@ -160,8 +174,17 @@ export async function listRedirects( ); } - const { pageSize, page, query, sort_by, order, type, enabled } = - parsedQuery.data; + const { + pageSize, + page, + query, + sort_by, + order, + type, + enabled, + resourceId, + domainId + } = parsedQuery.data; const conditions = [eq(redirects.orgId, orgId)]; if (type === "permanent") { @@ -174,6 +197,14 @@ export async function listRedirects( conditions.push(eq(redirects.enabled, enabled)); } + if (typeof resourceId !== "undefined") { + conditions.push(eq(redirects.resourceId, resourceId)); + } + + if (typeof domainId !== "undefined") { + conditions.push(eq(redirects.domainId, domainId)); + } + if (query) { const term = "%" + query.toLowerCase() + "%"; conditions.push( diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 1f2d4a7d6..7cc04837b 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -1,8 +1,14 @@ -import RedirectsTable from "@app/components/RedirectsTable"; +import RedirectsTable, { + type RedirectFilterDomain +} from "@app/components/RedirectsTable"; +import type { SelectedResource } from "@app/components/resource-selector"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { ListRedirectsResponse } from "@server/routers/redirect"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -18,10 +24,18 @@ type RedirectIndexPageProps = { export const dynamic = "force-dynamic"; +function parsePositiveInt(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return undefined; + return parsed; +} + export default async function RedirectIndexPage(props: RedirectIndexPageProps) { const { orgId } = await props.params; const searchParams = new URLSearchParams(await props.searchParams); const t = await getTranslations(); + const header = await authCookieHeader(); let redirects: ListRedirectsResponse["redirects"] = []; let pagination: ListRedirectsResponse["pagination"] = { @@ -33,7 +47,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { try { const res = await internal.get>( `/org/${orgId}/redirects?${searchParams.toString()}`, - await authCookieHeader() + header ); const responseData = res.data.data; redirects = responseData.redirects; @@ -42,6 +56,53 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { // empty list on error } + const resourceIdParam = parsePositiveInt(searchParams.get("resourceId")); + let initialFilterResource: SelectedResource | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const resource = ( + resourceRes.data as ResponseT + ).data; + if (resource && resource.orgId === orgId) { + initialFilterResource = { + name: resource.name, + resourceId: resource.resourceId, + fullDomain: resource.fullDomain, + niceId: resource.niceId, + ssl: resource.ssl, + wildcard: resource.wildcard + }; + } + } catch { + // leave null so the table falls back to the id + } + } + + const domainIdParam = searchParams.get("domainId"); + let initialFilterDomain: RedirectFilterDomain | null = null; + if (domainIdParam) { + try { + const domainRes = await internal.get( + `/org/${orgId}/domain/${domainIdParam}`, + header + ); + const domain = (domainRes.data as ResponseT) + .data; + if (domain) { + initialFilterDomain = { + domainId: domain.domainId, + baseDomain: domain.baseDomain + }; + } + } catch { + // leave null so the table falls back to the id + } + } + const redirectRows = redirects.map((redirect) => ({ redirectId: redirect.redirectId, niceId: redirect.niceId, @@ -79,6 +140,8 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterResource={initialFilterResource} + initialFilterDomain={initialFilterDomain} /> ); diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 5b35aafeb..2918c1e09 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -2,24 +2,45 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + ResourceSelector, + type SelectedResource +} from "@app/components/resource-selector"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Switch } from "@app/components/ui/switch"; import { - ControlledDataTable, - type ExtendedColumnDef -} from "@app/components/ui/controlled-data-table"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Switch } from "@app/components/ui/switch"; 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 { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown, @@ -28,11 +49,11 @@ import { ArrowUp, ArrowUp10Icon, ArrowUpRight, + CheckIcon, ChevronsUpDownIcon, - GlobeIcon, + Funnel, MinusIcon, - MoreHorizontal, - WaypointsIcon + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -44,7 +65,8 @@ import { useRef, useState, useTransition, - type ComponentRef + type ComponentRef, + type ReactNode } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -77,18 +99,27 @@ export type RedirectRow = { baseDomain: string | null; }; +export type RedirectFilterDomain = { + domainId: string; + baseDomain: string; +}; + type RedirectsTableProps = { redirects: RedirectRow[]; orgId: string; pagination: PaginationState; rowCount: number; + initialFilterResource?: SelectedResource | null; + initialFilterDomain?: RedirectFilterDomain | null; }; export default function RedirectsTable({ redirects, orgId, pagination, - rowCount + rowCount, + initialFilterResource = null, + initialFilterDomain = null }: RedirectsTableProps) { const router = useRouter(); const t = useTranslations(); @@ -103,6 +134,48 @@ export default function RedirectsTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [domainFilterOpen, setDomainFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + + const domainIdQ = searchParams.get("domainId"); + const selectedDomain: RedirectFilterDomain | null = useMemo(() => { + if (!domainIdQ) { + return null; + } + if (initialFilterDomain && initialFilterDomain.domainId === domainIdQ) { + return initialFilterDomain; + } + return { + domainId: domainIdQ, + baseDomain: t("redirectFilterDomainFallback", { id: domainIdQ }) + }; + }, [domainIdQ, initialFilterDomain, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("redirectFilterResourceFallback", { id: resourceIdNum }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false, + wildcard: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); function refreshData() { startTransition(() => { @@ -146,6 +219,26 @@ export default function RedirectsTable({ filter({ searchParams }); } + const clearDomainFilter = () => { + handleFilterChange("domainId", undefined); + setDomainFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickDomain = (domain: RedirectFilterDomain) => { + handleFilterChange("domainId", domain.domainId); + setDomainFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + function toggleSort(column: string) { filter({ searchParams: getNextSortOrder(column, searchParams) }); } @@ -239,53 +332,93 @@ export default function RedirectsTable({ cell: ({ row }) => {row.original.niceId} }, { - id: "attachedTo", - friendlyName: t("redirectAttachedTo"), + id: "domain", + friendlyName: t("domain"), header: () => ( - {t("redirectAttachedTo")} + + + ), cell: ({ row }) => { const redirect = row.original; - if (redirect.resourceId && redirect.resourceNiceId) { - return ( - - ); + if (!redirect.domainId || !redirect.baseDomain) { + return -; } - if (redirect.domainId) { - return ( - - ); + {redirect.baseDomain} + + + + ); + } + }, + { + id: "resource", + friendlyName: t("resource"), + header: () => ( + + + + ), + cell: ({ row }) => { + const redirect = row.original; + + if ( + !redirect.resourceId || + !redirect.resourceName || + !redirect.resourceNiceId + ) { + return -; } - return -; + return ( + + ); } }, @@ -506,7 +639,15 @@ export default function RedirectsTable({ ) } ], - [orgId, t, searchParams] + [ + orgId, + t, + searchParams, + domainFilterOpen, + resourceFilterOpen, + selectedDomain, + selectedResource + ] ); return ( @@ -553,7 +694,6 @@ export default function RedirectsTable({ isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ - attachedTo: false, niceId: false }} enableColumnVisibility @@ -603,3 +743,117 @@ function RedirectEnabledForm({ ); } + +type ColumnEntityFilterProps = { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + selectedName?: string | null; + clearLabel: string; + onClear: () => void; + children: ReactNode; +}; + +function ColumnEntityFilter({ + label, + open, + onOpenChange, + selectedName, + clearLabel, + onClear, + children +}: ColumnEntityFilterProps) { + return ( + + + + + +
+ +
+ {children} +
+
+ ); +} + +type DomainFilterListProps = { + orgId: string; + selectedDomainId: string | null; + onSelect: (domain: RedirectFilterDomain) => void; +}; + +function DomainFilterList({ + orgId, + selectedDomainId, + onSelect +}: DomainFilterListProps) { + const t = useTranslations(); + const { data: domains = [] } = useQuery(orgQueries.domains({ orgId })); + + return ( + + + + {t("domainsNotFound")} + + {domains.map((domain) => ( + + onSelect({ + domainId: domain.domainId, + baseDomain: domain.baseDomain + }) + } + > + + {domain.baseDomain} + + ))} + + + + ); +}