mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-07 16:18:47 +00:00
support site filter in private resources table
This commit is contained in:
@@ -4,7 +4,7 @@ import logger from "@server/logger";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
enum: ["asc", "desc"],
|
enum: ["asc", "desc"],
|
||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
siteId: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,10 +209,31 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { page, pageSize, query, mode, sort_by, order } =
|
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
|
if (siteId != null) {
|
||||||
|
const resourcesForSite = db
|
||||||
|
.select({ id: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.siteId, siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
conditions.push(
|
||||||
|
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
|
import type ResponseT from "@server/types/Response";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -22,6 +24,13 @@ export interface ClientResourcesPageProps {
|
|||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(s: string | undefined): number | undefined {
|
||||||
|
if (!s) return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
props: ClientResourcesPageProps
|
props: ClientResourcesPageProps
|
||||||
) {
|
) {
|
||||||
@@ -47,6 +56,32 @@ export default async function ClientResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
||||||
|
|
||||||
|
let initialFilterSite: {
|
||||||
|
siteId: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
} | null = null;
|
||||||
|
if (siteIdParam) {
|
||||||
|
try {
|
||||||
|
const siteRes = await internal.get(
|
||||||
|
`/site/${siteIdParam}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
||||||
|
if (s && s.orgId === params.orgId) {
|
||||||
|
initialFilterSite = {
|
||||||
|
siteId: s.siteId,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const res = await getCachedOrg(params.orgId);
|
const res = await getCachedOrg(params.orgId);
|
||||||
@@ -114,6 +149,7 @@ export default async function ClientResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
|
initialFilterSite={initialFilterSite}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { DataTable } from "@app/components/ui/data-table";
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +13,11 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
@@ -23,12 +29,14 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
|
Funnel,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
@@ -219,13 +227,15 @@ type ClientResourcesTableProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
initialFilterSite?: Selectedsite | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientResourcesTable({
|
export default function ClientResourcesTable({
|
||||||
internalResources,
|
internalResources,
|
||||||
orgId,
|
orgId,
|
||||||
pagination,
|
pagination,
|
||||||
rowCount
|
rowCount,
|
||||||
|
initialFilterSite = null
|
||||||
}: ClientResourcesTableProps) {
|
}: ClientResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -247,9 +257,26 @@ export default function ClientResourcesTable({
|
|||||||
const [editingResource, setEditingResource] =
|
const [editingResource, setEditingResource] =
|
||||||
useState<InternalResourceRow | null>();
|
useState<InternalResourceRow | null>();
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const siteIdQ = searchParams.get("siteId");
|
||||||
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
|
const selectedSite: Selectedsite | null = useMemo(() => {
|
||||||
|
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
|
||||||
|
return initialFilterSite;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
siteId: siteIdNum,
|
||||||
|
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
|
||||||
|
type: "newt"
|
||||||
|
};
|
||||||
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
@@ -391,7 +418,55 @@ export default function ClientResourcesTable({
|
|||||||
id: "sites",
|
id: "sites",
|
||||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
friendlyName: t("sites"),
|
friendlyName: t("sites"),
|
||||||
header: () => <span className="p-3">{t("sites")}</span>,
|
header: () => (
|
||||||
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
|
!selectedSite && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t("sites")}
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
{selectedSite && (
|
||||||
|
<Badge
|
||||||
|
className="truncate max-w-[10rem]"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{selectedSite.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
|
||||||
|
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={clearSiteFilter}
|
||||||
|
>
|
||||||
|
{t("standaloneHcFilterAnySite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSelectSite={onPickSite}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -576,6 +651,16 @@ export default function ClientResourcesTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSiteFilter = () => {
|
||||||
|
handleFilterChange("siteId", undefined);
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickSite = (site: Selectedsite) => {
|
||||||
|
handleFilterChange("siteId", String(site.siteId));
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user