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
6 changes: 6 additions & 0 deletions apps/docs/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,12 @@
"queue": "Zadania w kolejce",
"search": "Szukaj"
}
},
"search": {
"title": "Wyszukiwanie w panelu",
"desc": "Szukaj stron panelu i użytkowników lub przejdź od razu do profilu.",
"placeholder": "Szukaj stron i użytkowników...",
"hint": "Wpisz co najmniej {count} znaki, aby wyszukać użytkowników."
}
},
"advanced": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const listUsersAdminRoute = buildRoute({
params: {
query,
},
search: [core_users.name, core_users.email],
search: [core_users.name, core_users.email, core_users.nameCode],
where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,
primaryCursor: core_users.id,
query: async ({ limit, where, orderBy }) =>
Expand Down
12 changes: 6 additions & 6 deletions packages/vitnode/src/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,17 @@ function CommandDialog({
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
"top-1/3 max-h-[calc(66.667vh-2rem)] translate-y-0 grid-rows-[minmax(0,1fr)] overflow-hidden rounded-xl! p-0 sm:max-h-[calc(66.667vh-5rem)]",
className,
)}
showCloseButton={showCloseButton}
>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{children}
</DialogContent>
</Dialog>
Expand Down Expand Up @@ -92,7 +92,7 @@ function CommandList({
return (
<CommandPrimitive.List
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
"no-scrollbar max-h-72 min-h-0 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none in-data-[slot=dialog-content]:pb-2",
className,
)}
data-slot="command-list"
Expand Down
6 changes: 6 additions & 0 deletions packages/vitnode/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,12 @@
"queue": "Queue Tasks",
"search": "Search"
}
},
"search": {
"title": "Admin search",
"desc": "Search admin pages and users, or jump straight to a member.",
"placeholder": "Search pages and users...",
"hint": "Type at least {count} characters to search users."
}
},
"advanced": {
Expand Down
27 changes: 16 additions & 11 deletions packages/vitnode/src/views/admin/layouts/admin-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { cookies } from "next/headers";

import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider";
import { ThemeSwitcher } from "@/components/switchers/themes/theme-switcher";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
Expand All @@ -13,7 +12,9 @@ import { getSessionAdminApi } from "@/lib/api/get-session-admin-api";
import type { VitNodeConfig } from "../../../vitnode.config";

import { I18nProvider } from "../../../components/i18n-provider";
import { LanguageSwitcher } from "../../../components/switchers/langs/language-switcher";
import { getSearchNavItems } from "./search/get-search-nav-items";
import { SearchAdmin } from "./search/search";
Comment thread
aXenDeveloper marked this conversation as resolved.
import { getAdminNav } from "./sidebar/nav/get-admin-nav";
import { SidebarAdmin } from "./sidebar/sidebar";
import { UserBarAdmin } from "./user-bar/user-bar";

Expand All @@ -37,26 +38,30 @@ export const AdminLayout = async ({
cookieStore.get("vitnode_admin_sidebar_state")?.value === "true";
if (!session) return null;

const nav = await getAdminNav({ vitNodeConfig });
const searchItems = await getSearchNavItems({ nav, vitNodeConfig });

return (
<I18nProvider namespaces={["admin.global"]}>
<AdminStaffPermissionProvider value={session.permissions}>
<SidebarProvider defaultOpen={defaultOpen}>
<SidebarAdmin vitNodeConfig={vitNodeConfig} />
<SidebarAdmin nav={nav} vitNodeConfig={vitNodeConfig} />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<SidebarTrigger className="-ml-1 shrink-0" />
{breadcrumb != null && (
<>
<Separator className="mr-1 h-4" orientation="vertical" />
{breadcrumb}
<Separator
className="mr-1 h-4 shrink-0"
orientation="vertical"
/>
<div className="min-w-0 flex-1">{breadcrumb}</div>
</>
)}

<div className="ml-auto flex items-center justify-center gap-2 px-2">
{vitNodeConfig.i18n.locales.length > 1 && (
<LanguageSwitcher locales={vitNodeConfig.i18n.locales} />
)}
<ThemeSwitcher />
<div className="ml-auto flex shrink-0 items-center justify-center gap-2 px-2">
<SearchAdmin items={searchItems} />

<UserBarAdmin user={session.user} />
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ export const BreadcrumbAdmin = async ({
crumbs[crumbs.length - 1].label = overrideLastLabel;
}

return <BreadcrumbRender crumbs={crumbs} />;
return <BreadcrumbRender crumbs={crumbs} scrollable />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { type BreadcrumbCrumb, humanize } from "@/views/breadcrumb/crumb";

import type { NavAdminParent } from "../sidebar/nav/get-admin-nav";

export type { BreadcrumbCrumb };
import { normalizeUrl } from "../normalize-url";

const normalizeUrl = (url: string): string =>
url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url;
export type { BreadcrumbCrumb };

const flattenNav = (nav: NavAdminParent[]): Map<string, string> => {
const labels = new Map<string, string>();
Expand Down
2 changes: 2 additions & 0 deletions packages/vitnode/src/views/admin/layouts/normalize-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const normalizeUrl = (url: string): string =>
url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url;
3 changes: 3 additions & 0 deletions packages/vitnode/src/views/admin/layouts/search/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const MAX_SEARCH_RESULTS = 10;
export const MIN_USERS_QUERY_LENGTH = 3;
export const USERS_DEBOUNCE_MS = 400;
164 changes: 164 additions & 0 deletions packages/vitnode/src/views/admin/layouts/search/flatten-nav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";

import type { NavAdminParent } from "../sidebar/nav/get-admin-nav";

import {
buildSearchText,
flattenAdminNav,
matchesAdminNavItem,
} from "./flatten-nav";

const nav: NavAdminParent[] = [
{
id: "core",
title: "Core",
items: [
{ href: "/admin/core/", title: "Dashboard" },
{
href: "/admin/core/system",
title: "System",
items: [
{ href: "/admin/core/system/integrations", title: "Integrations" },
{ href: "/admin/core/system/files", title: "Files" },
],
},
{
href: "/admin/core/users",
title: "Users",
items: [
{ href: "/admin/core/users", title: "User List" },
{ href: "/admin/core/users/roles", title: "Roles" },
],
},
],
},
{
id: "@vitnode/blog",
title: "Blog",
items: [{ href: "/admin/blog/posts", title: "Posts" }],
},
];

describe("flattenAdminNav", () => {
it("emits only leaves, never a parent that has children", () => {
const hrefs = flattenAdminNav(nav).map(item => item.href);

// `/admin/core/system` has no page of its own - only its children do.
expect(hrefs).not.toContain("/admin/core/system");
expect(hrefs).toStrictEqual([
"/admin/core/",
"/admin/core/system/integrations",
"/admin/core/system/files",
"/admin/core/users",
"/admin/core/users/roles",
"/admin/blog/posts",
]);
});

it("keeps a sub-item whose href repeats its parent's exactly once", () => {
const users = flattenAdminNav(nav).filter(
item => item.href === "/admin/core/users",
);

expect(users).toHaveLength(1);
// The child wins, so the row reads "Users › User List".
expect(users[0].title).toBe("User List");
expect(users[0].parentTitle).toBe("Users");
});

it("dedupes on the normalized href, so a trailing slash is not a new row", () => {
const items = flattenAdminNav([
{
id: "core",
title: "Core",
items: [
{ href: "/admin/core/thing", title: "First" },
{ href: "/admin/core/thing/", title: "Second" },
],
},
]);

expect(items).toHaveLength(1);
expect(items[0].title).toBe("First");
});

it("carries the group title and inherits the parent icon", () => {
const icon = "icon-node";
const [item] = flattenAdminNav([
{
id: "core",
title: "Core",
items: [
{
href: "/admin/core/users",
icon,
title: "Users",
items: [{ href: "/admin/core/users/roles", title: "Roles" }],
},
],
},
]);

expect(item.groupTitle).toBe("Core");
expect(item.icon).toBe(icon);
});

it("builds searchText from title, parent and group, lower-cased", () => {
const roles = flattenAdminNav(nav).find(
item => item.href === "/admin/core/users/roles",
);

expect(roles?.searchText).toBe("roles users core");
});
});

describe("buildSearchText", () => {
it("drops empty parts and duplicates", () => {
expect(buildSearchText(["Roles", undefined, "", "roles", "Core"])).toBe(
"roles core",
);
});
});

describe("matchesAdminNavItem", () => {
const [item] = flattenAdminNav(nav).filter(
entry => entry.href === "/admin/core/users",
);

it("matches every item on an empty query", () => {
expect(matchesAdminNavItem(item, "")).toBe(true);
expect(matchesAdminNavItem(item, " ")).toBe(true);
});

it("matches on a partial, case-insensitive fragment", () => {
expect(matchesAdminNavItem(item, "LIS")).toBe(true);
});

it("matches on the parent or group title", () => {
expect(matchesAdminNavItem(item, "users")).toBe(true);
expect(matchesAdminNavItem(item, "core")).toBe(true);
});

it("requires every token, in any order", () => {
expect(matchesAdminNavItem(item, "user list")).toBe(true);
expect(matchesAdminNavItem(item, "list user")).toBe(true);
expect(matchesAdminNavItem(item, "list nope")).toBe(false);
});

it("does not match across a token boundary", () => {
expect(matchesAdminNavItem(item, "userlist")).toBe(false);
});

it("finds a row by another locale's title once searchText carries it", () => {
// What `getSearchNavItems` produces for a Polish request: the row displays
// "Role" but is still findable by the English "Roles".
const polish = {
...item,
searchText: buildSearchText(["Role", "Użytkownicy", "Roles", "Users"]),
title: "Role",
};

expect(matchesAdminNavItem(polish, "roles")).toBe(true);
expect(matchesAdminNavItem(polish, "użytkownicy")).toBe(true);
});
});
Loading