Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/api/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
79 changes: 45 additions & 34 deletions apps/docs/content/docs/dev/database/pagination.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: {
Expand All @@ -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);
}
},
});
```

Expand Down Expand Up @@ -104,15 +110,15 @@ 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
const zodPaginationPageInfo = z.object({
startCursor: z.string().nullable(),
endCursor: z.string().nullable(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean()
hasPreviousPage: z.boolean(),
});
```

Expand All @@ -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
});
```

Expand All @@ -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<SearchParamsDataTable>;
}) => {
Expand All @@ -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 (
<DataTable
id="users-table"
columns={[
{ id: "id", label: "ID" },
{ id: "username", label: "Username" },
{ id: "email", label: "Email" },
{ id: "createdAt", label: "Created at" }
{ id: "createdAt", label: "Created at" },
]}
edges={data.edges}
order={{
columns: ["id", "username", "email", "createdAt"],
defaultOrder: {
column: "createdAt",
order: "desc"
}
order: "desc",
},
}}
pageInfo={data.pageInfo}
/>
Expand Down Expand Up @@ -224,9 +234,10 @@ The pagination object returned from the API has the following structure:
```

<Callout title="Pagination Controls">
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.
</Callout>

## Advanced Usage
Expand All @@ -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 }) =>
Expand All @@ -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",
},
});
```

Expand Down
176 changes: 176 additions & 0 deletions apps/docs/content/docs/dev/database/search.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="warn" title="Wildcards">
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.
</Callout>

### 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
<DataTable
id="users-table"
// [!code ++]
search
// [!code ++]
searchPlaceholder="Search users..."
columns={[
{ id: "name", label: "Name" },
{ id: "email", label: "Email" },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: "name",
order: "asc",
},
}}
/>
```

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.
Loading
Loading