mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 03:59:07 +02:00
seperate attached to column
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user