From 2e9a040174d262d01a98634f261b8808ed9c7bb0 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 29 May 2026 11:04:53 +0100 Subject: [PATCH] Add global Ctrl+K command palette for navigation and search. Provides quick access to sidebar destinations, org switching, server-filtered entity lookup, and common actions from anywhere in the authenticated layout. --- messages/en-US.json | 23 ++ src/app/page.tsx | 4 +- src/components/Layout.tsx | 79 ++--- src/components/LayoutHeader.tsx | 2 + src/components/LayoutMobileMenu.tsx | 2 + .../command-palette/CommandPalette.tsx | 270 ++++++++++++++++++ .../CommandPaletteProvider.tsx | 76 +++++ .../command-palette/CommandPaletteTrigger.tsx | 69 +++++ .../command-palette/commandPaletteContext.tsx | 37 +++ .../useCommandPaletteActions.tsx | 161 +++++++++++ .../useCommandPaletteNavigation.ts | 57 ++++ .../useCommandPaletteOrganizations.ts | 45 +++ .../useCommandPaletteSearch.ts | 142 +++++++++ src/lib/flattenNavItems.ts | 42 +++ src/lib/hydrateNavHref.ts | 35 +++ 15 files changed, 1005 insertions(+), 39 deletions(-) create mode 100644 src/components/command-palette/CommandPalette.tsx create mode 100644 src/components/command-palette/CommandPaletteProvider.tsx create mode 100644 src/components/command-palette/CommandPaletteTrigger.tsx create mode 100644 src/components/command-palette/commandPaletteContext.tsx create mode 100644 src/components/command-palette/useCommandPaletteActions.tsx create mode 100644 src/components/command-palette/useCommandPaletteNavigation.ts create mode 100644 src/components/command-palette/useCommandPaletteOrganizations.ts create mode 100644 src/components/command-palette/useCommandPaletteSearch.ts create mode 100644 src/lib/flattenNavItems.ts create mode 100644 src/lib/hydrateNavHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 027d9fc38..7891a67f7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1354,6 +1354,29 @@ "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", + "commandPaletteTitle": "Command palette", + "commandPaletteDescription": "Search for pages, organizations, resources, and actions", + "commandPaletteSearchPlaceholder": "Search pages, resources, actions...", + "commandPaletteNoResults": "No results found.", + "commandPaletteSearching": "Searching...", + "commandPaletteNavigation": "Navigation", + "commandPaletteOrganizations": "Organizations", + "commandPaletteSites": "Sites", + "commandPaletteResources": "Resources", + "commandPaletteUsers": "Users", + "commandPaletteClients": "Machine clients", + "commandPaletteActions": "Actions", + "commandPaletteCreateSite": "Create site", + "commandPaletteCreateProxyResource": "Create public resource", + "commandPaletteCreateUser": "Create user", + "commandPaletteCreateApiKey": "Create API key", + "commandPaletteCreateMachineClient": "Create machine client", + "commandPaletteCreateAlertRule": "Create alert rule", + "commandPaletteCreateIdentityProvider": "Create identity provider", + "commandPaletteToggleTheme": "Toggle theme", + "commandPaletteChooseOrganization": "Choose organization", + "commandPaletteShortcutMac": "⌘K", + "commandPaletteShortcutWindows": "Ctrl K", "navbarDocsLink": "Documentation", "otpErrorEnable": "Unable to enable 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA", diff --git a/src/app/page.tsx b/src/app/page.tsx index f6f30276a..6b18016c6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,9 +21,11 @@ export default async function Page(props: { searchParams: Promise<{ redirect: string | undefined; t: string | undefined; + orgs?: string | undefined; }>; }) { const params = await props.searchParams; // this is needed to prevent static optimization + const showOrgPicker = params.orgs === "1"; const env = pullEnv(); @@ -106,7 +108,7 @@ export default async function Page(props: { } } - if (targetOrgId) { + if (targetOrgId && !showOrgPicker) { return ; } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..95ce43120 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,6 +5,7 @@ import type { SidebarNavSection } from "@app/app/navigation"; import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; +import { CommandPaletteProvider } from "@app/components/command-palette/CommandPaletteProvider"; import { cookies } from "next/headers"; interface LayoutProps { @@ -37,51 +38,53 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Desktop Sidebar */} - {showSidebar && ( - - )} - - {/* Main content area */} -
- {/* Mobile header */} - {showHeader && ( - +
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} - {/* Desktop header */} - {showHeader && } + {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + + )} - {/* Main content */} -
-
- {children} -
-
+ {/* Desktop header */} + {showHeader && } + + {/* Main content */} +
+
+ {children} +
+
+
-
+ ); } diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..f1096796c 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -6,6 +6,7 @@ import ProfileIcon from "@app/components/ProfileIcon"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; @@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { {showTopBar && (
+
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..ff4815ce8 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -13,6 +13,7 @@ import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import ProfileIcon from "@app/components/ProfileIcon"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; import type { SidebarNavSection } from "@app/app/navigation"; import { Sheet, @@ -121,6 +122,7 @@ export function LayoutMobileMenu({ {showTopBar && (
+
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 000000000..c915aedaf --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@app/components/ui/command"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { Badge } from "@app/components/ui/badge"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPalette } from "./commandPaletteContext"; +import { useCommandPaletteActions } from "./useCommandPaletteActions"; +import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; +import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; +import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; + +type CommandPaletteProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { + const t = useTranslations(); + const router = useRouter(); + const { open, setOpen } = useCommandPalette(); + const [search, setSearch] = useState(""); + + const navigationGroups = useCommandPaletteNavigation(navItems); + const organizations = useCommandPaletteOrganizations(orgs); + const actions = useCommandPaletteActions(orgId, orgs); + const { shouldSearch, sites, resources, users, machineClients, isLoading } = + useCommandPaletteSearch({ + orgId, + query: search, + enabled: open + }); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }, + [setOpen] + ); + + const runCommand = useCallback( + (command: () => void) => { + setOpen(false); + setSearch(""); + command(); + }, + [setOpen] + ); + + const hasEntityResults = + sites.length > 0 || + resources.length > 0 || + users.length > 0 || + machineClients.length > 0; + + return ( + + + + {t("commandPaletteNoResults")} + + {navigationGroups.map((group) => ( + + {group.items.map((item) => ( + + runCommand(() => router.push(item.href)) + } + > + {item.icon} + {item.title} + + ))} + + ))} + + {organizations.length > 1 && ( + <> + + + {organizations.map((org) => ( + + runCommand(() => router.push(org.href)) + } + > + {org.name} + + {org.orgId} + + {org.isPrimaryOrg && ( + + {t("primary")} + + )} + + ))} + + + )} + + {shouldSearch && orgId && ( + <> + + {isLoading && !hasEntityResults ? ( +
+ + {t("commandPaletteSearching")} +
+ ) : ( + <> + {sites.length > 0 && ( + + {sites.map((site) => ( + + runCommand(() => + router.push(site.href) + ) + } + > + + {site.name} + + + ))} + + )} + {resources.length > 0 && ( + + {resources.map((resource) => ( + + runCommand(() => + router.push( + resource.href + ) + ) + } + > + + {resource.name} + + + ))} + + )} + {users.length > 0 && ( + + {users.map((user) => ( + + runCommand(() => + router.push(user.href) + ) + } + > +
+ + {user.name} + + + {user.email} + +
+
+ ))} +
+ )} + {machineClients.length > 0 && ( + + {machineClients.map((client) => ( + + runCommand(() => + router.push(client.href) + ) + } + > + + {client.name} + + + ))} + + )} + + )} + + )} + + {actions.length > 0 && ( + <> + + + {actions.map((action) => ( + + runCommand(() => { + if (action.onSelect) { + action.onSelect(); + } else if (action.href) { + router.push(action.href); + } + }) + } + > + {action.icon} + {action.label} + + ))} + + + )} +
+
+ ); +} diff --git a/src/components/command-palette/CommandPaletteProvider.tsx b/src/components/command-palette/CommandPaletteProvider.tsx new file mode 100644 index 000000000..c3a6bbf0c --- /dev/null +++ b/src/components/command-palette/CommandPaletteProvider.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { SidebarNavSection } from "@app/app/navigation"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + CommandPaletteContextProvider, + type CommandPaletteContextValue +} from "./commandPaletteContext"; +import { CommandPalette } from "./CommandPalette"; + +type CommandPaletteProviderProps = { + children: React.ReactNode; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tagName = target.tagName; + return ( + tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" + ); +} + +export function CommandPaletteProvider({ + children, + orgId, + orgs, + navItems +}: CommandPaletteProviderProps) { + const [open, setOpen] = useState(false); + + const toggle = useCallback(() => { + setOpen((current) => !current); + }, []); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + toggle + }), + [open, toggle] + ); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) + ) { + return; + } + + if (!open && isEditableTarget(event.target)) { + return; + } + + event.preventDefault(); + toggle(); + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, toggle]); + + return ( + + {children} + + + ); +} diff --git a/src/components/command-palette/CommandPaletteTrigger.tsx b/src/components/command-palette/CommandPaletteTrigger.tsx new file mode 100644 index 000000000..f4dd0bdef --- /dev/null +++ b/src/components/command-palette/CommandPaletteTrigger.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { CommandShortcut } from "@app/components/ui/command"; +import { cn } from "@app/lib/cn"; +import { Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPalette } from "./commandPaletteContext"; + +type CommandPaletteTriggerProps = { + variant?: "header" | "mobile"; + className?: string; +}; + +function useIsMac() { + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform)); + }, []); + + return isMac; +} + +export function CommandPaletteTrigger({ + variant = "header", + className +}: CommandPaletteTriggerProps) { + const t = useTranslations(); + const { setOpen } = useCommandPalette(); + const isMac = useIsMac(); + + if (variant === "mobile") { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/command-palette/commandPaletteContext.tsx b/src/components/command-palette/commandPaletteContext.tsx new file mode 100644 index 000000000..26de0f2e6 --- /dev/null +++ b/src/components/command-palette/commandPaletteContext.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React, { createContext, useContext } from "react"; + +export type CommandPaletteContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +}; + +const CommandPaletteContext = createContext( + null +); + +export function CommandPaletteContextProvider({ + value, + children +}: { + value: CommandPaletteContextValue; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useCommandPalette() { + const context = useContext(CommandPaletteContext); + if (!context) { + throw new Error( + "useCommandPalette must be used within CommandPaletteProvider" + ); + } + return context; +} diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx new file mode 100644 index 000000000..a4a65d382 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { build } from "@server/build"; +import { + BellRing, + Building2, + Globe, + KeyRound, + MonitorUp, + Plus, + SunMoon, + UserPlus +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { ListUserOrgsResponse } from "@server/routers/org"; + +export type CommandPaletteAction = { + id: string; + label: string; + icon: ReactNode; + href?: string; + onSelect?: () => void; +}; + +export function useCommandPaletteActions( + orgId?: string, + orgs?: ListUserOrgsResponse["orgs"] +): CommandPaletteAction[] { + const t = useTranslations(); + const pathname = usePathname(); + const { env } = useEnvContext(); + const { user } = useUserContext(); + const { setTheme, theme } = useTheme(); + const isAdminPage = pathname?.startsWith("/admin"); + + return useMemo(() => { + const actions: CommandPaletteAction[] = []; + + function cycleTheme() { + const currentTheme = theme || "system"; + if (currentTheme === "light") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + } + + if (isAdminPage) { + actions.push({ + id: "create-admin-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: "/admin/api-keys/create" + }); + + if ( + build === "oss" || + env?.app.identityProviderMode === "global" || + env?.app.identityProviderMode === undefined + ) { + actions.push({ + id: "create-admin-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: "/admin/idp/create" + }); + } + } else if (orgId) { + actions.push({ + id: "create-site", + label: t("commandPaletteCreateSite"), + icon: , + href: `/${orgId}/settings/sites/create` + }); + actions.push({ + id: "create-proxy-resource", + label: t("commandPaletteCreateProxyResource"), + icon: , + href: `/${orgId}/settings/resources/proxy/create` + }); + actions.push({ + id: "create-user", + label: t("commandPaletteCreateUser"), + icon: , + href: `/${orgId}/settings/access/users/create` + }); + actions.push({ + id: "create-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: `/${orgId}/settings/api-keys/create` + }); + actions.push({ + id: "create-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/create` + }); + + if (!env?.flags.disableEnterpriseFeatures) { + actions.push({ + id: "create-alert-rule", + label: t("commandPaletteCreateAlertRule"), + icon: , + href: `/${orgId}/settings/alerting/create` + }); + } + + if ( + (build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && build !== "oss") + ) { + actions.push({ + id: "create-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: `/${orgId}/settings/idp/create` + }); + } + } + + const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1; + + if (canChooseOrganization) { + actions.push({ + id: "choose-org", + label: t("commandPaletteChooseOrganization"), + icon: , + href: "/?orgs=1" + }); + } + + actions.push({ + id: "toggle-theme", + label: t("commandPaletteToggleTheme"), + icon: , + onSelect: cycleTheme + }); + + if (user.serverAdmin && !isAdminPage) { + actions.push({ + id: "go-admin", + label: t("serverAdmin"), + icon: , + href: "/admin/users" + }); + } + + return actions; + }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]); +} diff --git a/src/components/command-palette/useCommandPaletteNavigation.ts b/src/components/command-palette/useCommandPaletteNavigation.ts new file mode 100644 index 000000000..1968d1a89 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteNavigation.ts @@ -0,0 +1,57 @@ +"use client"; + +import type { SidebarNavSection } from "@app/components/SidebarNav"; +import { flattenNavSections } from "@app/lib/flattenNavItems"; +import { + hydrateNavHref, + navHrefParamsFromRoute +} from "@app/lib/hydrateNavHref"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; + +export type NavigationCommand = { + id: string; + title: string; + href: string; + icon?: React.ReactNode; + sectionHeading: string; +}; + +export type NavigationCommandGroup = { + heading: string; + items: NavigationCommand[]; +}; + +export function useCommandPaletteNavigation( + navItems: SidebarNavSection[] +): NavigationCommandGroup[] { + const params = useParams(); + const t = useTranslations(); + + return useMemo(() => { + const hrefParams = navHrefParamsFromRoute(params); + const flat = flattenNavSections(navItems); + const groups = new Map(); + + for (const item of flat) { + const href = hydrateNavHref(item.href, hrefParams); + if (!href) continue; + + const groupItems = groups.get(item.sectionHeading) ?? []; + groupItems.push({ + id: `nav-${item.sectionHeading}-${item.title}-${href}`, + title: t(item.title), + href, + icon: item.icon, + sectionHeading: item.sectionHeading + }); + groups.set(item.sectionHeading, groupItems); + } + + return Array.from(groups.entries()).map(([heading, items]) => ({ + heading: t(heading), + items + })); + }, [navItems, params, t]); +} diff --git a/src/components/command-palette/useCommandPaletteOrganizations.ts b/src/components/command-palette/useCommandPaletteOrganizations.ts new file mode 100644 index 000000000..1e5343ccd --- /dev/null +++ b/src/components/command-palette/useCommandPaletteOrganizations.ts @@ -0,0 +1,45 @@ +"use client"; + +import { ListUserOrgsResponse } from "@server/routers/org"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; + +export type OrganizationCommand = { + id: string; + orgId: string; + name: string; + isPrimaryOrg?: boolean; + href: string; +}; + +export function useCommandPaletteOrganizations( + orgs: ListUserOrgsResponse["orgs"] | undefined +): OrganizationCommand[] { + const pathname = usePathname(); + + return useMemo(() => { + if (!orgs?.length) return []; + + const sortedOrgs = [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) return -1; + if (!aPrimary && bPrimary) return 1; + return 0; + }); + + return sortedOrgs.map((org) => { + const newPath = pathname.includes("/settings/") + ? pathname.replace(/^\/[^/]+/, `/${org.orgId}`) + : `/${org.orgId}`; + + return { + id: `org-${org.orgId}`, + orgId: org.orgId, + name: org.name, + isPrimaryOrg: org.isPrimaryOrg, + href: newPath + }; + }); + }, [orgs, pathname]); +} diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts new file mode 100644 index 000000000..984892354 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -0,0 +1,142 @@ +"use client"; + +import { orgQueries } from "@app/lib/queries"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useDebounce } from "use-debounce"; + +const SEARCH_PER_PAGE = 5; +const MIN_QUERY_LENGTH = 2; + +export type SiteSearchResult = { + id: string; + name: string; + href: string; +}; + +export type ResourceSearchResult = { + id: string; + name: string; + href: string; +}; + +export type UserSearchResult = { + id: string; + name: string; + email: string; + href: string; +}; + +export type ClientSearchResult = { + id: string; + name: string; + href: string; +}; + +export function useCommandPaletteSearch({ + orgId, + query, + enabled +}: { + orgId?: string; + query: string; + enabled: boolean; +}) { + const [debouncedQuery] = useDebounce(query, 150); + const trimmedQuery = debouncedQuery.trim(); + const shouldSearch = + enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH; + + const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({ + queries: [ + { + ...orgQueries.sites({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.resources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.users({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.machineClients({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + } + ] + }); + + const sites = useMemo((): SiteSearchResult[] => { + if (!orgId || !sitesQuery.data) return []; + return sitesQuery.data.map((site) => ({ + id: `site-${site.siteId}`, + name: site.name, + href: `/${orgId}/settings/sites/${site.niceId}` + })); + }, [orgId, sitesQuery.data]); + + const resources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !resourcesQuery.data) return []; + return resourcesQuery.data.map((resource) => ({ + id: `resource-${resource.resourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/proxy/${resource.niceId}` + })); + }, [orgId, resourcesQuery.data]); + + const users = useMemo((): UserSearchResult[] => { + if (!orgId || !usersQuery.data) return []; + return usersQuery.data.map((user) => ({ + id: `user-${user.id}`, + name: user.name ?? user.email ?? user.username ?? "", + email: user.email ?? user.username ?? "", + href: `/${orgId}/settings/access/users/${user.id}` + })); + }, [orgId, usersQuery.data]); + + const machineClients = useMemo((): ClientSearchResult[] => { + if (!orgId || !clientsQuery.data) return []; + return clientsQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/machine/${client.niceId}` + })); + }, [orgId, clientsQuery.data]); + + const isLoading = + shouldSearch && + (sitesQuery.isFetching || + resourcesQuery.isFetching || + usersQuery.isFetching || + clientsQuery.isFetching); + + return { + debouncedQuery: trimmedQuery, + shouldSearch, + sites, + resources, + users, + machineClients, + isLoading + }; +} diff --git a/src/lib/flattenNavItems.ts b/src/lib/flattenNavItems.ts new file mode 100644 index 000000000..b64268322 --- /dev/null +++ b/src/lib/flattenNavItems.ts @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import type { + SidebarNavItem, + SidebarNavSection +} from "@app/components/SidebarNav"; + +export type FlatNavItem = { + title: string; + href: string; + icon?: ReactNode; + sectionHeading: string; +}; + +function flattenItems( + items: SidebarNavItem[], + sectionHeading: string, + result: FlatNavItem[] +) { + for (const item of items) { + if (item.href) { + result.push({ + title: item.title, + href: item.href, + icon: item.icon, + sectionHeading + }); + } + if (item.items?.length) { + flattenItems(item.items, sectionHeading, result); + } + } +} + +export function flattenNavSections( + sections: SidebarNavSection[] +): FlatNavItem[] { + const result: FlatNavItem[] = []; + for (const section of sections) { + flattenItems(section.items, section.heading, result); + } + return result; +} diff --git a/src/lib/hydrateNavHref.ts b/src/lib/hydrateNavHref.ts new file mode 100644 index 000000000..45fa0f5ee --- /dev/null +++ b/src/lib/hydrateNavHref.ts @@ -0,0 +1,35 @@ +export type NavHrefParams = { + orgId?: string; + niceId?: string; + resourceId?: string; + userId?: string; + apiKeyId?: string; + clientId?: string; +}; + +export function hydrateNavHref( + val: string | undefined, + params: NavHrefParams +): string | undefined { + if (!val) return undefined; + return val + .replace("{orgId}", params.orgId ?? "") + .replace("{niceId}", params.niceId ?? "") + .replace("{resourceId}", params.resourceId ?? "") + .replace("{userId}", params.userId ?? "") + .replace("{apiKeyId}", params.apiKeyId ?? "") + .replace("{clientId}", params.clientId ?? ""); +} + +export function navHrefParamsFromRoute( + params: Record +): NavHrefParams { + return { + orgId: params.orgId as string | undefined, + niceId: params.niceId as string | undefined, + resourceId: params.resourceId as string | undefined, + userId: params.userId as string | undefined, + apiKeyId: params.apiKeyId as string | undefined, + clientId: params.clientId as string | undefined + }; +}