diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index f1b057a11..ba46e40c4 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -3,34 +3,68 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; import { ActionsEnum } from "@server/auth/actions"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { object, z } from "zod"; import { fromError } from "zod-validation-error"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); const listRolesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -async function queryRoles(orgId: string, limit: number, offset: number) { - return await db +function queryRolesBase() { + return db .select({ roleId: roles.roleId, orgId: roles.orgId, @@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) { sshUnixGroups: roles.sshUnixGroups }) .from(roles) - .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) - .where(eq(roles.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(orgs, eq(roles.orgId, orgs.orgId)); + // .where(eq(roles.orgId, orgId)) + // .limit(limit) + // .offset(offset); } -export type ListRolesResponse = { - roles: NonNullable>>; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; +export type ListRolesResponse = PaginatedResponse<{ + roles: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -88,7 +117,7 @@ export async function listRoles( ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, sort_by, order } = parsedQuery.data; const parsedParams = listRolesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -102,14 +131,36 @@ export async function listRoles( const { orgId } = parsedParams.data; - const countQuery: any = db - .select({ count: sql`cast(count(*) as integer)` }) - .from(roles) - .where(eq(roles.orgId, orgId)); + const conditions = [and(eq(roles.orgId, orgId))]; - const rolesList = await queryRoles(orgId, limit, offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (query) { + conditions.push( + like(sql`LOWER(${roles.name})`, "%" + query.toLowerCase() + "%") + ); + } + + const countQuery = db.$count( + queryRolesBase() + .where(and(...conditions)) + .as("filtered_roles") + ); + + const rolesListQuery = queryRolesBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(roles[sort_by]) + : desc(roles[sort_by]) + : asc(roles.name) + ); + + const [totalCount, rolesList] = await Promise.all([ + countQuery, + rolesListQuery + ]); let rolesWithAllowSsh = rolesList; if (rolesList.length > 0) { @@ -135,8 +186,8 @@ export async function listRoles( roles: rolesWithAllowSsh, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 7165d9e6c..a2d415be4 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -11,24 +11,32 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type RolesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function RolesPage(props: RolesPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let roles: ListRolesResponse["roles"] = []; + let pagination: ListRolesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/roles`, await authCookieHeader()) + >(`/org/${params.orgId}/roles?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { roles = res.data.data.roles; + pagination = res.data.data.pagination; } const invitationsRes = await internal @@ -63,7 +71,14 @@ export default async function RolesPage(props: RolesPageProps) { description={t("accessRolesDescription")} /> - + ); diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx deleted file mode 100644 index 5a2d1cb4c..000000000 --- a/src/components/RolesDataTable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createRole?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function RolesDataTable({ - columns, - data, - createRole, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index bf17f63f7..75a484ab0 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -7,7 +7,13 @@ import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { toast } from "@app/hooks/useToast"; import { Role } from "@server/db"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpDown, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; @@ -18,24 +24,40 @@ import { DropdownMenuItem } from "./ui/dropdown-menu"; import EditRoleForm from "./EditRoleForm"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { useDebouncedCallback } from "use-debounce"; export type RoleRow = Role; type RolesTableProps = { roles: RoleRow[]; + pagination: PaginationState; + rowCount: number; }; -export default function UsersTable({ roles }: RolesTableProps) { +export default function UsersTable({ + roles, + pagination, + rowCount +}: RolesTableProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const router = useRouter(); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [roleToRemove, setRoleToRemove] = useState(null); const t = useTranslations(); - const [isRefreshing, startTransition] = useTransition(); const refreshData = async () => { console.log("Data refreshed"); @@ -56,15 +78,17 @@ export default function UsersTable({ roles }: RolesTableProps) { enableHiding: false, friendlyName: t("name"), header: ({ column }) => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( - ); } @@ -148,6 +172,30 @@ export default function UsersTable({ roles }: RolesTableProps) { } ]; + 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 ( <> {editingRole && ( @@ -191,10 +239,18 @@ export default function UsersTable({ roles }: RolesTableProps) { /> )} - { + rows={roles} + tableId="roles-table" + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + searchPlaceholder={t("accessRolesSearch")} + addButtonText={t("accessRolesAdd")} + rowCount={rowCount} + pagination={pagination} + onAdd={() => { setIsCreateModalOpen(true); }} onRefresh={() => startTransition(refreshData)} diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx deleted file mode 100644 index ececa4c17..000000000 --- a/src/components/UsersDataTable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - inviteUser?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - inviteUser, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9da0aa1ca..163274877 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,7 @@ "use client"; -import { ColumnDef, type PaginationState } from "@tanstack/react-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -8,35 +9,29 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, ArrowRight, ArrowUp10Icon, - ArrowUpDown, ChevronsUpDownIcon, - Crown, MoreHorizontal } from "lucide-react"; -import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect, useTransition } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { toast } from "@app/hooks/useToast"; -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import IdpTypeBadge from "./IdpTypeBadge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import type { filter } from "d3"; -import { useDebouncedCallback } from "use-debounce"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; -import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; export type UserRow = { id: string;