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 @@ -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}",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down
163 changes: 163 additions & 0 deletions apps/docs/content/docs/ui/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}[]>;
}[]`,
},
}}
/>

Expand Down Expand Up @@ -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.
</Callout>

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

<TypeTable
type={{
id: {
description: "The URL query parameter this filter reads and writes.",
required: true,
type: "string",
},
label: {
description:
"Text shown on the filter trigger and as the search placeholder.",
required: true,
type: "string",
},
options: {
description:
"Static options, filtered on the client. `label` can be any node; provide `keywords` so typing can search a node-based label.",
required: false,
type: `{
value: string;
label: ReactNode;
keywords?: string[];
}[]`,
},
onSearch: {
description:
"Async source called (debounced) as the user types. The API is expected to return results already filtered and capped (e.g. 20 per request).",
required: false,
type: "(search: string) => Promise<Option[]>",
},
}}
/>

### 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
<DataTable
id="users-table"
// [!code ++]
filters={[
// [!code ++]
{
// [!code ++]
id: "status",
// [!code ++]
label: "Status",
// [!code ++]
options: [
// [!code ++]
{ value: "active", label: "Active" },
// [!code ++]
{ value: "banned", label: "Banned" },
// [!code ++]
],
// [!code ++]
},
// [!code ++]
]}
columns={[
{ id: "name", label: "Name" },
{ id: "email", label: "Email" },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: "name",
order: "asc",
},
}}
/>
```

### 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<FilterOption[]> => {
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: <RoleFormat role={role} />,
keywords: role.name.map(item => item.name),
}));
};
```

```tsx
filters={[
{
id: "roleId",
label: "Group",
// [!code ++]
onSearch: searchRoles,
},
]}
```

<Callout type="info" title="Configure the API route">
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));
Comment thread
aXenDeveloper marked this conversation as resolved.

// pass to withPagination:
where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,
```

</Callout>

## Complete Example

Here's a complete example showing how to use the `DataTable` component in a page:
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/ui/toggle-group.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
```

```tsx
<ToggleGroup variant="outline" type="multiple">
<ToggleGroup variant="outline" multiple>
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<Bold className="size-4" />
</ToggleGroupItem>
Expand All @@ -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)
2 changes: 1 addition & 1 deletion apps/docs/content/docs/ui/toggle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 2 additions & 4 deletions apps/docs/content/docs/ui/tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@vitnode/core/component

```tsx
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Hover</Button>
</TooltipTrigger>
<TooltipTrigger render={<Button variant="outline">Hover</Button>} />
<TooltipContent>
<p>Add to library</p>
</TooltipContent>
Expand All @@ -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)
2 changes: 1 addition & 1 deletion apps/docs/src/app/[locale]/(docs)/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function Layout({ children }: { children: ReactNode }) {
{
color,
"--tab-color": color,
} as object
} as React.CSSProperties
}
>
{node.icon}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/examples/toggle-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Bold, Italic, Underline } from "lucide-react";

export default function ToggleGroupDemo() {
return (
<ToggleGroup type="multiple" variant="outline">
<ToggleGroup multiple variant="outline">
<ToggleGroupItem aria-label="Toggle bold" value="bold">
<Bold className="size-4" />
</ToggleGroupItem>
Expand Down
7 changes: 6 additions & 1 deletion apps/docs/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down
29 changes: 26 additions & 3 deletions packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -30,6 +30,7 @@ const rolesAdminListSchema = z.object({
root: z.boolean(),
guest: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
usersCount: z.number(),
}),
),
Expand All @@ -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: {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const roleAdminSchema = z.object({
root: z.boolean(),
guest: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
});

export const showRoleAdminRoute = buildRoute({
Expand Down
Loading
Loading