diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 7ec497809..ba4d07844 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -52,6 +52,8 @@ }, "search_placeholder": "Search...", "results_not_found": "No results found", + "selected_count": "{count} selected", + "clear_filters": "Clear filters", "date": "{date, date}", "date_medium": "{date, date, medium}", "date_short": "{date, date, short}", @@ -263,6 +265,7 @@ "createdAt": "Created At", "emailNotVerified": "Email Not Verified", "view": "View profile", + "roles": "Roles", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -306,7 +309,9 @@ "desc": "Manage the roles of your application.", "role": "Role", "usersCount": "Number of users", - "createdAt": "Created At", + "updatedAt": "Updated At", + "searchPlaceholder": "Search roles by name...", + "openUsersTooltip": "View users with this role", "noResults": { "title": "No roles found", "description": "Try adjusting your search criteria." diff --git a/apps/docs/content/docs/ui/data-table.mdx b/apps/docs/content/docs/ui/data-table.mdx index d040e9a74..bd8d6f2b4 100644 --- a/apps/docs/content/docs/ui/data-table.mdx +++ b/apps/docs/content/docs/ui/data-table.mdx @@ -87,6 +87,25 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; required: false, type: "string", }, + filters: { + description: + "Renders one or more faceted multi-select dropdowns above the table. Each filter controls its own URL query parameter.", + required: false, + type: `{ + id: string; + label: string; + options?: { + value: string; + label: ReactNode; + keywords?: string[]; + }[]; + onSearch?: (search: string) => Promise<{ + value: string; + label: ReactNode; + keywords?: string[]; + }[]>; + }[]`, + }, }} /> @@ -173,6 +192,150 @@ The input writes the term to the `?search=` query parameter (debounced) and relo columns to search across — see the [Search](/docs/dev/database/search) guide. +## Filters + +Pass the `filters` prop to render one or more faceted, multi-select dropdowns above the table (next to the search input). Each filter controls its own URL query parameter, so it works out of the box with server-side data fetching. Multiple selected values are stored as a comma-separated list, e.g. `?roleId=1,3`, and changing a filter resets the pagination cursor. + + Promise", + }, + }} +/> + +### Static filters + +When the set of options is small and known ahead of time, pass them directly via `options`. The dropdown list is filtered on the client. + +```tsx + +``` + +### Async filters + +When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs — debounced — as the user types and should return results already filtered and capped by the server. Use a [Server Action](https://react.dev/reference/rsc/server-functions) so the lookup runs server-side: + +```tsx title="search-roles.action.tsx" +"use server"; + +import type { FilterOption } from "@vitnode/core/components/table/filters"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { RoleFormat } from "@vitnode/core/components/role-format"; +import { fetcher } from "@vitnode/core/lib/fetcher"; + +export const searchRoles = async (search: string): Promise => { + const res = await fetcher(adminModule, { + path: "/list", + method: "get", + module: "admin/roles", + args: { + query: { search, first: "20" }, + }, + withPagination: true, + }); + + if (res.status !== 200) { + return []; + } + + const data = await res.json(); + + return data.edges.map(role => ({ + value: String(role.id), + label: , + keywords: role.name.map(item => item.name), + })); +}; +``` + +```tsx +filters={[ + { + id: "roleId", + label: "Group", + // [!code ++] + onSearch: searchRoles, + }, +]} +``` + + + A filter only writes its values to the URL — the backend must read the query + parameter and apply the matching `where` clause. For a comma-separated + multi-select, split the value and use `inArray`: + +```ts +const roleIds = (query.roleId?.split(",") ?? []) + .filter(Boolean) + .map(Number) + .filter((id) => !Number.isNaN(id)); + +// pass to withPagination: +where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined, +``` + + + ## Complete Example Here's a complete example showing how to use the `DataTable` component in a page: diff --git a/apps/docs/content/docs/ui/toggle-group.mdx b/apps/docs/content/docs/ui/toggle-group.mdx index 0520e0972..f18b8e6e1 100644 --- a/apps/docs/content/docs/ui/toggle-group.mdx +++ b/apps/docs/content/docs/ui/toggle-group.mdx @@ -19,7 +19,7 @@ import { ``` ```tsx - + @@ -34,4 +34,4 @@ import { ## API Reference -[Radix UI - Toggle Group](https://www.radix-ui.com/primitives/docs/components/toggle-group#api-reference) +[Base UI - Toggle Group](https://base-ui.com/react/components/toggle-group) diff --git a/apps/docs/content/docs/ui/toggle.mdx b/apps/docs/content/docs/ui/toggle.mdx index 9aa0a7240..979fb9342 100644 --- a/apps/docs/content/docs/ui/toggle.mdx +++ b/apps/docs/content/docs/ui/toggle.mdx @@ -23,4 +23,4 @@ import { Toggle } from '@vitnode/core/components/ui/toggle'; ## API Reference -[Radix UI - Toggle](https://www.radix-ui.com/primitives/docs/components/toggle#api-reference) +[Base UI - Toggle](https://base-ui.com/react/components/toggle) diff --git a/apps/docs/content/docs/ui/tooltip.mdx b/apps/docs/content/docs/ui/tooltip.mdx index a079e1120..0349c8ba9 100644 --- a/apps/docs/content/docs/ui/tooltip.mdx +++ b/apps/docs/content/docs/ui/tooltip.mdx @@ -16,9 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@vitnode/core/component ```tsx - - - + Hover} />

