From 2a327902b80667b80ff4e122421418ac54017e77 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 16 Jul 2026 20:18:41 +0200 Subject: [PATCH] fix(web): keep GA tenant filter usable through the zero-session empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dashboard flow gaps for cross-tenant (GA/MSP) tenant drilling: 1. Emptying the tenant filter input by hand (backspace/delete) only widened the client-side view over the already-loaded filtered sessions; the backend scope stayed on the old tenant until Filter was pressed again. An emptied input now acts as an implicit clear and refetches all sessions. 2. When a filtered tenant had no sessions, the whole SessionTable — and with it the tenant filter bar — unmounted, leaving only the welcome view with no way to clear or change the filter without leaving the page. The filter bar is now extracted into TenantFilterBar and stays on screen (with a short hint) above the welcome message in cross-tenant mode. Co-Authored-By: Claude Fable 5 --- .../app/dashboard/components/SessionTable.tsx | 122 ++------------ .../dashboard/components/TenantFilterBar.tsx | 152 ++++++++++++++++++ .../app/dashboard/page.tsx | 30 ++++ 3 files changed, 192 insertions(+), 112 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index e2fed855..d127cb22 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -7,6 +7,7 @@ import { trackEvent } from "@/lib/appInsights"; import { fuzzyContains } from "@/utils/fuzzy"; import { buildUniqueValuesByField } from "./uniqueValuesByField"; import { SessionStatusBadge } from "@/components/SessionStatusBadge"; +import { TenantFilterBar } from "./TenantFilterBar"; // Column definition for the session table interface ColumnDef { @@ -150,9 +151,6 @@ export function SessionTable({ const [openFilterColumn, setOpenFilterColumn] = useState(null); const columnSelectorRef = useRef(null); const filterDropdownRef = useRef(null); - const tenantDropdownRef = useRef(null); - const [showTenantSuggestions, setShowTenantSuggestions] = useState(false); - const [tenantSelectedIndex, setTenantSelectedIndex] = useState(-1); const searchDropdownRef = useRef(null); const [showSearchSuggestions, setShowSearchSuggestions] = useState(false); const [searchSelectedIndex, setSearchSelectedIndex] = useState(-1); @@ -184,7 +182,6 @@ export function SessionTable({ // Defer expensive suggestion scans so rapid typing keeps the input responsive. const deferredSearchQuery = useDeferredValue(searchQuery); - const deferredTenantIdFilter = useDeferredValue(tenantIdFilter); const searchSuggestions = useMemo(() => { const q = deferredSearchQuery.trim().toLowerCase(); @@ -231,15 +228,6 @@ export function SessionTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [deferredSearchQuery, sessions]); - // Fuzzy-match tenants by domain name or tenant ID - const tenantSuggestions = useMemo(() => { - const q = deferredTenantIdFilter.trim().toLowerCase(); - if (q.length < 2 || tenantList.length === 0) return []; - return tenantList - .filter((t) => t.domainName.toLowerCase().includes(q) || t.tenantId.toLowerCase().includes(q)) - .slice(0, 8); - }, [deferredTenantIdFilter, tenantList]); - const tenantDomainById = useMemo( () => new Map(tenantList.map((t) => [t.tenantId, t.domainName])), [tenantList], @@ -259,18 +247,15 @@ export function SessionTable({ if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) { setOpenFilterColumn(null); } - if (tenantDropdownRef.current && !tenantDropdownRef.current.contains(e.target as Node)) { - setShowTenantSuggestions(false); - } if (searchDropdownRef.current && !searchDropdownRef.current.contains(e.target as Node)) { setShowSearchSuggestions(false); } } - if (showColumnSelector || openFilterColumn || showTenantSuggestions || showSearchSuggestions) { + if (showColumnSelector || openFilterColumn || showSearchSuggestions) { document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); } - }, [showColumnSelector, openFilterColumn, showTenantSuggestions, showSearchSuggestions]); + }, [showColumnSelector, openFilterColumn, showSearchSuggestions]); // Compute unique values for every filterable field in a single pass over sessions. // Memoized so that header re-renders pay only a map lookup, not N × O(sessions). @@ -425,100 +410,13 @@ export function SessionTable({ {/* Tenant ID Filter (Global Admin only) */} {globalAdminMode && ( -
-
- { - onTenantIdFilterChange(e.target.value); - setShowTenantSuggestions(true); - setTenantSelectedIndex(-1); - }} - onFocus={() => { - if (tenantSuggestions.length > 0) setShowTenantSuggestions(true); - }} - onKeyDown={(e) => { - if (showTenantSuggestions && tenantSuggestions.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setTenantSelectedIndex((i) => Math.min(i + 1, tenantSuggestions.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setTenantSelectedIndex((i) => Math.max(i - 1, -1)); - return; - } - if (e.key === "Enter" && tenantSelectedIndex >= 0) { - e.preventDefault(); - const selected = tenantSuggestions[tenantSelectedIndex]; - onTenantIdFilterChange(selected.tenantId); - setShowTenantSuggestions(false); - setTenantSelectedIndex(-1); - return; - } - if (e.key === "Escape") { - setShowTenantSuggestions(false); - return; - } - } - if (e.key === "Enter") { - setShowTenantSuggestions(false); - onTenantIdFilterSubmit(); - } - }} - className="w-full px-4 py-2 pr-10 border border-purple-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors font-mono text-sm" - /> - {tenantIdFilter && ( - - )} - {/* Tenant suggestions dropdown */} - {showTenantSuggestions && tenantSuggestions.length > 0 && ( -
- {tenantSuggestions.map((t, idx) => ( - - ))} -
- )} -
- -
+ )} {/* Search Input with Fuzzy Suggestions */} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx new file mode 100644 index 00000000..1da7d0a9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/TenantFilterBar.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useDeferredValue } from "react"; + +interface TenantFilterBarProps { + tenantIdFilter: string; + onChange: (value: string) => void; + onSubmit: () => void; + onClear: () => void; + tenantList: { tenantId: string; domainName: string }[]; +} + +/** + * Cross-tenant (Global Admin / delegated MSP) tenant filter input with fuzzy + * suggestions. Extracted from SessionTable so the dashboard can keep it on + * screen even when a filtered fetch returns zero sessions and the table itself + * is not rendered — otherwise a GA drilling through tenants has no way to + * clear or change the filter without leaving the page. + */ +export function TenantFilterBar({ + tenantIdFilter, + onChange, + onSubmit, + onClear, + tenantList, +}: TenantFilterBarProps) { + const dropdownRef = useRef(null); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + // Defer expensive suggestion scans so rapid typing keeps the input responsive. + const deferredTenantIdFilter = useDeferredValue(tenantIdFilter); + + // Fuzzy-match tenants by domain name or tenant ID + const suggestions = useMemo(() => { + const q = deferredTenantIdFilter.trim().toLowerCase(); + if (q.length < 2 || tenantList.length === 0) return []; + return tenantList + .filter((t) => t.domainName.toLowerCase().includes(q) || t.tenantId.toLowerCase().includes(q)) + .slice(0, 8); + }, [deferredTenantIdFilter, tenantList]); + + // Close suggestions on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowSuggestions(false); + } + } + if (showSuggestions) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showSuggestions]); + + return ( +
+
+ { + onChange(e.target.value); + setShowSuggestions(true); + setSelectedIndex(-1); + }} + onFocus={() => { + if (suggestions.length > 0) setShowSuggestions(true); + }} + onKeyDown={(e) => { + if (showSuggestions && suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, -1)); + return; + } + if (e.key === "Enter" && selectedIndex >= 0) { + e.preventDefault(); + const selected = suggestions[selectedIndex]; + onChange(selected.tenantId); + setShowSuggestions(false); + setSelectedIndex(-1); + return; + } + if (e.key === "Escape") { + setShowSuggestions(false); + return; + } + } + if (e.key === "Enter") { + setShowSuggestions(false); + onSubmit(); + } + }} + className="w-full px-4 py-2 pr-10 border border-purple-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors font-mono text-sm" + /> + {tenantIdFilter && ( + + )} + {/* Tenant suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((t, idx) => ( + + ))} +
+ )} +
+ +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 60eb9fcd..fb0ee4fc 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import { useNotifications } from "../../contexts/NotificationContext"; import { StatsCard } from "./components/StatsCards"; import { WelcomeMessage } from "./components/WelcomeMessage"; import { SessionTable } from "./components/SessionTable"; +import { TenantFilterBar } from "./components/TenantFilterBar"; import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals"; import TipOfTheDay from "./components/TipOfTheDay"; import { useAdminMode } from "@/hooks/useAdminMode"; @@ -249,6 +250,14 @@ function HomeContent() { const applyTenantIdFilter = (value: string) => { setTenantIdFilter(value); + // Emptying the input while a filter is submitted acts as an implicit clear: + // without this, deleting the text only widens the client-side view over the + // already-loaded (filtered) sessions — the backend scope would stay on the + // old tenant until the user hits Filter again on an empty box. + if (!value.trim() && submittedTenantIdFilter.trim()) { + setSubmittedTenantIdFilter(""); + refetchWith(""); + } }; const submitTenantIdFilter = () => { @@ -417,6 +426,27 @@ function HomeContent() { + {/* Zero sessions in a cross-tenant view: keep the tenant filter on screen so a + GA/MSP admin drilling through tenants can clear it or type the next tenant + without leaving the page (the SessionTable — which normally hosts this bar — + is not rendered when the filtered fetch came back empty). */} + {!loading && sessions.length === 0 && crossTenant && ( +
+ + {submittedTenantIdFilter.trim() && ( +

+ No sessions found for this tenant yet. Clear the filter or enter another tenant to continue. +

+ )} +
+ )} + {/* Welcome message - only show when no sessions */} {!loading && sessions.length === 0 && }