diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 0c07e40c..479d16a0 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -1,3 +1,4 @@ +import { build } from "@server/build"; import { clients, currentFingerprint, @@ -132,9 +133,12 @@ const listUserDevicesSchema = z.object({ ]) .optional() .catch(undefined), - filters: z.preprocess( - (val: string) => { - return val.split(","); // the search query array is an array joined by a comma + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; }, z .array( @@ -222,16 +226,8 @@ export async function listUserDevices( ) ); } - const { - page, - pageSize, - query, - sort_by, - online, - filters, - agent, - order - } = parsedQuery.data; + const { page, pageSize, query, sort_by, online, status, agent, order } = + parsedQuery.data; const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -293,7 +289,7 @@ export async function listUserDevices( conditions.push( or( ilike(clients.name, "%" + query + "%"), - ilike(users.name, "%" + query + "%") + ilike(users.email, "%" + query + "%") ) ); } @@ -321,34 +317,40 @@ export async function listUserDevices( } } - if (filters.length > 0) { + if (status.length > 0) { const filterAggregates: (SQL | undefined)[] = []; - if (filters.includes("active")) { + if (status.includes("active")) { filterAggregates.push( and( eq(clients.archived, false), eq(clients.blocked, false), - or( - eq(clients.approvalState, "approved"), - isNull(clients.approvalState) // approval state of `NULL` means approved by default - ) + build !== "oss" + ? or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + : undefined // undefined are automatically ignored by `drizzle-orm` ) ); } - if (filters.includes("pending")) { - filterAggregates.push(eq(clients.approvalState, "pending")); - } - if (filters.includes("denied")) { - filterAggregates.push(eq(clients.approvalState, "denied")); - } - if (filters.includes("archived")) { + + if (status.includes("archived")) { filterAggregates.push(eq(clients.archived, true)); } - if (filters.includes("blocked")) { + if (status.includes("blocked")) { filterAggregates.push(eq(clients.blocked, true)); } + if (build !== "oss") { + if (status.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (status.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + } + conditions.push(or(...filterAggregates)); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index d047a60f..fcb24e4e 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let userClients: ListUserDevicesResponse["devices"] = []; @@ -30,7 +32,10 @@ export default async function ClientsPage(props: ClientsPageProps) { try { const userRes = await internal.get< AxiosResponse - >(`/org/${params.orgId}/user-devices`, await authCookieHeader()); + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, + await authCookieHeader() + ); const responseData = userRes.data.data; userClients = responseData.devices; pagination = responseData.pagination; @@ -97,11 +102,6 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - console.log({ - userClientRows, - pagination - }); - return ( <> ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index e3fcd11a..126eb242 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -382,7 +382,7 @@ export default function ClientResourcesTable({ onPaginationChange={handlePaginationChange} pagination={pagination} rowCount={rowCount} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} enableColumnVisibility columnVisibility={{ niceId: false, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 76117776..c7857773 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -12,10 +12,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useSortColumn } from "@app/hooks/useSortColumn"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; import { type PaginationState } from "@tanstack/react-table"; import { @@ -24,21 +24,19 @@ import { ArrowUp10Icon, ArrowUpRight, ChevronsUpDownIcon, - Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; -import { ColumnFilter } from "./ColumnFilter"; -import { ColumnFilterButton } from "./ColumnFilterButton"; -import z from "zod"; export type SiteRow = { id: number; @@ -71,16 +69,18 @@ export default function SitesTable({ rowCount }: SitesTableProps) { const router = useRouter(); - const searchParams = useSearchParams(); const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const [getSortDirection, toggleSorting] = useSortColumn(); - const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -136,9 +136,6 @@ export default function SitesTable({ }); } - const dataInOrder = getSortDirection("megabytesIn"); - const dataOutOrder = getSortDirection("megabytesOut"); - const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -212,6 +209,10 @@ export default function SitesTable({ accessorKey: "mbIn", friendlyName: t("dataIn"), header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); const Icon = dataInOrder === "asc" ? ArrowDown01Icon @@ -221,21 +222,23 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - - parseDataSize(rowB.original.mbIn) + } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = dataOutOrder === "asc" ? ArrowDown01Icon @@ -245,16 +248,13 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - - parseDataSize(rowB.original.mbOut) + } }, { accessorKey: "type", @@ -423,18 +423,28 @@ export default function SitesTable({ } ]; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + const handlePaginationChange = (newPage: PaginationState) => { - const sp = new URLSearchParams(searchParams); - sp.set("page", (newPage.pageIndex + 1).toString()); - sp.set("pageSize", newPage.pageSize.toString()); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); }; const handleSearchChange = useDebouncedCallback((query: string) => { - const sp = new URLSearchParams(searchParams); - sp.set("query", query); - sp.delete("page"); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); }, 300); return ( @@ -478,7 +488,7 @@ export default function SitesTable({ onSearch={handleSearchChange} addButtonText={t("siteAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ niceId: false, diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index edc84088..7e441547 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -2,37 +2,41 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { build } from "@server/build"; +import type { PaginationState } from "@tanstack/react-table"; import { - formatFingerprintInfo, - formatPlatform -} from "@app/lib/formatDeviceFingerprint"; -import { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, - MoreHorizontal, - CircleSlash + ChevronsUpDownIcon, + CircleSlash, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { Badge } from "./ui/badge"; -import { build } from "@server/build"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { InfoPopup } from "@app/components/ui/info-popup"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type ClientRow = { id: number; @@ -68,9 +72,15 @@ export type ClientRow = { type ClientTableProps = { userClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; -export default function UserDevicesTable({ userClients }: ClientTableProps) { +export default function UserDevicesTable({ + userClients, + pagination, + rowCount +}: ClientTableProps) { const router = useRouter(); const t = useTranslations(); @@ -80,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); const defaultUserColumnVisibility = { @@ -296,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; const fingerprintInfo = r.fingerprint @@ -360,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "niceId", friendlyName: t("identifier"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "userEmail", friendlyName: t("users"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("users")}, cell: ({ row }) => { const r = row.original; return r.userId ? ( @@ -416,19 +389,30 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -453,18 +437,29 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + console.log({ + dataInOrder, + searchParams: Object.fromEntries(searchParams.entries()) + }); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -472,18 +467,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -491,21 +493,48 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + ]} + selectedValue={searchParams.get("agent") ?? undefined} + onValueChange={(value) => + handleFilterChange("agent", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("agent")} + className="p-3" + /> + ), cell: ({ row }) => { const originalRow = row.original; @@ -531,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "subnet", friendlyName: t("address"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("address")} } ]; @@ -643,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); const statusFilterOptions = useMemo(() => { const allOptions = [ @@ -691,6 +706,53 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return ["active", "pending"]; }, []); + const [, setCount] = useState(0); + + 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) { + for (const val of value) { + searchParams.append(column, val); + } + } + + filter({ + searchParams + }); + setCount((c) => c + 1); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> {selectedClient && !selectedClient.userId && ( @@ -714,67 +776,69 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { )} - { - if (selectedValues.length === 0) return true; - const rowArchived = row.archived; - const rowBlocked = row.blocked; - const approvalState = row.approvalState; - const isActive = - !rowArchived && - !rowBlocked && - approvalState !== "pending" && - approvalState !== "denied"; + // filters={[ + // { + // id: "status", + // label: t("status") || "Status", + // multiSelect: true, + // displayMode: "calculated", + // options: statusFilterOptions, + // filterFn: ( + // row: ClientRow, + // selectedValues: (string | number | boolean)[] + // ) => { + // if (selectedValues.length === 0) return true; + // const rowArchived = row.archived; + // const rowBlocked = row.blocked; + // const approvalState = row.approvalState; + // const isActive = + // !rowArchived && + // !rowBlocked && + // approvalState !== "pending" && + // approvalState !== "denied"; - if (selectedValues.includes("active") && isActive) - return true; - if ( - selectedValues.includes("pending") && - approvalState === "pending" - ) - return true; - if ( - selectedValues.includes("denied") && - approvalState === "denied" - ) - return true; - if ( - selectedValues.includes("archived") && - rowArchived - ) - return true; - if ( - selectedValues.includes("blocked") && - rowBlocked - ) - return true; - return false; - }, - defaultValues: statusFilterDefaultValues - } - ]} + // if (selectedValues.includes("active") && isActive) + // return true; + // if ( + // selectedValues.includes("pending") && + // approvalState === "pending" + // ) + // return true; + // if ( + // selectedValues.includes("denied") && + // approvalState === "denied" + // ) + // return true; + // if ( + // selectedValues.includes("archived") && + // rowArchived + // ) + // return true; + // if ( + // selectedValues.includes("blocked") && + // rowBlocked + // ) + // return true; + // return false; + // }, + // defaultValues: statusFilterDefaultValues + // } + // ]} /> ); diff --git a/src/hooks/useSortColumn.ts b/src/hooks/useSortColumn.ts deleted file mode 100644 index 95fb673e..00000000 --- a/src/hooks/useSortColumn.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SortOrder } from "@app/lib/types/sort"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import { startTransition } from "react"; - -export function useSortColumn() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const toggleSorting = (column: string) => { - const sp = new URLSearchParams(searchParams); - - let nextDirection: SortOrder = "indeterminate"; - - if (sp.get("sort_by") === column) { - nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; - } - - switch (nextDirection) { - case "indeterminate": { - nextDirection = "asc"; - break; - } - case "asc": { - nextDirection = "desc"; - break; - } - default: { - nextDirection = "indeterminate"; - break; - } - } - - sp.delete("sort_by"); - sp.delete("order"); - - if (nextDirection !== "indeterminate") { - sp.set("sort_by", column); - sp.set("order", nextDirection); - } - - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); - }; - - function getSortDirection(column: string) { - let currentDirection: SortOrder = "indeterminate"; - - if (searchParams.get("sort_by") === column) { - currentDirection = - (searchParams.get("order") as SortOrder) ?? "indeterminate"; - } - return currentDirection; - } - - return [getSortDirection, toggleSorting] as const; -} diff --git a/src/lib/sortColumn.ts b/src/lib/sortColumn.ts new file mode 100644 index 00000000..fcb4cc98 --- /dev/null +++ b/src/lib/sortColumn.ts @@ -0,0 +1,52 @@ +import type { SortOrder } from "@app/lib/types/sort"; + +export function getNextSortOrder( + column: string, + searchParams: URLSearchParams +) { + const sp = new URLSearchParams(searchParams); + + let nextDirection: SortOrder = "indeterminate"; + + if (sp.get("sort_by") === column) { + nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; + } + + switch (nextDirection) { + case "indeterminate": { + nextDirection = "asc"; + break; + } + case "asc": { + nextDirection = "desc"; + break; + } + default: { + nextDirection = "indeterminate"; + break; + } + } + + sp.delete("sort_by"); + sp.delete("order"); + + if (nextDirection !== "indeterminate") { + sp.set("sort_by", column); + sp.set("order", nextDirection); + } + + return sp; +} + +export function getSortDirection( + column: string, + searchParams: URLSearchParams +) { + let currentDirection: SortOrder = "indeterminate"; + + if (searchParams.get("sort_by") === column) { + currentDirection = + (searchParams.get("order") as SortOrder) ?? "indeterminate"; + } + return currentDirection; +}