seperate attached to column

This commit is contained in:
miloschwartz
2026-10-02 17:02:35 -04:00
parent e0fb6d6883
commit 4d1a14c965
4 changed files with 406 additions and 53 deletions
+5
View File
@@ -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)",
+34 -3
View File
@@ -104,7 +104,21 @@ const listSchema = z.object({
.openapi({
type: "boolean",
description: "Filter redirects based on enabled status"
})
}),
resourceId: z.coerce
.number<string>()
.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(
+65 -2
View File
@@ -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<AxiosResponse<ListRedirectsResponse>>(
`/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<GetResourceResponse>
).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<GetDomainResponse>)
.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}
/>
</>
);
+302 -48
View File
@@ -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 }) => <span>{row.original.niceId}</span>
},
{
id: "attachedTo",
friendlyName: t("redirectAttachedTo"),
id: "domain",
friendlyName: t("domain"),
header: () => (
<span className="p-3">{t("redirectAttachedTo")}</span>
<ColumnEntityFilter
label={t("domain")}
open={domainFilterOpen}
onOpenChange={setDomainFilterOpen}
selectedName={selectedDomain?.baseDomain}
clearLabel={t("redirectFilterAnyDomain")}
onClear={clearDomainFilter}
>
<DomainFilterList
orgId={orgId}
selectedDomainId={selectedDomain?.domainId ?? null}
onSelect={onPickDomain}
/>
</ColumnEntityFilter>
),
cell: ({ row }) => {
const redirect = row.original;
if (redirect.resourceId && redirect.resourceNiceId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
>
<WaypointsIcon className="size-3 text-muted-foreground" />
{redirect.resourceName}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
if (!redirect.domainId || !redirect.baseDomain) {
return <span>-</span>;
}
if (redirect.domainId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/domains/${redirect.domainId}`}
>
<Link
href={`/${orgId}/settings/domains/${redirect.domainId}`}
>
<GlobeIcon className="size-3 text-muted-foreground" />
{redirect.baseDomain}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
{redirect.baseDomain}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
},
{
id: "resource",
friendlyName: t("resource"),
header: () => (
<ColumnEntityFilter
label={t("resource")}
open={resourceFilterOpen}
onOpenChange={setResourceFilterOpen}
selectedName={selectedResource?.name}
clearLabel={t("redirectFilterAnyResource")}
onClear={clearResourceFilter}
>
<ResourceSelector
orgId={orgId}
selectedResource={selectedResource}
onSelectResource={onPickResource}
/>
</ColumnEntityFilter>
),
cell: ({ row }) => {
const redirect = row.original;
if (
!redirect.resourceId ||
!redirect.resourceName ||
!redirect.resourceNiceId
) {
return <span>-</span>;
}
return <span>-</span>;
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
>
{redirect.resourceName}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
},
@@ -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({
</form>
);
}
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 (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedName && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{label}
<Funnel className="size-4 flex-none" />
{selectedName && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedName}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={onClear}
>
{clearLabel}
</Button>
</div>
{children}
</PopoverContent>
</Popover>
);
}
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 (
<Command>
<CommandInput placeholder={t("domainsSearch")} />
<CommandList>
<CommandEmpty>{t("domainsNotFound")}</CommandEmpty>
<CommandGroup>
{domains.map((domain) => (
<CommandItem
key={domain.domainId}
value={domain.baseDomain}
onSelect={() =>
onSelect({
domainId: domain.domainId,
baseDomain: domain.baseDomain
})
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
domain.domainId === selectedDomainId
? "opacity-100"
: "opacity-0"
)}
/>
{domain.baseDomain}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}