Add to library

@@ -45,4 +43,4 @@ import { PlayIcon } from "lucide-react"; ## API Reference -[Radix UI - Tooltip](https://www.radix-ui.com/primitives/docs/components/tooltip#api-reference) +[Base UI - Tooltip](https://base-ui.com/react/components/tooltip) diff --git a/apps/docs/src/app/[locale]/(docs)/docs/layout.tsx b/apps/docs/src/app/[locale]/(docs)/docs/layout.tsx index 0e82841ab..7ae0545ed 100644 --- a/apps/docs/src/app/[locale]/(docs)/docs/layout.tsx +++ b/apps/docs/src/app/[locale]/(docs)/docs/layout.tsx @@ -27,7 +27,7 @@ export default function Layout({ children }: { children: ReactNode }) { { color, "--tab-color": color, - } as object + } as React.CSSProperties } > {node.icon} diff --git a/apps/docs/src/examples/toggle-group.tsx b/apps/docs/src/examples/toggle-group.tsx index 0ca1f217d..d7b7bc944 100644 --- a/apps/docs/src/examples/toggle-group.tsx +++ b/apps/docs/src/examples/toggle-group.tsx @@ -8,7 +8,7 @@ import { Bold, Italic, Underline } from "lucide-react"; export default function ToggleGroupDemo() { return ( - + diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 7ec497809..ba4d07844 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -52,6 +52,8 @@ }, "search_placeholder": "Search...", "results_not_found": "No results found", + "selected_count": "{count} selected", + "clear_filters": "Clear filters", "date": "{date, date}", "date_medium": "{date, date, medium}", "date_short": "{date, date, short}", @@ -263,6 +265,7 @@ "createdAt": "Created At", "emailNotVerified": "Email Not Verified", "view": "View profile", + "roles": "Roles", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -306,7 +309,9 @@ "desc": "Manage the roles of your application.", "role": "Role", "usersCount": "Number of users", - "createdAt": "Created At", + "updatedAt": "Updated At", + "searchPlaceholder": "Search roles by name...", + "openUsersTooltip": "View users with this role", "noResults": { "title": "No roles found", "description": "Try adjusting your search criteria." diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts index 2d97055a2..495497cd1 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -1,5 +1,5 @@ import { z } from "@hono/zod-openapi"; -import { and, count, eq, inArray } from "drizzle-orm"; +import { and, count, eq, ilike, inArray } from "drizzle-orm"; import { buildRoute } from "@/api/lib/route"; import { @@ -30,6 +30,7 @@ const rolesAdminListSchema = z.object({ root: z.boolean(), guest: z.boolean(), createdAt: z.date(), + updatedAt: z.date(), usersCount: z.number(), }), ), @@ -45,7 +46,8 @@ export const listRolesAdminRoute = buildRoute({ request: { query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["id", "createdAt"]).optional(), + orderBy: z.enum(["id", "createdAt", "updatedAt"]).optional(), + search: z.string().optional(), }), }, responses: { @@ -64,11 +66,31 @@ export const listRolesAdminRoute = buildRoute({ }, handler: async c => { const query = c.req.valid("query"); + const search = query.search?.trim(); const data = await withPagination({ params: { query, }, + // Role names live in `core_languages_words`, so search resolves matching + // role ids from there instead of a column on `core_roles`. + where: search + ? inArray( + core_roles.id, + c + .get("db") + .select({ id: core_languages_words.itemId }) + .from(core_languages_words) + .where( + and( + eq(core_languages_words.tableName, "core_roles"), + eq(core_languages_words.variable, "name"), + eq(core_languages_words.pluginCode, "core"), + ilike(core_languages_words.value, `%${search}%`), + ), + ), + ) + : undefined, primaryCursor: core_roles.id, query: async ({ limit, where, orderBy }) => await c @@ -81,6 +103,7 @@ export const listRolesAdminRoute = buildRoute({ root: core_roles.root, guest: core_roles.guest, createdAt: core_roles.createdAt, + updatedAt: core_roles.updatedAt, }) .from(core_roles) .where(where) @@ -90,7 +113,7 @@ export const listRolesAdminRoute = buildRoute({ orderBy: { column: query.orderBy ? core_roles[query.orderBy] - : core_roles.createdAt, + : core_roles.updatedAt, order: query.order ?? "desc", }, c, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts index 28e5852b0..7817c1c72 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts @@ -22,6 +22,7 @@ const roleAdminSchema = z.object({ root: z.boolean(), guest: z.boolean(), createdAt: z.date(), + updatedAt: z.date(), }); export const showRoleAdminRoute = buildRoute({ diff --git a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts index 936c6609a..9daded584 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi"; +import { inArray } from "drizzle-orm"; import { buildRoute } from "@/api/lib/route"; import { @@ -19,6 +20,7 @@ export const listUsersAdminRoute = buildRoute({ query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(["name", "createdAt"]).optional(), + roleId: z.string().optional(), search: z.string().optional(), }), }, @@ -52,11 +54,16 @@ export const listUsersAdminRoute = buildRoute({ }, handler: async c => { const query = c.req.valid("query"); + const roleIds = (query.roleId?.split(",") ?? []) + .filter(Boolean) + .map(id => Number(id)) + .filter(id => !Number.isNaN(id)); const data = await withPagination({ params: { query, }, search: [core_users.name, core_users.email], + where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined, primaryCursor: core_users.id, query: async ({ limit, where, orderBy }) => await c diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx index ecab2b02b..a5022fb88 100644 --- a/packages/vitnode/src/components/table/content.tsx +++ b/packages/vitnode/src/components/table/content.tsx @@ -12,6 +12,7 @@ import { TableHeader, TableRow, } from "../ui/table"; +import { FiltersDataTable } from "./filters"; import { OrderTableHeadDataTable } from "./order-table-head"; import { PaginationDataTable } from "./pagination"; import { SearchDataTable } from "./search"; @@ -24,13 +25,26 @@ export function ContentDataTable({ customNoResults, search, searchPlaceholder, + filters, ...props }: React.ComponentProps>) { const t = useTranslations("core.global"); + const hasToolbar = Boolean(search) || Boolean(filters?.length); return (
- {search && } + {hasToolbar && ( +
+ {search && ( +
+ +
+ )} + {filters && filters.length > 0 && ( + + )} +
+ )}
diff --git a/packages/vitnode/src/components/table/data-table.tsx b/packages/vitnode/src/components/table/data-table.tsx index 5fdf549b1..d458b9e84 100644 --- a/packages/vitnode/src/components/table/data-table.tsx +++ b/packages/vitnode/src/components/table/data-table.tsx @@ -1,5 +1,6 @@ import React from "react"; +import type { FilterDataTable } from "./filters"; import type { PaginationDataTable } from "./pagination"; import type { SearchDataTable } from "./search"; @@ -100,6 +101,7 @@ export function DataTable( title?: string; }; edges: T[]; + filters?: FilterDataTable[]; id: string; order: { columns?: (keyof T)[]; diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx new file mode 100644 index 000000000..aecb7decb --- /dev/null +++ b/packages/vitnode/src/components/table/filters.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import React from "react"; +import { useDebouncedCallback } from "use-debounce"; + +import { usePathname, useRouter } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Separator } from "../ui/separator"; +import { Spinner } from "../ui/spinner"; + +export interface FilterOption { + // Plain text used for type-to-search in static mode, since cmdk can't search + // a `label` node. + keywords?: string[]; + label: React.ReactNode; + value: string; +} + +export interface FilterDataTable { + // URL search param this filter controls. Multiple selections are stored as a + // comma-separated value, e.g. `roleId=1,3`. + id: string; + label: string; + // Async source: called (debounced) as the user types. The API is expected to + // return results already filtered and capped (e.g. 20 per request). + onSearch?: (search: string) => Promise; + // Static options, filtered on the client. + options?: FilterOption[]; +} + +function FilterItem({ filter }: { filter: FilterDataTable }) { + const t = useTranslations("core.global"); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const { push } = useRouter(); + const [isPending, startTransition] = React.useTransition(); + + const isAsync = Boolean(filter.onSearch); + const [asyncOptions, setAsyncOptions] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + + const selected = (searchParams.get(filter.id)?.split(",") ?? []).filter( + Boolean, + ); + const selectedSet = new Set(selected); + const options = isAsync ? asyncOptions : (filter.options ?? []); + + const runSearch = React.useCallback( + async (value: string) => { + if (!filter.onSearch) return; + setIsSearching(true); + try { + setAsyncOptions(await filter.onSearch(value)); + } finally { + setIsSearching(false); + } + }, + [filter], + ); + const debouncedSearch = useDebouncedCallback(runSearch, 400); + + const handleOpenChange = (open: boolean) => { + if (open && isAsync) { + setAsyncOptions([]); + void runSearch(""); + } + }; + + const applySelection = (values: string[]) => { + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()); + + if (values.length) { + params.set(filter.id, values.join(",")); + } else { + params.delete(filter.id); + } + + // The result set changes, so drop the pagination cursor to land on the + // first page instead of a now-invalid one. + params.delete("cursor"); + params.delete("first"); + params.delete("last"); + + push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; + + const toggle = (value: string) => { + const next = new Set(selectedSet); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + applySelection([...next]); + }; + + // In static mode every label is known up front, so selected values render as + // chips. In async mode the selected labels may not be loaded, so we show a + // count instead. + const selectedStaticOptions = isAsync + ? [] + : (filter.options ?? []).filter(option => selectedSet.has(option.value)); + + return ( + + + + + + + + + {isSearching && options.length === 0 ? ( +
+ +
+ ) : ( + <> + {t("results_not_found")} + + {options.map(option => { + const isSelected = selectedSet.has(option.value); + + return ( + toggle(option.value)} + value={option.value} + > +
+ +
+ {option.label} +
+ ); + })} +
+ + )} + {selected.length > 0 && ( + <> + + + applySelection([])} + > + {t("clear_filters")} + + + + )} +
+
+
+
+ ); +} + +export function FiltersDataTable({ filters }: { filters: FilterDataTable[] }) { + return ( + <> + {filters.map(filter => ( + + ))} + + ); +} diff --git a/packages/vitnode/src/components/ui/sidebar.tsx b/packages/vitnode/src/components/ui/sidebar.tsx index 2885fa870..6ebf12bf1 100644 --- a/packages/vitnode/src/components/ui/sidebar.tsx +++ b/packages/vitnode/src/components/ui/sidebar.tsx @@ -534,7 +534,7 @@ function SidebarMenuButton({ return ( - {button} +