diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json index 5bdefd0c2..062a25e0e 100644 --- a/apps/docs/src/locales/@vitnode/core/pl.json +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -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": { 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 2bde26cce..bd04bd9dc 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 @@ -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 }) => diff --git a/packages/vitnode/src/components/ui/command.tsx b/packages/vitnode/src/components/ui/command.tsx index cce4454d3..3961abed5 100644 --- a/packages/vitnode/src/components/ui/command.tsx +++ b/packages/vitnode/src/components/ui/command.tsx @@ -45,17 +45,17 @@ function CommandDialog({ }) { return ( - - {title} - {description} - + + {title} + {description} + {children} @@ -92,7 +92,7 @@ function CommandList({ return ( - +
- + {breadcrumb != null && ( <> - - {breadcrumb} + +
{breadcrumb}
)} -
- {vitNodeConfig.i18n.locales.length > 1 && ( - - )} - +
+ +
diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx index 3eee844dc..453e106ab 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx @@ -51,5 +51,5 @@ export const BreadcrumbAdmin = async ({ crumbs[crumbs.length - 1].label = overrideLastLabel; } - return ; + return ; }; diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts index 651ab82b3..e20d894e9 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts @@ -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 => { const labels = new Map(); diff --git a/packages/vitnode/src/views/admin/layouts/normalize-url.ts b/packages/vitnode/src/views/admin/layouts/normalize-url.ts new file mode 100644 index 000000000..41992da52 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/normalize-url.ts @@ -0,0 +1,2 @@ +export const normalizeUrl = (url: string): string => + url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url; diff --git a/packages/vitnode/src/views/admin/layouts/search/constants.ts b/packages/vitnode/src/views/admin/layouts/search/constants.ts new file mode 100644 index 000000000..895e535ba --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/constants.ts @@ -0,0 +1,3 @@ +export const MAX_SEARCH_RESULTS = 10; +export const MIN_USERS_QUERY_LENGTH = 3; +export const USERS_DEBOUNCE_MS = 400; diff --git a/packages/vitnode/src/views/admin/layouts/search/flatten-nav.test.ts b/packages/vitnode/src/views/admin/layouts/search/flatten-nav.test.ts new file mode 100644 index 000000000..d19c61838 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/flatten-nav.test.ts @@ -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); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/search/flatten-nav.ts b/packages/vitnode/src/views/admin/layouts/search/flatten-nav.ts new file mode 100644 index 000000000..868f10eab --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/flatten-nav.ts @@ -0,0 +1,83 @@ +import type { NavAdminParent } from "../sidebar/nav/get-admin-nav"; + +import { normalizeUrl } from "../normalize-url"; + +export interface AdminSearchNavItem { + groupTitle: string; + href: string; + icon?: React.ReactNode; + isOpenInNewTab?: boolean; + parentTitle?: string; + searchText: string; + title: string; +} + +export const flattenAdminNav = ( + nav: NavAdminParent[], +): AdminSearchNavItem[] => { + const items: AdminSearchNavItem[] = []; + const seen = new Set(); + + const push = (item: Omit) => { + const key = normalizeUrl(item.href); + if (seen.has(key)) return; + seen.add(key); + + items.push({ + ...item, + searchText: buildSearchText([ + item.title, + item.parentTitle, + item.groupTitle, + ]), + }); + }; + + for (const group of nav) { + for (const item of group.items) { + if (item.items?.length) { + for (const subItem of item.items) { + push({ + groupTitle: group.title, + href: subItem.href, + icon: item.icon, + isOpenInNewTab: subItem.isOpenInNewTab, + parentTitle: item.title, + title: subItem.title, + }); + } + + continue; + } + + push({ + groupTitle: group.title, + href: item.href, + icon: item.icon, + isOpenInNewTab: item.isOpenInNewTab, + title: item.title, + }); + } + } + + return items; +}; + +export const buildSearchText = (parts: (string | undefined)[]): string => + [ + ...new Set( + parts + .filter((part): part is string => Boolean(part)) + .map(part => part.toLowerCase()), + ), + ].join(" "); + +export const matchesAdminNavItem = ( + item: AdminSearchNavItem, + query: string, +): boolean => { + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!tokens.length) return true; + + return tokens.every(token => item.searchText.includes(token)); +}; diff --git a/packages/vitnode/src/views/admin/layouts/search/get-search-nav-items.tsx b/packages/vitnode/src/views/admin/layouts/search/get-search-nav-items.tsx new file mode 100644 index 000000000..17877f3a8 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/get-search-nav-items.tsx @@ -0,0 +1,142 @@ +import { BugIcon } from "lucide-react"; +import { createTranslator } from "next-intl"; +import { getLocale, getTranslations } from "next-intl/server"; +import "server-only"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { VitNodeConfig } from "@/vitnode.config"; + +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; +import { loadMessages } from "@/lib/i18n/load-messages"; +import { buildMessagesSources } from "@/lib/i18n/sources"; + +import type { AdminNavTranslator } from "../sidebar/nav/get-admin-nav"; +import type { NavAdminParent } from "../sidebar/nav/get-admin-nav"; +import type { AdminSearchNavItem } from "./flatten-nav"; + +import { normalizeUrl } from "../normalize-url"; +import { getAdminNav } from "../sidebar/nav/get-admin-nav"; +import { buildSearchText, flattenAdminNav } from "./flatten-nav"; + +interface SearchOnlyPage { + href: string; + icon: React.ReactNode; + permission: Omit; + titleKey: string; +} + +const SEARCH_ONLY_PAGES: SearchOnlyPage[] = [ + { + href: "/admin/core/debug", + icon: , + permission: { module: "debug", permission: "can_view" }, + titleKey: "admin.global.nav.user_bar.debug", + }, +]; + +const getPermittedSearchOnlyPages = async (): Promise => { + const allowed = await Promise.all( + SEARCH_ONLY_PAGES.map( + async page => await checkAdminPermissionApi(page.permission), + ), + ); + + return SEARCH_ONLY_PAGES.filter((_, index) => allowed[index]); +}; + +const toSearchItems = ( + pages: SearchOnlyPage[], + t: AdminNavTranslator, +): AdminSearchNavItem[] => + pages.map(page => { + const groupTitle = t("admin.global.nav.core"); + const title = t(page.titleKey); + + return { + groupTitle, + href: page.href, + icon: page.icon, + searchText: buildSearchText([title, groupTitle]), + title, + }; + }); + +const getEnabledLocales = (vitNodeConfig: VitNodeConfig): string[] => + vitNodeConfig.i18n.locales + .filter(locale => locale.enabled !== false) + .map(locale => locale.code); + +const getItemsForLocale = async ({ + locale, + searchOnlyPages, + vitNodeConfig, +}: { + locale: string; + searchOnlyPages: SearchOnlyPage[]; + vitNodeConfig: VitNodeConfig; +}): Promise => { + const messages = await loadMessages({ + defaultLocale: vitNodeConfig.i18n.defaultLocale, + locale, + sources: buildMessagesSources({ + appMessages: vitNodeConfig.i18n.messages, + plugins: vitNodeConfig.plugins, + }), + }); + const translator = createTranslator({ + locale, + messages, + }) as unknown as AdminNavTranslator; + + return [ + ...flattenAdminNav(await getAdminNav({ translator, vitNodeConfig })), + ...toSearchItems(searchOnlyPages, translator), + ]; +}; + +export const getSearchNavItems = async ({ + nav, + vitNodeConfig, +}: { + nav: NavAdminParent[]; + vitNodeConfig: VitNodeConfig; +}): Promise => { + const searchOnlyPages = await getPermittedSearchOnlyPages(); + const activeTranslator = + (await getTranslations()) as unknown as AdminNavTranslator; + const items = [ + ...flattenAdminNav(nav), + ...toSearchItems(searchOnlyPages, activeTranslator), + ]; + const activeLocale = await getLocale(); + const otherLocales = getEnabledLocales(vitNodeConfig).filter( + locale => locale !== activeLocale, + ); + + if (!otherLocales.length) return items; + + const translated = await Promise.all( + otherLocales.map( + async locale => + await getItemsForLocale({ locale, searchOnlyPages, vitNodeConfig }), + ), + ); + + const textByHref = new Map(); + for (const list of [items, ...translated]) { + for (const item of list) { + const key = normalizeUrl(item.href); + textByHref.set(key, [ + ...(textByHref.get(key) ?? []), + item.title, + item.parentTitle ?? "", + item.groupTitle, + ]); + } + } + + return items.map(item => ({ + ...item, + searchText: buildSearchText(textByHref.get(normalizeUrl(item.href)) ?? []), + })); +}; diff --git a/packages/vitnode/src/views/admin/layouts/search/multi-locale-titles.test.ts b/packages/vitnode/src/views/admin/layouts/search/multi-locale-titles.test.ts new file mode 100644 index 000000000..dc1525ee9 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/multi-locale-titles.test.ts @@ -0,0 +1,136 @@ +import { createTranslator } from "next-intl"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { CONFIG_PLUGIN } from "@/config"; +import { loadMessages, resetMessagesCache } from "@/lib/i18n/load-messages"; +import { buildMessagesSources } from "@/lib/i18n/sources"; + +import { buildSearchText, matchesAdminNavItem } from "./flatten-nav"; + +const buildTranslator = async (locale: string) => { + const messages = await loadMessages({ + defaultLocale: "en", + locale, + sources: buildMessagesSources({ + appMessages: { + pl: { + [CONFIG_PLUGIN.pluginId]: async () => + Promise.resolve({ + default: { + admin: { + global: { + nav: { + core: "Rdzeń", + user_bar: { debug: "Panel debugowania" }, + users: { + list: "Lista użytkowników", + roles: "Role", + title: "Użytkownicy", + }, + }, + }, + }, + }, + }), + }, + }, + plugins: [], + }), + }); + + return createTranslator({ locale, messages }); +}; + +describe("multi-locale nav titles", () => { + beforeEach(() => { + resetMessagesCache(); + }); + + it("resolves a non-active locale's titles", async () => { + const t = await buildTranslator("pl"); + + // @ts-expect-error - the test's inline tree is not the augmented `Messages`. + expect(t("admin.global.nav.users.roles")).toBe("Role"); + // @ts-expect-error - see above. + expect(t("admin.global.nav.core")).toBe("Rdzeń"); + }); + + it("resolves the debug panel, which sits outside the sidebar nav", async () => { + const key = "admin.global.nav.user_bar.debug"; + const [en, pl] = await Promise.all([ + buildTranslator("en"), + buildTranslator("pl"), + ]); + + // @ts-expect-error - the test's inline tree is not the augmented `Messages`. + expect(en(key)).toBe("Debug Panel"); + // @ts-expect-error - see above. + expect(pl(key)).toBe("Panel debugowania"); + + const item = { + groupTitle: "Rdzeń", + href: "/admin/core/debug", + // @ts-expect-error - see above. + searchText: buildSearchText([pl(key), en(key), "Rdzeń", "Core"]), + // @ts-expect-error - see above. + title: pl(key), + }; + + expect(matchesAdminNavItem(item, "debug")).toBe(true); + expect(matchesAdminNavItem(item, "panel debugowania")).toBe(true); + expect(matchesAdminNavItem(item, "debug panel")).toBe(true); + }); + + it("falls back to the default locale key by key", async () => { + const t = await buildTranslator("pl"); + + // Not translated above, so English shows through rather than a raw key. + // @ts-expect-error - see above. + expect(t("admin.global.nav.dashboard")).toBe("Dashboard"); + }); + + it("still resolves English when that is the locale asked for", async () => { + const t = await buildTranslator("en"); + + // @ts-expect-error - see above. + expect(t("admin.global.nav.users.roles")).toBe("Roles"); + }); + + it("finds a Polish-labelled row by its English title", async () => { + const [pl, en] = await Promise.all([ + buildTranslator("pl"), + buildTranslator("en"), + ]); + const key = "admin.global.nav.users.roles"; + const parentKey = "admin.global.nav.users.title"; + + const item = { + groupTitle: "Rdzeń", + href: "/admin/core/users/roles", + // What `getSearchNavItems` merges: the active locale's strings plus every + // other locale's, so either language finds the row. + searchText: buildSearchText([ + // @ts-expect-error - see above. + pl(key), + // @ts-expect-error - see above. + pl(parentKey), + // @ts-expect-error - see above. + en(key), + // @ts-expect-error - see above. + en(parentKey), + ]), + // @ts-expect-error - see above. + title: pl(key), + }; + + expect(item.title).toBe("Role"); + // The regression this whole feature exists for: "Role" alone does not + // contain "roles", so a single-locale haystack would miss it. + expect(item.searchText).not.toBe("role użytkownicy"); + expect(matchesAdminNavItem(item, "roles")).toBe(true); + expect(matchesAdminNavItem(item, "role")).toBe(true); + expect(matchesAdminNavItem(item, "users")).toBe(true); + expect(matchesAdminNavItem(item, "użytkownicy")).toBe(true); + expect(matchesAdminNavItem(item, "nope")).toBe(false); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/search/search-dialog.test.tsx b/packages/vitnode/src/views/admin/layouts/search/search-dialog.test.tsx new file mode 100644 index 000000000..7892a2ce4 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/search-dialog.test.tsx @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminSearchUser } from "./search-users.action.server"; + +import { MIN_USERS_QUERY_LENGTH } from "./constants"; +import { SearchAdminDialog } from "./search-dialog"; +import { searchUsersForAdminPalette } from "./search-users.action.server"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: (props: React.ComponentProps<"a">) => , + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + {alt} + ), +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => true, +})); + +vi.mock("./search-users.action.server", () => ({ + searchUsersForAdminPalette: vi.fn(), +})); + +/** `cmdk` measures its list and scrolls the active row into view. */ +vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + unobserve() {} + }, +); +Element.prototype.scrollIntoView = vi.fn(); + +const buildUser = (name: string, id: number): AdminSearchUser => ({ + id, + name, + nameCode: name.toLowerCase().replaceAll(" ", "_"), + email: `${id}@vitnode.com`, + avatarColor: "3b82f6", +}); + +const USERS_BY_QUERY: Record = { + john: [buildUser("John Doe", 1)], + johnny: [buildUser("Johnny Cash", 2)], +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +const renderDialog = () => + render( + + + , + ); + +/** Types into the palette, which debounces before the users query runs. */ +const typeQuery = (value: string) => { + fireEvent.change(screen.getByPlaceholderText("search.placeholder"), { + target: { value }, + }); +}; + +const mockedSearchUsers = vi.mocked(searchUsersForAdminPalette); + +const searchedQueries = () => + mockedSearchUsers.mock.calls.map(([search]) => search); + +describe("SearchAdminDialog users results", () => { + beforeEach(() => { + mockedSearchUsers.mockImplementation( + async search => await Promise.resolve(USERS_BY_QUERY[search] ?? []), + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("lists users once the query reaches the threshold", async () => { + renderDialog(); + + typeQuery("john"); + + expect(await screen.findByText("John Doe")).toBeDefined(); + expect(searchedQueries()).toStrictEqual(["john"]); + }); + + it("drops the retained users when the query is shortened below the threshold", async () => { + renderDialog(); + + typeQuery("john"); + expect(await screen.findByText("John Doe")).toBeDefined(); + + // Deleting back under the threshold only *disables* the users query, so + // `keepPreviousData` used to hand the finished search's users to that + // disabled query - leaving people who no longer match listed and + // selectable for as long as the palette stayed open. + typeQuery("jo"); + + await waitFor(() => { + expect(screen.queryByText("John Doe")).toBeNull(); + }); + // The palette asks for more characters instead of showing stale people. + expect(screen.getByText("search.hint")).toBeDefined(); + expect(searchedQueries()).toStrictEqual(["john"]); + }); + + it("drops the retained users when the query is cleared", async () => { + renderDialog(); + + typeQuery("john"); + expect(await screen.findByText("John Doe")).toBeDefined(); + + typeQuery(""); + + await waitFor(() => { + expect(screen.queryByText("John Doe")).toBeNull(); + }); + // An empty palette shows neither the users nor the threshold hint. + expect(screen.queryByText("search.hint")).toBeNull(); + expect(screen.getByText("results_not_found")).toBeDefined(); + expect(searchedQueries()).toStrictEqual(["john"]); + }); + + it("keeps the previous users while a longer query is still fetching", async () => { + const pending = deferred(); + + renderDialog(); + + typeQuery("john"); + expect(await screen.findByText("John Doe")).toBeDefined(); + + mockedSearchUsers.mockImplementationOnce(async () => await pending.promise); + typeQuery("johnny"); + + await waitFor(() => { + expect(searchedQueries()).toStrictEqual(["john", "johnny"]); + }); + // Still above the threshold, so the retained results bridge the refetch + // rather than flashing an empty palette. + expect(screen.getByText("John Doe")).toBeDefined(); + + pending.resolve(USERS_BY_QUERY.johnny); + + expect(await screen.findByText("Johnny Cash")).toBeDefined(); + expect(screen.queryByText("John Doe")).toBeNull(); + }); + + it("never queries below the threshold in the first place", async () => { + renderDialog(); + + typeQuery("johnny".slice(0, MIN_USERS_QUERY_LENGTH - 1)); + + await waitFor(() => { + expect(screen.getByText("search.hint")).toBeDefined(); + }); + expect(mockedSearchUsers).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/search/search-dialog.tsx b/packages/vitnode/src/views/admin/layouts/search/search-dialog.tsx new file mode 100644 index 000000000..013d31026 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/search-dialog.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { MenuIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { useDebouncedCallback } from "use-debounce"; + +import { Avatar } from "@/components/avatar"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { + Command, + CommandDialog, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CONFIG_PLUGIN } from "@/config"; +import { Link, useRouter } from "@/lib/navigation"; + +import type { AdminSearchNavItem } from "./flatten-nav"; + +import { + MAX_SEARCH_RESULTS, + MIN_USERS_QUERY_LENGTH, + USERS_DEBOUNCE_MS, +} from "./constants"; +import { matchesAdminNavItem } from "./flatten-nav"; +import { searchUsersForAdminPalette } from "./search-users.action.server"; +import { splitResultBudget } from "./split-results"; + +const NavCommandItem = ({ + item, + onNavigate, +}: { + item: AdminSearchNavItem; + onNavigate: (href: string) => void; +}) => { + const linkRef = React.useRef(null); + const content = ( + <> + {item.icon ?? } + {item.title} + + {item.parentTitle ?? item.groupTitle} + + + ); + + if (item.isOpenInNewTab) { + return ( + linkRef.current?.click()} value={item.href}> + event.stopPropagation()} + ref={linkRef} + rel="noopener noreferrer" + target="_blank" + > + {content} + + + ); + } + + return ( + onNavigate(item.href)} value={item.href}> + {content} + + ); +}; + +export const SearchAdminDialog = ({ + items, + onOpenChange, + open, +}: { + items: AdminSearchNavItem[]; + onOpenChange: (open: boolean) => void; + open: boolean; +}) => { + const t = useTranslations("admin.global"); + const tCore = useTranslations("core.global"); + const { push } = useRouter(); + + const [query, setQuery] = React.useState(""); + const [usersQuery, setUsersQuery] = React.useState(""); + + const debounceUsersQuery = useDebouncedCallback( + setUsersQuery, + USERS_DEBOUNCE_MS, + ); + + const pendingHrefRef = React.useRef(null); + + const canViewUsers = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "users", + permission: "can_view", + }); + + const trimmedUsersQuery = usersQuery.trim(); + const canSearchUsers = + open && canViewUsers && trimmedUsersQuery.length >= MIN_USERS_QUERY_LENGTH; + + const { data: users, isFetching: isFetchingUsers } = useQuery({ + queryKey: ["admin-search-users", trimmedUsersQuery], + queryFn: async () => await searchUsersForAdminPalette(trimmedUsersQuery), + enabled: canSearchUsers, + // Retained users must not outlive the threshold: dropping back under + // `MIN_USERS_QUERY_LENGTH` only disables the query, so an unconditional + // `keepPreviousData` would keep unmatched people listed and selectable. + placeholderData: canSearchUsers ? keepPreviousData : undefined, + staleTime: 30_000, + }); + + const { groupedPages, pagesCount, visibleUsers } = React.useMemo(() => { + const matched = items.filter(item => + matchesAdminNavItem(item, query.trim()), + ); + const userResults = users ?? []; + const share = splitResultBudget({ + budget: MAX_SEARCH_RESULTS, + navCount: matched.length, + usersCount: userResults.length, + }); + const groups = new Map(); + + for (const item of matched.slice(0, share.nav)) { + const group = groups.get(item.groupTitle); + + if (group) { + group.push(item); + continue; + } + + groups.set(item.groupTitle, [item]); + } + + return { + groupedPages: [...groups], + pagesCount: share.nav, + visibleUsers: userResults.slice(0, share.users), + }; + }, [items, query, users]); + + const handleChangeQuery = (value: string) => { + setQuery(value); + debounceUsersQuery(value); + }; + + const navigateOnClose = (href: string) => { + debounceUsersQuery.cancel(); + pendingHrefRef.current = href; + onOpenChange(false); + }; + + const trimmedQuery = query.trim(); + const showUsersHint = + canViewUsers && + trimmedQuery.length > 0 && + trimmedQuery.length < MIN_USERS_QUERY_LENGTH; + const showUsersSkeleton = isFetchingUsers && !visibleUsers.length; + const isEmpty = + !pagesCount && !visibleUsers.length && !showUsersHint && !showUsersSkeleton; + + return ( + { + if (isOpen) return; + + const href = pendingHrefRef.current; + pendingHrefRef.current = null; + setQuery(""); + setUsersQuery(""); + + if (href) { + push(href); + } + }} + open={open} + title={t("search.title")} + > + + + + + {isEmpty && ( +
+ {tCore("results_not_found")} +
+ )} + + {groupedPages.map(([groupTitle, groupItems]) => ( + + {groupItems.map(item => ( + + ))} + + ))} + + {!!pagesCount && + (!!visibleUsers.length || showUsersSkeleton || showUsersHint) && ( + + )} + + {showUsersHint && ( +
+ {t("search.hint", { count: MIN_USERS_QUERY_LENGTH })} +
+ )} + + {showUsersSkeleton && ( +
+ {["a", "b", "c"].map(id => ( + + ))} +
+ )} + + {!!visibleUsers.length && ( + + {visibleUsers.map(user => ( + + navigateOnClose(`/admin/core/users/${user.id}`) + } + value={`user-${user.id}`} + > + + {user.name} + + @{user.nameCode} + + + ))} + + )} +
+
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/search/search-users.action.server.ts b/packages/vitnode/src/views/admin/layouts/search/search-users.action.server.ts new file mode 100644 index 000000000..c0de11c7e --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/search-users.action.server.ts @@ -0,0 +1,42 @@ +"use server"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +import { MAX_SEARCH_RESULTS } from "./constants"; + +export interface AdminSearchUser { + avatarColor: string; + email: string; + id: number; + name: string; + nameCode: string; +} + +export const searchUsersForAdminPalette = async ( + search: string, +): Promise => { + const res = await fetcher(adminModule, { + path: "/list", + method: "get", + module: "admin/users", + args: { + query: { search, first: String(MAX_SEARCH_RESULTS) }, + }, + withPagination: true, + }); + + if (res.status !== 200) { + return []; + } + + const data = await res.json(); + + return data.edges.map(user => ({ + id: user.id, + name: user.name, + nameCode: user.nameCode, + email: user.email, + avatarColor: user.avatarColor, + })); +}; diff --git a/packages/vitnode/src/views/admin/layouts/search/search.tsx b/packages/vitnode/src/views/admin/layouts/search/search.tsx new file mode 100644 index 000000000..50288bf93 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/search.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { SearchIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; +import { Kbd, KbdGroup } from "@/components/ui/kbd"; + +import type { AdminSearchNavItem } from "./flatten-nav"; + +const importSearchAdminDialog = async () => await import("./search-dialog"); + +const SearchAdminDialog = React.lazy(async () => + importSearchAdminDialog().then(module => ({ + default: module.SearchAdminDialog, + })), +); + +export const SearchAdmin = ({ items }: { items: AdminSearchNavItem[] }) => { + const t = useTranslations("admin.global"); + const tCore = useTranslations("core.global"); + + const [open, setOpen] = React.useState(false); + /** Keeps the dialog chunk mounted after the first open so it is not re-requested. */ + const [isMounted, setIsMounted] = React.useState(false); + const [isApple, setIsApple] = React.useState(undefined); + + React.useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-initialize-state, @eslint-react/set-state-in-effect + setIsApple(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)); + }, []); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "k" || (!event.metaKey && !event.ctrlKey)) return; + if (event.altKey || event.shiftKey || event.defaultPrevented) return; + + event.preventDefault(); + setIsMounted(true); + setOpen(prev => !prev); + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const handleOpen = () => { + setIsMounted(true); + setOpen(true); + }; + + /** Warms the dialog chunk on hover/focus so opening it does not wait on the network. */ + const handlePrefetch = () => { + void importSearchAdminDialog(); + }; + + return ( + <> + + + + + + + + + {isApple !== undefined && ( + + + {isApple ? "⌘" : "Ctrl"} + K + + + )} + + + {isMounted && ( + + + + )} + + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/search/split-results.test.ts b/packages/vitnode/src/views/admin/layouts/search/split-results.test.ts new file mode 100644 index 000000000..cb69fa881 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/split-results.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { MAX_SEARCH_RESULTS } from "./constants"; +import { splitResultBudget } from "./split-results"; + +const split = (navCount: number, usersCount: number) => + splitResultBudget({ budget: MAX_SEARCH_RESULTS, navCount, usersCount }); + +const total = (share: { nav: number; users: number }) => + share.nav + share.users; + +describe("splitResultBudget", () => { + it("never renders more rows than the budget", () => { + for (const navCount of [0, 1, 5, 9, 10, 13, 40]) { + for (const usersCount of [0, 1, 5, 9, 10]) { + const share = split(navCount, usersCount); + + expect(total(share)).toBeLessThanOrEqual(MAX_SEARCH_RESULTS); + expect(share.nav).toBeLessThanOrEqual(navCount); + expect(share.users).toBeLessThanOrEqual(usersCount); + } + } + }); + + it("gives the whole budget to pages when there are no users", () => { + expect(split(13, 0)).toStrictEqual({ nav: 10, users: 0 }); + }); + + it("gives the whole budget to users when no page matches", () => { + // Searching a person's name rarely matches a page - showing only five + // users there would waste half the palette. + expect(split(0, 10)).toStrictEqual({ nav: 0, users: 10 }); + }); + + it("splits evenly when both sides are plentiful", () => { + expect(split(13, 10)).toStrictEqual({ nav: 5, users: 5 }); + }); + + it("hands unused rows to the other side", () => { + expect(split(13, 2)).toStrictEqual({ nav: 8, users: 2 }); + expect(split(2, 10)).toStrictEqual({ nav: 2, users: 8 }); + }); + + it("shows everything when the total already fits", () => { + expect(split(4, 3)).toStrictEqual({ nav: 4, users: 3 }); + expect(split(0, 0)).toStrictEqual({ nav: 0, users: 0 }); + }); + + it("fills the budget whenever enough results exist", () => { + for (const [navCount, usersCount] of [ + [10, 10], + [6, 6], + [20, 1], + [1, 20], + ]) { + expect(total(split(navCount, usersCount))).toBe(MAX_SEARCH_RESULTS); + } + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/search/split-results.ts b/packages/vitnode/src/views/admin/layouts/search/split-results.ts new file mode 100644 index 000000000..2d1fcd60a --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/search/split-results.ts @@ -0,0 +1,14 @@ +export const splitResultBudget = ({ + budget, + navCount, + usersCount, +}: { + budget: number; + navCount: number; + usersCount: number; +}): { nav: number; users: number } => { + const fairShare = Math.floor(budget / 2); + const nav = Math.min(navCount, budget - Math.min(usersCount, fairShare)); + + return { nav, users: Math.min(usersCount, budget - nav) }; +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx index fe7a7d10a..b52a782e2 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx @@ -26,6 +26,8 @@ export interface NavAdminParent { title: string; } +export type AdminNavTranslator = (key: string) => string; + interface NavSubItemConfig { href: string; isOpenInNewTab?: boolean; @@ -93,11 +95,14 @@ const filterNavItems = ( }; export const getAdminNav = async ({ + translator, vitNodeConfig = getVitNodeConfig(), }: { + translator?: AdminNavTranslator; vitNodeConfig?: VitNodeConfig; } = {}): Promise => { - const t = await getTranslations(); + const activeTranslator = await getTranslations(); + const t = (translator ?? activeTranslator) as typeof activeTranslator; const session = await getSessionAdminApi(); const permissions: StaffPermissionSet = session?.permissions ?? { root: false, diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx index 3e1354d26..5ce3db4b5 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx @@ -20,6 +20,8 @@ import { useIsMobile } from "@/hooks/use-mobile"; import { Link, usePathname } from "@/lib/navigation"; import { cn } from "@/lib/utils"; +import { normalizeUrl } from "../../normalize-url"; + interface ItemNavAdminProps { href: string; icon?: React.ReactNode; @@ -40,11 +42,6 @@ export const ItemNavAdmin = ({ const isMobile = useIsMobile(); const pathname = usePathname(); - // Helper function to normalize URLs for comparison - const normalizeUrl = (url: string) => { - return url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url; - }; - // True when the pathname is the href itself or lives under it as a path // segment (e.g. an edit/create sub-page), matching whole segments only. const isPathnameUnderHref = (candidate: string) => { diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx index fbb33c51a..120cb54ee 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx @@ -1,24 +1,17 @@ -import type { VitNodeConfig } from "@/vitnode.config"; - import { SidebarGroup, SidebarGroupLabel, SidebarMenu, } from "@/components/ui/sidebar"; -import { getAdminNav } from "./get-admin-nav"; +import type { NavAdminParent } from "./get-admin-nav"; + import { ItemNavAdmin } from "./item"; export type { NavAdminParent } from "./get-admin-nav"; -export const NavSidebarAdmin = async ({ - vitNodeConfig, -}: { - vitNodeConfig: VitNodeConfig; -}) => { - const rootItems = await getAdminNav({ vitNodeConfig }); - - return rootItems.map(parent => ( +export const NavSidebarAdmin = ({ nav }: { nav: NavAdminParent[] }) => { + return nav.map(parent => ( {parent.title} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx index 099018c6c..a69692774 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx @@ -1,4 +1,8 @@ +import type { VitNodeConfig } from "@/vitnode.config"; + import { LogoVitNode } from "@/components/logo-vitnode"; +import { LanguageSwitcher } from "@/components/switchers/langs/language-switcher"; +import { ThemeSwitcher } from "@/components/switchers/themes/theme-switcher"; import { Sidebar, SidebarContent, @@ -6,20 +10,32 @@ import { } from "@/components/ui/sidebar"; import { Link } from "@/lib/navigation"; +import type { NavAdminParent } from "./nav/nav"; + import { NavSidebarAdmin } from "./nav/nav"; export const SidebarAdmin = ({ + nav, vitNodeConfig, -}: React.ComponentProps) => { +}: { + nav: NavAdminParent[]; + vitNodeConfig: VitNodeConfig; +}) => { return ( - - + + + + {vitNodeConfig.i18n.locales.length > 1 && ( + + )} + + - + ); diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx index f46d6f566..899fa9ef0 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx @@ -9,19 +9,33 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Link } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; import type { BreadcrumbCrumb } from "./crumb"; -/** - * Shared presentational renderer for a resolved list of breadcrumb crumbs. - * Used by both the AdminCP and the main-site breadcrumbs. - */ -export const BreadcrumbRender = ({ crumbs }: { crumbs: BreadcrumbCrumb[] }) => { +export const BreadcrumbRender = ({ + crumbs, + scrollable, +}: { + crumbs: BreadcrumbCrumb[]; + /** + * Keeps every crumb on a single line and scrolls them horizontally instead + * of wrapping - for fixed-height bars like the AdminCP header. + */ + scrollable?: boolean; +}) => { if (crumbs.length === 0) return null; return ( - - + + {crumbs.map((crumb, index) => ( {index > 0 && }