From c452127e0c2226e30c281945e2b0be118353a159 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 14 Jun 2026 15:56:20 +0200 Subject: [PATCH 1/3] feat: Add filters for data table --- apps/api/src/locales/@vitnode/core/en.json | 5 + apps/docs/content/docs/ui/data-table.mdx | 163 +++++++++++++ apps/docs/src/locales/@vitnode/core/en.json | 5 + .../modules/admin/roles/routes/list.route.ts | 23 +- .../modules/admin/users/routes/list.route.ts | 6 + .../vitnode/src/components/table/content.tsx | 16 +- .../src/components/table/data-table.tsx | 2 + .../vitnode/src/components/table/filters.tsx | 222 ++++++++++++++++++ packages/vitnode/src/locales/en.json | 5 + .../core/users/roles/roles-admin-view.tsx | 23 +- .../views/core/users/search-roles.action.tsx | 33 +++ .../views/core/users/users-admin-view.tsx | 8 + 12 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 packages/vitnode/src/components/table/filters.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/search-roles.action.tsx diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 7ec497809..7d48e092e 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", + "search_role": "Search by role", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -307,6 +310,8 @@ "role": "Role", "usersCount": "Number of users", "createdAt": "Created 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..4fa2431ba 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(",") ?? []) + .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/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 7ec497809..7d48e092e 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", + "search_role": "Search by role", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -307,6 +310,8 @@ "role": "Role", "usersCount": "Number of users", "createdAt": "Created 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..e82753f44 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 { @@ -46,6 +46,7 @@ export const listRolesAdminRoute = buildRoute({ query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(["id", "createdAt"]).optional(), + search: z.string().optional(), }), }, responses: { @@ -64,11 +65,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 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..6bdb77d45 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,15 @@ export const listUsersAdminRoute = buildRoute({ }, handler: async c => { const query = c.req.valid("query"); + const roleIds = (query.roleId?.split(",") ?? []) + .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..679663ed9 --- /dev/null +++ b/packages/vitnode/src/components/table/filters.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { CheckIcon, PlusCircleIcon } 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 [hasLoaded, setHasLoaded] = 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 && !hasLoaded) { + setHasLoaded(true); + 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/locales/en.json b/packages/vitnode/src/locales/en.json index 7ec497809..7d48e092e 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/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", + "search_role": "Search by role", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -307,6 +310,8 @@ "role": "Role", "usersCount": "Number of users", "createdAt": "Created 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/views/admin/views/core/users/roles/roles-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx index 724ef34a6..b77ea9fff 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx @@ -1,4 +1,4 @@ -import { ShieldIcon } from "lucide-react"; +import { ExternalLink, ShieldIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -6,7 +6,9 @@ import { adminModule } from "@/api/modules/admin/admin.module"; import { DateFormat } from "@/components/date-format"; import { RoleFormat } from "@/components/role-format"; import { DataTable } from "@/components/table/data-table"; +import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; +import { Link } from "@/lib/navigation"; export const RolesAdminView = async ({ searchParams, @@ -42,7 +44,22 @@ export const RolesAdminView = async ({ { id: "usersCount", label: t("usersCount"), - cell: ({ row }) => row.usersCount, + cell: ({ row }) => { + if (row.usersCount === 0) { + return 0; + } + + return ( + + + {row.usersCount} + + + ); + }, }, { id: "createdAt", @@ -65,6 +82,8 @@ export const RolesAdminView = async ({ }, }} pageInfo={data.pageInfo} + search + searchPlaceholder={t("searchPlaceholder")} /> ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/search-roles.action.tsx b/packages/vitnode/src/views/admin/views/core/users/search-roles.action.tsx new file mode 100644 index 000000000..03fae949d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/search-roles.action.tsx @@ -0,0 +1,33 @@ +"use server"; + +import type { FilterOption } from "@/components/table/filters"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { RoleFormat } from "@/components/role-format"; +import { fetcher } from "@/lib/fetcher"; + +export const searchRolesAdmin = 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), + })); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx index b21977acb..92b8c2006 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx @@ -9,6 +9,7 @@ import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; import { UsersAdminActions } from "./actions"; +import { searchRolesAdmin } from "./search-roles.action"; export const UsersAdminView = async ({ searchParams, @@ -72,6 +73,13 @@ export const UsersAdminView = async ({ icon: , }} edges={data.edges} + filters={[ + { + id: "roleId", + label: t("search_role"), + onSearch: searchRolesAdmin, + }, + ]} id="users-table" order={{ columns: ["createdAt", "name"], From f3a88f1c420ebafda7ca117eac196a940a049b92 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 14 Jun 2026 15:57:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(layout):=20=F0=9F=90=9B=20Correct=20typ?= =?UTF-8?q?e=20assertion=20for=20style=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/src/app/[locale]/(docs)/docs/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From 756486357d1deb6c5949486d99dcd4eafa953963 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 14 Jun 2026 18:16:13 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(filters):=20=E2=9C=A8=20Update=20filte?= =?UTF-8?q?r=20labels=20and=20add=20updatedAt=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/locales/@vitnode/core/en.json | 4 +- apps/docs/content/docs/ui/data-table.mdx | 12 ++-- apps/docs/content/docs/ui/toggle-group.mdx | 4 +- apps/docs/content/docs/ui/toggle.mdx | 2 +- apps/docs/content/docs/ui/tooltip.mdx | 6 +- apps/docs/src/examples/toggle-group.tsx | 2 +- apps/docs/src/locales/@vitnode/core/en.json | 4 +- .../modules/admin/roles/routes/list.route.ts | 6 +- .../modules/admin/roles/routes/show.route.ts | 1 + .../modules/admin/users/routes/list.route.ts | 1 + .../vitnode/src/components/table/filters.tsx | 17 ++--- .../vitnode/src/components/ui/sidebar.tsx | 2 +- .../src/components/ui/toggle-group.tsx | 18 ++--- packages/vitnode/src/components/ui/toggle.tsx | 8 +-- .../vitnode/src/components/ui/tooltip.tsx | 65 ++++++++++--------- packages/vitnode/src/locales/en.json | 4 +- .../core/users/roles/roles-admin-view.tsx | 8 +-- .../views/core/users/users-admin-view.tsx | 2 +- .../sign-up/components/password-input.tsx | 50 +++++++------- .../vitnode/src/views/layouts/provider.tsx | 3 +- 20 files changed, 111 insertions(+), 108 deletions(-) diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 7d48e092e..ba4d07844 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -265,7 +265,7 @@ "createdAt": "Created At", "emailNotVerified": "Email Not Verified", "view": "View profile", - "search_role": "Search by role", + "roles": "Roles", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -309,7 +309,7 @@ "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": { diff --git a/apps/docs/content/docs/ui/data-table.mdx b/apps/docs/content/docs/ui/data-table.mdx index 4fa2431ba..bd8d6f2b4 100644 --- a/apps/docs/content/docs/ui/data-table.mdx +++ b/apps/docs/content/docs/ui/data-table.mdx @@ -204,7 +204,8 @@ Pass the `filters` prop to render one or more faceted, multi-select dropdowns ab type: "string", }, label: { - description: "Text shown on the filter trigger and as the search placeholder.", + description: + "Text shown on the filter trigger and as the search placeholder.", required: true, type: "string", }, @@ -282,9 +283,7 @@ 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 => { +export const searchRoles = async (search: string): Promise => { const res = await fetcher(adminModule, { path: "/list", method: "get", @@ -301,10 +300,10 @@ export const searchRoles = async ( const data = await res.json(); - return data.edges.map((role) => ({ + return data.edges.map(role => ({ value: String(role.id), label: , - keywords: role.name.map((item) => item.name), + keywords: role.name.map(item => item.name), })); }; ``` @@ -327,6 +326,7 @@ filters={[ ```ts const roleIds = (query.roleId?.split(",") ?? []) + .filter(Boolean) .map(Number) .filter((id) => !Number.isNaN(id)); 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/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 7d48e092e..ba4d07844 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -265,7 +265,7 @@ "createdAt": "Created At", "emailNotVerified": "Email Not Verified", "view": "View profile", - "search_role": "Search by role", + "roles": "Roles", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -309,7 +309,7 @@ "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": { 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 e82753f44..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 @@ -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,7 @@ 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(), }), }, @@ -102,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) @@ -111,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 6bdb77d45..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 @@ -55,6 +55,7 @@ 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({ diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx index 679663ed9..aecb7decb 100644 --- a/packages/vitnode/src/components/table/filters.tsx +++ b/packages/vitnode/src/components/table/filters.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckIcon, PlusCircleIcon } from "lucide-react"; +import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import React from "react"; @@ -54,7 +54,6 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { const isAsync = Boolean(filter.onSearch); const [asyncOptions, setAsyncOptions] = React.useState([]); const [isSearching, setIsSearching] = React.useState(false); - const [hasLoaded, setHasLoaded] = React.useState(false); const selected = (searchParams.get(filter.id)?.split(",") ?? []).filter( Boolean, @@ -77,8 +76,8 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { const debouncedSearch = useDebouncedCallback(runSearch, 400); const handleOpenChange = (open: boolean) => { - if (open && isAsync && !hasLoaded) { - setHasLoaded(true); + if (open && isAsync) { + setAsyncOptions([]); void runSearch(""); } }; @@ -135,14 +134,10 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { <> {isAsync || selectedStaticOptions.length > 2 ? ( - - {t("selected_count", { count: selected.length })} - + {t("selected_count", { count: selected.length })} ) : ( selectedStaticOptions.map(option => ( - - {option.label} - + {option.label} )) )} @@ -199,7 +194,7 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { className="justify-center text-center" onSelect={() => applySelection([])} > - {t("clear_filters")} + {t("clear_filters")} 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} +