From f3ac28420225362225899dd02cfebe764f76678c Mon Sep 17 00:00:00 2001 From: Lince Mathew Date: Sun, 26 Jul 2026 15:38:28 +0530 Subject: [PATCH 1/5] Implement Mega Menu and MCP Integration LiveReview Pre-Commit Check: skipped manually --- internal/api/teams_config_handler.go | 12 +- ui/package-lock.json | 10 + ui/package.json | 1 + .../Connector/ProviderSelection.tsx | 2 +- ui/src/components/Navbar/NavMegaMenu.tsx | 284 ++++++++++++++++++ ui/src/components/Navbar/Navbar.tsx | 127 ++++++-- ui/src/components/Navbar/megaMenuData.ts | 217 +++++++++++++ .../OrganizationSelector.tsx | 2 +- ui/src/components/UIPrimitives.tsx | 59 ++-- ui/src/pages/AIProviders/AIProviders.tsx | 8 +- ui/src/pages/Reports/TaxonomyReports.tsx | 9 + ui/src/pages/Settings/MCPIntegrationTab.tsx | 86 ++++++ ui/src/pages/Settings/Settings.tsx | 17 ++ 13 files changed, 778 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/Navbar/NavMegaMenu.tsx create mode 100644 ui/src/components/Navbar/megaMenuData.ts create mode 100644 ui/src/pages/Settings/MCPIntegrationTab.tsx diff --git a/internal/api/teams_config_handler.go b/internal/api/teams_config_handler.go index 152c47a4..6add757b 100644 --- a/internal/api/teams_config_handler.go +++ b/internal/api/teams_config_handler.go @@ -23,13 +23,13 @@ func NewTeamsConfigHandler(db *sql.DB) *TeamsConfigHandler { } } -type teamsConfigResponse struct { +type TeamsConfigResponse struct { Configured bool `json:"configured"` BotAppID string `json:"bot_app_id,omitempty"` TenantID string `json:"tenant_id,omitempty"` } -type teamsConfigUpdateRequest struct { +type TeamsConfigUpdateRequest struct { BotAppID string `json:"bot_app_id"` BotPassword string `json:"bot_password"` } @@ -43,12 +43,12 @@ func (h *TeamsConfigHandler) GetTeamsConfig(c echo.Context) error { cfg, err := h.storage.GetTeamsConfig(c.Request().Context(), permCtx.OrgID) if err != nil { if err == sql.ErrNoRows { - return c.JSON(http.StatusOK, teamsConfigResponse{Configured: false}) + return c.JSON(http.StatusOK, TeamsConfigResponse{Configured: false}) } return echo.NewHTTPError(http.StatusInternalServerError, "failed to get Teams config") } - return c.JSON(http.StatusOK, teamsConfigResponse{ + return c.JSON(http.StatusOK, TeamsConfigResponse{ Configured: true, BotAppID: cfg.BotAppID, TenantID: cfg.TenantID, @@ -65,7 +65,7 @@ func (h *TeamsConfigHandler) UpdateTeamsConfig(c echo.Context) error { return echo.NewHTTPError(http.StatusForbidden, "only owners can configure Teams integration") } - var req teamsConfigUpdateRequest + var req TeamsConfigUpdateRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") } @@ -97,7 +97,7 @@ func (h *TeamsConfigHandler) UpdateTeamsConfig(c echo.Context) error { log.Printf("[TeamsConfig] Org %d: Teams bot configured with app ID %s", permCtx.OrgID, req.BotAppID) - return c.JSON(http.StatusOK, teamsConfigResponse{ + return c.JSON(http.StatusOK, TeamsConfigResponse{ Configured: true, BotAppID: cfg.BotAppID, }) diff --git a/ui/package-lock.json b/ui/package-lock.json index 2d19213f..94c9d3b0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.7.0", "react-redux": "^9.2.0", "react-router": "^6.30.4", "react-router-dom": "^6.30.4", @@ -17109,6 +17110,15 @@ "react-dom": ">=16" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/ui/package.json b/ui/package.json index 5986a447..5498eee9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -103,6 +103,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.7.0", "react-redux": "^9.2.0", "react-router": "^6.30.4", "react-router-dom": "^6.30.4", diff --git a/ui/src/components/Connector/ProviderSelection.tsx b/ui/src/components/Connector/ProviderSelection.tsx index 23f65667..eef35999 100644 --- a/ui/src/components/Connector/ProviderSelection.tsx +++ b/ui/src/components/Connector/ProviderSelection.tsx @@ -23,7 +23,7 @@ const ProviderSelection: React.FC = () => { - + +); + +// A group nested inside another group (not used by any current data, but rendered flat/always-open +// as a fallback so a future 3-level entry degrades gracefully instead of needing a 3rd hover layer). +const NestedGroupFallback: React.FC<{ node: MegaMenuGroupNode; goTo: (path: string) => void }> = ({ node, goTo }) => ( +
  • +
    {node.name}
    + +
  • +); + +// Renders one top-level nav category. Which of its groups is expanded is tracked here, at the +// column level, and only collapses when the mouse leaves the WHOLE column (not a single row) — +// otherwise moving from an open group down to a sibling link collapses the group mid-transit and +// the target jumps out from under the cursor. +const SectionColumn: React.FC<{ section: MegaMenuSection; goTo: (path: string) => void; isHighlighted?: boolean }> = ({ section, goTo, isHighlighted }) => { + // Groups a user has hovered stay expanded — switching to a sibling group must never collapse + // another one, since collapsing shifts content under the cursor and closes everything. + const [openGroups, setOpenGroups] = useState>(new Set()); + const closeTimerRef = useRef | null>(null); + + const cancelClose = () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + + const scheduleClose = () => { + cancelClose(); + closeTimerRef.current = setTimeout(() => setOpenGroups(new Set()), 300); + }; + + const openGroup = (name: string) => { + cancelClose(); + setOpenGroups((prev) => (prev.has(name) ? prev : new Set(prev).add(name))); + }; + + return ( +
    + {section.items && section.items.length > 0 && ( + <> +
    + {section.icon} + {section.name} +
    +
      + {section.items.map((node: MegaMenuNode) => { + if (node.kind === 'link') { + return ; + } + const isOpen = openGroups.has(node.name); + return ( +
    • +
      openGroup(node.name)} + className={classNames( + 'flex cursor-default items-center gap-1 whitespace-nowrap rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors', + isOpen ? 'bg-slate-700/60 text-white' : 'text-slate-300 hover:bg-slate-700/60 hover:text-white' + )} + > + + {node.icon && {node.icon}} + {node.name} + + + + +
      + {isOpen && ( +
        + {node.children.map((child) => ( + child.kind === 'link' + ? + : + ))} +
      + )} +
    • + ); + })} +
    + + )} +
    + ); +}; + +const SearchResultRow: React.FC<{ entry: MegaMenuSearchEntry; isFirst: boolean; goTo: (path: string) => void }> = ({ entry, isFirst, goTo }) => ( +
  • + +
  • +); + +export const NavMegaMenu: React.FC = ({ isOpen, onClose, sections, highlightKey, searchFocusToken }) => { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + const searchEntries = useMemo(() => flattenMegaMenuSections(sections), [sections]); + + const results = useMemo(() => { + const trimmed = query.trim(); + if (!trimmed) return []; + return searchEntries + .map((entry) => ({ entry, score: scoreSearchEntry(entry, trimmed) })) + .filter((s): s is { entry: MegaMenuSearchEntry; score: number } => s.score !== null) + .sort((a, b) => a.score - b.score) + .slice(0, 30) + .map((s) => s.entry); + }, [searchEntries, query]); + + useEffect(() => { + if (!isOpen) setQuery(''); + }, [isOpen]); + + useEffect(() => { + if (searchFocusToken) inputRef.current?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchFocusToken]); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const goTo = (path: string) => { + navigate(path); + onClose(); + }; + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && results.length > 0) { + goTo(results[0].path); + } + }; + + return ( +
    +
    +
    + + + + setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search actions..." + className="w-full rounded-lg border border-slate-700/60 bg-slate-800/70 py-2 pl-10 pr-16 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + Ctrl K + +
    +
    + + {query.trim() ? ( +
    +
    + {results.length === 0 ? ( +

    No matches for "{query}"

    + ) : ( +
      + {results.map((entry, index) => ( + + ))} +
    + )} +
    +
    + ) : ( +
    + {sections + .filter((section) => section.items && section.items.length > 0) + .map((section) => ( + + ))} +
    + )} +
    + ); +}; + +export default NavMegaMenu; diff --git a/ui/src/components/Navbar/Navbar.tsx b/ui/src/components/Navbar/Navbar.tsx index 7973cd82..133a7667 100644 --- a/ui/src/components/Navbar/Navbar.tsx +++ b/ui/src/components/Navbar/Navbar.tsx @@ -8,6 +8,8 @@ import { useOrgContext } from '../../hooks/useOrgContext'; import { isCloudMode } from '../../utils/deploymentMode'; import apiClient from '../../api/apiClient'; import { useAppSelector } from '../../store/configureStore'; +import { buildMegaMenuSections, filterMegaMenuSection, MegaMenuContext } from './megaMenuData'; +import { NavMegaMenu } from './NavMegaMenu'; type NavbarBillingStatusResponse = { billing: { @@ -573,6 +575,11 @@ const testNavLink: NavLink = { export const Navbar: React.FC = ({ title, activePage = 'dashboard', onNavigate, onLogout }) => { const [isOpen, setIsOpen] = useState(false); + const [isMegaMenuOpen, setIsMegaMenuOpen] = useState(false); + const [hoveredNavKey, setHoveredNavKey] = useState(null); + const [searchFocusToken, setSearchFocusToken] = useState(0); + const megaMenuCloseTimerRef = useRef | null>(null); + const navRef = useRef(null); const { isDevMode } = useSystemInfo(); const { isSuperAdmin, currentOrg } = useOrgContext(); @@ -593,13 +600,78 @@ export const Navbar: React.FC = ({ title, activePage = 'dashboard', // Conditionally include test middleware link based on dev mode const navLinks = isDevMode ? [...filteredBaseLinks, testNavLink] : filteredBaseLinks; + const megaMenuContext: MegaMenuContext = { + isSuperAdmin, + hasOrg: Boolean(currentOrg), + orgRole: currentOrg?.role, + isDevMode, + }; + + const filteredMegaMenuSections = buildMegaMenuSections() + .filter(section => { + if (section.devOnly) return isDevMode; + if (section.requiresSuperAdmin) return isSuperAdmin; + if (section.requiresOwnerOrAdmin) return canManageCurrentOrg; + return true; + }) + .map(section => filterMegaMenuSection(section, megaMenuContext)); + const handleNavClick = (target: string) => { if (onNavigate) onNavigate(target); setIsOpen(false); + setIsMegaMenuOpen(false); + }; + + const openMegaMenu = () => { + if (megaMenuCloseTimerRef.current) { + clearTimeout(megaMenuCloseTimerRef.current); + megaMenuCloseTimerRef.current = null; + } + setIsMegaMenuOpen(true); + }; + + const closeMegaMenuSoon = () => { + if (megaMenuCloseTimerRef.current) { + clearTimeout(megaMenuCloseTimerRef.current); + } + megaMenuCloseTimerRef.current = setTimeout(() => { + // Don't close while focus is still inside the nav (e.g. the search input) — the + // results list resizing as you type can shift the panel's edge out from under a + // stationary cursor and fire a mouseleave even though you're actively typing. + if (navRef.current && navRef.current.contains(document.activeElement)) { + megaMenuCloseTimerRef.current = null; + return; + } + setIsMegaMenuOpen(false); + setHoveredNavKey(null); + megaMenuCloseTimerRef.current = null; + }, 200); }; + // Ctrl+K / Cmd+K opens the mega menu and focuses its search box from anywhere in the app. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + if (megaMenuCloseTimerRef.current) { + clearTimeout(megaMenuCloseTimerRef.current); + megaMenuCloseTimerRef.current = null; + } + setIsMegaMenuOpen(true); + setSearchFocusToken((token) => token + 1); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + return ( -