diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 8fa1fee6c..95d925bb5 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -260,7 +260,12 @@ "desc": "Manage users of your application.", "user": "User", "createdAt": "Created At", - "emailNotVerified": "Email Not Verified" + "emailNotVerified": "Email Not Verified", + "searchPlaceholder": "Search users by email or username...", + "noResults": { + "title": "No users found", + "description": "Try adjusting your search criteria." + } } }, "debug": { diff --git a/apps/docs/content/docs/dev/database/pagination.mdx b/apps/docs/content/docs/dev/database/pagination.mdx index b7f86a41f..041c3b179 100644 --- a/apps/docs/content/docs/dev/database/pagination.mdx +++ b/apps/docs/content/docs/dev/database/pagination.mdx @@ -17,7 +17,7 @@ import { buildRoute } from "@/api/lib/route"; import { withPagination, zodPaginationPageInfo, - zodPaginationQuery + zodPaginationQuery, } from "@/api/lib/with-pagination"; import { CONFIG_PLUGIN } from "@/config"; import { core_cron } from "@/database/cron"; @@ -31,8 +31,8 @@ export const getCronsRoute = buildRoute({ request: { query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["lastRun"]).optional() - }) + orderBy: z.enum(["lastRun"]).optional(), + }), }, responses: { 200: { @@ -47,36 +47,42 @@ export const getCronsRoute = buildRoute({ description: z.string().nullable(), pluginId: z.string(), module: z.string(), - lastRun: z.date().nullable() - }) + lastRun: z.date().nullable(), + }), ), - pageInfo: zodPaginationPageInfo - }) - } + pageInfo: zodPaginationPageInfo, + }), + }, }, - description: "List of cron jobs" - } - } + description: "List of cron jobs", + }, + }, }, - handler: async (c) => { + handler: async c => { const query = c.req.valid("query"); const data = await withPagination({ params: { - query + query, }, c, primaryCursor: core_cron.id, query: async ({ limit, where, orderBy }) => - await c.get("db").select().from(core_cron).where(where).orderBy(orderBy).limit(limit), + await c + .get("db") + .select() + .from(core_cron) + .where(where) + .orderBy(orderBy) + .limit(limit), table: core_cron, orderBy: { column: query.orderBy ? core_cron[query.orderBy] : core_cron.lastRun, - order: query.order ?? "desc" - } + order: query.order ?? "desc", + }, }); return c.json(data); - } + }, }); ``` @@ -104,7 +110,7 @@ VitNode provides pre-defined Zod schemas for pagination: const zodPaginationQuery = z.object({ cursor: z.string().optional(), first: z.string().transform(Number).optional(), - last: z.string().transform(Number).optional() + last: z.string().transform(Number).optional(), }); // Example of zodPaginationPageInfo @@ -112,7 +118,7 @@ const zodPaginationPageInfo = z.object({ startCursor: z.string().nullable(), endCursor: z.string().nullable(), hasNextPage: z.boolean(), - hasPreviousPage: z.boolean() + hasPreviousPage: z.boolean(), }); ``` @@ -131,9 +137,9 @@ const res = await fetcher(userModule, { method: "get", module: "user", args: { - query + query, }, - withPagination: true // Important flag for pagination + withPagination: true, // Important flag for pagination }); ``` @@ -157,11 +163,14 @@ Here's a complete example showing how to implement pagination in a Next.js page: ```tsx import { middlewareModule } from "@/api/modules/middleware/middleware.module"; -import { DataTable, SearchParamsDataTable } from "@vitnode/core/components/table/data-table"; +import { + DataTable, + SearchParamsDataTable, +} from "@vitnode/core/components/table/data-table"; import { fetcher } from "@vitnode/core/lib/fetcher"; export const UsersAdminView = async ({ - searchParams + searchParams, }: { searchParams: Promise; }) => { @@ -171,27 +180,28 @@ export const UsersAdminView = async ({ method: "get", module: "middleware", args: { - query + query, }, - withPagination: true + withPagination: true, }); const data = await res.json(); return ( @@ -224,9 +234,10 @@ The pagination object returned from the API has the following structure: ``` - The DataTable component automatically handles pagination controls when provided with the correct - `pageInfo` object, allowing users to navigate through data with next/previous buttons and showing - the current page information. + The DataTable component automatically handles pagination controls when + provided with the correct `pageInfo` object, allowing users to navigate + through data with next/previous buttons and showing the current page + information. ## Advanced Usage @@ -240,7 +251,7 @@ const query = c.req.valid("query"); const data = await withPagination({ params: { query, - additionalWhere: eq(users.isActive, true) // Only active users + additionalWhere: eq(users.isActive, true), // Only active users }, primaryCursor: users.id, query: async ({ limit, where, orderBy }) => @@ -253,8 +264,8 @@ const data = await withPagination({ table: users, orderBy: { column: query.orderBy ? users[query.orderBy] : users.createdAt, - order: query.order ?? "desc" - } + order: query.order ?? "desc", + }, }); ``` diff --git a/apps/docs/content/docs/dev/database/search.mdx b/apps/docs/content/docs/dev/database/search.mdx new file mode 100644 index 000000000..dccdd8173 --- /dev/null +++ b/apps/docs/content/docs/dev/database/search.mdx @@ -0,0 +1,176 @@ +--- +title: Search +description: How to add type-safe, full-text-like search to paginated VitNode routes. +--- + +VitNode's `withPagination` helper accepts an optional `search` array that lets you search across one or more columns of a table. It plugs directly into the cursor-based [Pagination](/docs/dev/database/pagination) system and the [DataTable](/docs/ui/data-table) component, so the same route powers both paging and searching. + +## Backend Implementation + +Pass a `search` array of columns to `withPagination`. The term is read from the `search` query parameter and matched against every column you list using a case-insensitive `ILIKE`, combined with `OR`. + +### Basic Usage + +```ts +import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +export const listUsersAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Get list of all users", + path: "/list", + request: { + query: zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["name", "createdAt"]).optional(), + search: z.string().optional(), // [!code ++] + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array( + z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + createdAt: z.date(), + }), + ), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: "List of users", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + const data = await withPagination({ + params: { + query, + }, + search: [core_users.name, core_users.email], // [!code ++] + primaryCursor: core_users.id, + query: async ({ limit, where, orderBy }) => + await c + .get("db") + .select({ + id: core_users.id, + name: core_users.name, + email: core_users.email, + createdAt: core_users.createdAt, + }) + .from(core_users) + .where(where) + .orderBy(orderBy) + .limit(limit), + table: core_users, + orderBy: { + column: query.orderBy + ? core_users[query.orderBy] + : core_users.createdAt, + order: query.order ?? "desc", + }, + c, + }); + + return c.json(data); + }, +}); +``` + +There are only two additions compared to a plain paginated route: + +1. Add `search: z.string().optional()` to the request `query` schema so the term is validated and forwarded. +2. Pass the `search` array of columns to `withPagination`. + +### How It Works + +When a `search` term is present, `withPagination` builds the following condition and `AND`-combines it with any existing `where` clause: + +```sql +WHERE (name ILIKE '%term%' OR email ILIKE '%term%') +``` + +A few details worth knowing: + +- The term is **trimmed**; an empty or whitespace-only value is ignored and no search filter is applied. +- Matching is **case-insensitive** (`ILIKE`) and substring-based (`%term%`). +- The filter is applied to the `totalCount` as well, so the pagination info reflects the filtered result set. +- If you don't pass `search`, the route behaves exactly like a normal paginated route — the `search` query parameter is simply ignored. + + + The term is interpolated into the `ILIKE` pattern, so `%` and `_` entered by + the user act as SQL wildcards. The value is still parameterized, so this is + not an injection risk — but escape those characters yourself if you need + literal matching. + + +### Search Parameters + +| Parameter | Type | Description | +| --------- | ---------- | ---------------------------------------------------------------------------- | +| `search` | `Column[]` | Columns to search across. Must belong to the `table` passed to the function. | + +The term itself is read from `params.query.search`, which comes from the `search` query parameter on the request. + +## Frontend Implementation + +On the frontend, enable the search input by setting the `search` prop on the [DataTable](/docs/ui/data-table) component. You can optionally customize the placeholder. + +```tsx + +``` + +The input is debounced and writes the term to the `?search=` query parameter, then reloads the page. + +### Forwarding the Query + +The `search` term lives in the URL, so it arrives as part of the awaited `searchParams`. You **must** forward that `query` object to the fetcher's `args` for the term to reach the API — without it, the search parameter never gets sent. + +```tsx +const query = await searchParams; +const res = await fetcher(userModule, { + path: "/users", + method: "get", + module: "user", + args: { + // Forwards `search`, `cursor`, `order`, ... + query, // [!code highlight] + }, + withPagination: true, +}); +``` + +Since `query` carries the pagination cursors and ordering as well, the same object powers searching, sorting, and paging at once. diff --git a/apps/docs/content/docs/dev/websocket.mdx b/apps/docs/content/docs/dev/websocket.mdx index d2146f5ab..19baaf056 100644 --- a/apps/docs/content/docs/dev/websocket.mdx +++ b/apps/docs/content/docs/dev/websocket.mdx @@ -57,6 +57,22 @@ Install the `ws` package: pnpm add ws && pnpm add -D @types/ws ``` + + +```bash tab="bun" +bun i ws && bun i @types/ws -D +``` + +```bash tab="pnpm" +pnpm i ws && pnpm i @types/ws -D +``` + +```bash tab="npm" +npm i ws && npm i @types/ws -D +``` + + + ```ts title="src/index.ts" import { serve } from "@hono/node-server"; import { WebSocketServer } from "ws"; diff --git a/apps/docs/content/docs/ui/data-table.mdx b/apps/docs/content/docs/ui/data-table.mdx index 2408c318c..d040e9a74 100644 --- a/apps/docs/content/docs/ui/data-table.mdx +++ b/apps/docs/content/docs/ui/data-table.mdx @@ -10,35 +10,36 @@ description: A table component with sorting, filtering, and pagination compatibl ## Usage ```ts -import { DataTable } from '@vitnode/core/components/table/data-table'; +import { DataTable } from "@vitnode/core/components/table/data-table"; ``` ```tsx ``` -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; @@ -84,10 +96,11 @@ You can customize how each cell is rendered using the `cell` property. The rende ```tsx ( // [!code ++] @@ -99,14 +112,14 @@ You can customize how each cell is rendered using the `cell` property. The rende // [!code ++] ), }, - { id: 'createdAt', label: 'Created at' }, + { id: "createdAt", label: "Created at" }, ]} edges={data.edges} pageInfo={data.pageInfo} order={{ - columns: ['createdAt', 'id'], + columns: ["createdAt", "id"], defaultOrder: { - order: 'desc', + order: "desc", }, }} /> @@ -127,6 +140,39 @@ order={{ }} ``` +## Search + +Set the `search` prop to `true` to render a search input above the table. You can optionally customize the placeholder with `searchPlaceholder`. + +```tsx + +``` + +The input writes the term to the `?search=` query parameter (debounced) and reloads the page, so it works out of the box with server-side data fetching. The columns that are actually searched are defined on the API route. + + + Enabling `search` only renders the input. You must also tell the backend which + columns to search across — see the [Search](/docs/dev/database/search) guide. + + ## Complete Example Here's a complete example showing how to use the `DataTable` component in a page: @@ -135,9 +181,9 @@ Here's a complete example showing how to use the `DataTable` component in a page import { DataTable, SearchParamsDataTable, -} from '@vitnode/core/components/table/data-table'; -import { userModule } from '@/api/modules/user/user.module'; -import { fetcher } from '@vitnode/core/lib/fetcher'; +} from "@vitnode/core/components/table/data-table"; +import { userModule } from "@/api/modules/user/user.module"; +import { fetcher } from "@vitnode/core/lib/fetcher"; export const UsersView = async ({ searchParams, @@ -146,9 +192,9 @@ export const UsersView = async ({ }) => { const query = await searchParams; const res = await fetcher(userModule, { - path: '/users', - method: 'get', - module: 'user', + path: "/users", + method: "get", + module: "user", args: { query, }, @@ -158,27 +204,28 @@ export const UsersView = async ({ return ( ( {row.username} ), }, - { id: 'email', label: 'Email' }, - { id: 'createdAt', label: 'Created at' }, + { id: "email", label: "Email" }, + { id: "createdAt", label: "Created at" }, ]} edges={data.edges} order={{ - columns: ['id', 'username', 'email', 'createdAt'], + columns: ["id", "username", "email", "createdAt"], defaultOrder: { - column: 'createdAt', - order: 'desc', + column: "createdAt", + order: "desc", }, }} pageInfo={data.pageInfo} diff --git a/apps/docs/src/examples/data-table.tsx b/apps/docs/src/examples/data-table.tsx index 35e2b98a1..387dd84b1 100644 --- a/apps/docs/src/examples/data-table.tsx +++ b/apps/docs/src/examples/data-table.tsx @@ -40,6 +40,7 @@ export default function DataTableExample() { status: "Inactive", }, ]} + id="users-table" order={{ defaultOrder: { column: "name", diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 8fa1fee6c..95d925bb5 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -260,7 +260,12 @@ "desc": "Manage users of your application.", "user": "User", "createdAt": "Created At", - "emailNotVerified": "Email Not Verified" + "emailNotVerified": "Email Not Verified", + "searchPlaceholder": "Search users by email or username...", + "noResults": { + "title": "No users found", + "description": "Try adjusting your search criteria." + } } }, "debug": { diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts index a40fc100f..5a0964d8f 100644 --- a/packages/vitnode/src/api/lib/with-pagination.ts +++ b/packages/vitnode/src/api/lib/with-pagination.ts @@ -8,7 +8,7 @@ import type { import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; -import { and, asc, count, desc, gt, lt } from "drizzle-orm"; +import { and, asc, count, desc, gt, ilike, lt, or } from "drizzle-orm"; function parsePaginationParams(params: { query: { cursor?: string; first?: string; last?: string }; @@ -69,6 +69,16 @@ function buildWhereWithCursor< return baseWhere ? and(baseWhere, cursorWhere) : cursorWhere; } +function buildSearchWhere( + search: PgColumn[] | undefined, + term: string | undefined, +): SQL | undefined { + const trimmed = term?.trim(); + if (!search?.length || !trimmed) return undefined; + + return or(...search.map(column => ilike(column, `%${trimmed}%`))); +} + async function fetchTotalCount( c: Context, table: PgTable, @@ -91,6 +101,7 @@ export async function withPagination< query, table, params, + search, where: whereFromParams, primaryCursor, orderBy: orderByFromParams, @@ -106,6 +117,7 @@ export async function withPagination< cursor?: string; first?: string; last?: string; + search?: string; }; }; primaryCursor: PgColumn; @@ -114,6 +126,7 @@ export async function withPagination< orderBy: SQL; where: SQL | undefined; }) => Promise; + search?: T["columns"][keyof T["columns"]][]; table: Omit, "enableRLS">; where?: SQL; }): Promise<{ @@ -133,8 +146,14 @@ export async function withPagination< const orderFn = getOrderFn(isForward, orderByFromParams.order); const orderBy: SQL = orderFn(table[orderByFromParams.column.name]); + const searchWhere = buildSearchWhere(search, params.query.search); + const baseWhere = + whereFromParams && searchWhere + ? and(whereFromParams, searchWhere) + : (whereFromParams ?? searchWhere); + const where = buildWhereWithCursor( - whereFromParams, + baseWhere, cursor, isForward, orderByFromParams.order, @@ -142,7 +161,7 @@ export async function withPagination< primaryCursor, ); - const totalCount = await fetchTotalCount(c, table, whereFromParams); + const totalCount = await fetchTotalCount(c, table, baseWhere); const limit = (first ?? last ?? 50) + 1; const edges = await query({ limit, where, orderBy }); 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 c431acc57..936c6609a 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 @@ -19,6 +19,7 @@ export const listUsersAdminRoute = buildRoute({ query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(["name", "createdAt"]).optional(), + search: z.string().optional(), }), }, responses: { @@ -55,6 +56,7 @@ export const listUsersAdminRoute = buildRoute({ params: { query, }, + search: [core_users.name, core_users.email], 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 8deed95cb..ecab2b02b 100644 --- a/packages/vitnode/src/components/table/content.tsx +++ b/packages/vitnode/src/components/table/content.tsx @@ -14,19 +14,24 @@ import { } from "../ui/table"; import { OrderTableHeadDataTable } from "./order-table-head"; import { PaginationDataTable } from "./pagination"; +import { SearchDataTable } from "./search"; export function ContentDataTable({ columns, edges, pageInfo, order, - customNotFoundComponent, + customNoResults, + search, + searchPlaceholder, ...props }: React.ComponentProps>) { const t = useTranslations("core.global"); return (
+ {search && } +
@@ -80,20 +85,19 @@ export function ContentDataTable({ className="mx-auto max-w-sm p-4 text-center whitespace-normal sm:px-10 sm:py-12" colSpan={columns.length} > - {customNotFoundComponent ?? ( -
- +
+ {customNoResults?.icon ?? } -
-

- {t("no_results.title")} -

-

- {t("no_results.desc")} -

-
+
+

+ {customNoResults?.title ?? t("no_results.title")} +

+

+ {customNoResults?.description ?? t("no_results.desc")} +

+ {customNoResults?.footer}
- )} +
)} diff --git a/packages/vitnode/src/components/table/data-table.tsx b/packages/vitnode/src/components/table/data-table.tsx index f2bdb4b1d..5fdf549b1 100644 --- a/packages/vitnode/src/components/table/data-table.tsx +++ b/packages/vitnode/src/components/table/data-table.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { PaginationDataTable } from "./pagination"; +import type { SearchDataTable } from "./search"; import { ErrorView } from "../../views/error/error-view"; import { Skeleton } from "../ui/skeleton"; @@ -84,15 +85,22 @@ export const DataTableSkeleton = ({ columns }: { columns: number }) => { export function DataTable( props: Omit, "columns"> & - React.ComponentProps & { + React.ComponentProps & + React.ComponentProps & { columns: { cell?: (data: { allData: T[]; row: T }) => React.ReactNode; className?: string; id: "actions" | keyof T; label: string; }[]; - customNotFoundComponent?: React.ReactNode; + customNoResults?: { + description?: string; + footer?: React.ReactNode; + icon?: React.ReactNode; + title?: string; + }; edges: T[]; + id: string; order: { columns?: (keyof T)[]; defaultOrder: { @@ -100,6 +108,7 @@ export function DataTable( order: "asc" | "desc"; }; }; + search?: boolean; }, ) { if (!(props.edges && props.pageInfo)) { diff --git a/packages/vitnode/src/components/table/search.tsx b/packages/vitnode/src/components/table/search.tsx new file mode 100644 index 000000000..d537dc214 --- /dev/null +++ b/packages/vitnode/src/components/table/search.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Search } 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 { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "../ui/input-group"; +import { Spinner } from "../ui/spinner"; + +export function SearchDataTable({ + searchPlaceholder, +}: { + searchPlaceholder?: string; +}) { + const t = useTranslations("core.global"); + const searchParams = useSearchParams(); + const searchValue = searchParams.get("search") ?? ""; + const [value, setValue] = React.useState(searchValue); + const [prevSearchValue, setPrevSearchValue] = React.useState(searchValue); + const [isPending, startTransition] = React.useTransition(); + const pathname = usePathname(); + const { push } = useRouter(); + + if (searchValue !== prevSearchValue) { + setPrevSearchValue(searchValue); + setValue(searchValue); + } + + const handleChangeSearch = useDebouncedCallback((value: string) => { + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()); + + if (value.length >= 3) { + params.set("search", value); + } else { + params.delete("search"); + } + + push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }, 500); + + return ( + + { + setValue(e.target.value); + handleChangeSearch(e.target.value); + }} + placeholder={searchPlaceholder ?? t("search_placeholder")} + type="search" + value={value} + /> + {isPending ? : } + + ); +} diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 8fa1fee6c..95d925bb5 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -260,7 +260,12 @@ "desc": "Manage users of your application.", "user": "User", "createdAt": "Created At", - "emailNotVerified": "Email Not Verified" + "emailNotVerified": "Email Not Verified", + "searchPlaceholder": "Search users by email or username...", + "noResults": { + "title": "No users found", + "description": "Try adjusting your search criteria." + } } }, "debug": { diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx index aea36684d..6b9a78ef8 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx @@ -82,6 +82,7 @@ export const CronTableView = async ({ }, ]} edges={data.edges} + id="cron-table" order={{ columns: ["lastRun", "createdAt", "nextRun"], defaultOrder: { diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-view.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-view.tsx index 01928c461..8adbb4169 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-view.tsx @@ -82,6 +82,7 @@ export const SystemLogsView = async ({ }, ]} edges={data.edges.map(edge => ({ ...edge }))} + id="system-logs-table" order={{ columns: ["createdAt", "pluginId", "type"], defaultOrder: { 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 1e436ad88..3f5de9da1 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 @@ -1,4 +1,4 @@ -import { MailIcon } from "lucide-react"; +import { MailIcon, UserSearchIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { adminModule } from "@/api/modules/admin/admin.module"; @@ -58,7 +58,13 @@ export const UsersAdminView = async ({ cell: ({ row }) => , }, ]} + customNoResults={{ + title: t("noResults.title"), + description: t("noResults.description"), + icon: , + }} edges={data.edges} + id="users-table" order={{ columns: ["createdAt", "name"], defaultOrder: { @@ -67,6 +73,8 @@ export const UsersAdminView = async ({ }, }} pageInfo={data.pageInfo} + search + searchPlaceholder={t("searchPlaceholder")} /> ); }; diff --git a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx b/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx index 7217ebf46..bfab9ac31 100644 --- a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx +++ b/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx @@ -60,6 +60,7 @@ export const CategoriesAdminView = async ({ }, ]} edges={data.edges.map(edge => ({ ...edge }))} + id="categories-table" order={{ columns: ["createdAt", "updatedAt"], defaultOrder: { diff --git a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx b/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx index 4d7841673..b0867998f 100644 --- a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx +++ b/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx @@ -66,6 +66,7 @@ export const PostsAdminView = async ({ }, ]} edges={data.edges.map(edge => ({ ...edge }))} + id="posts-table" order={{ columns: ["createdAt", "updatedAt"], defaultOrder: {