fix(web): keep GA tenant filter usable through the zero-session empty state

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-16 20:18:41 +02:00
parent 6a8358a2a3
commit 2a327902b8
3 changed files with 192 additions and 112 deletions
@@ -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<string | null>(null);
const columnSelectorRef = useRef<HTMLDivElement>(null);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const tenantDropdownRef = useRef<HTMLDivElement>(null);
const [showTenantSuggestions, setShowTenantSuggestions] = useState(false);
const [tenantSelectedIndex, setTenantSelectedIndex] = useState(-1);
const searchDropdownRef = useRef<HTMLDivElement>(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<SearchSuggestion[]>(() => {
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 && (
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1" ref={tenantDropdownRef}>
<input
type="text"
placeholder="Filter by Tenant ID or domain name"
value={tenantIdFilter}
onChange={(e) => {
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 && (
<button
onClick={() => {
onTenantIdFilterClear();
setShowTenantSuggestions(false);
}}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear tenant filter"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Tenant suggestions dropdown */}
{showTenantSuggestions && tenantSuggestions.length > 0 && (
<div className="absolute z-50 mt-1 w-full bg-white border border-purple-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{tenantSuggestions.map((t, idx) => (
<button
key={t.tenantId}
onClick={() => {
onTenantIdFilterChange(t.tenantId);
setShowTenantSuggestions(false);
setTenantSelectedIndex(-1);
}}
className={`w-full text-left px-4 py-2.5 flex flex-col gap-0.5 transition-colors ${
idx === tenantSelectedIndex
? "bg-purple-100"
: "hover:bg-purple-50"
}`}
>
<span className="text-sm font-medium text-gray-900">{t.domainName}</span>
<span className="text-xs text-gray-500 font-mono">{t.tenantId}</span>
</button>
))}
</div>
)}
</div>
<button
onClick={() => {
setShowTenantSuggestions(false);
onTenantIdFilterSubmit();
}}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium shrink-0"
>
Filter
</button>
</div>
<TenantFilterBar
tenantIdFilter={tenantIdFilter}
onChange={onTenantIdFilterChange}
onSubmit={onTenantIdFilterSubmit}
onClear={onTenantIdFilterClear}
tenantList={tenantList}
/>
)}
{/* Search Input with Fuzzy Suggestions */}
@@ -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<HTMLDivElement>(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 (
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1" ref={dropdownRef}>
<input
type="text"
placeholder="Filter by Tenant ID or domain name"
value={tenantIdFilter}
onChange={(e) => {
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 && (
<button
onClick={() => {
onClear();
setShowSuggestions(false);
}}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear tenant filter"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Tenant suggestions dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 mt-1 w-full bg-white border border-purple-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{suggestions.map((t, idx) => (
<button
key={t.tenantId}
onClick={() => {
onChange(t.tenantId);
setShowSuggestions(false);
setSelectedIndex(-1);
}}
className={`w-full text-left px-4 py-2.5 flex flex-col gap-0.5 transition-colors ${
idx === selectedIndex
? "bg-purple-100"
: "hover:bg-purple-50"
}`}
>
<span className="text-sm font-medium text-gray-900">{t.domainName}</span>
<span className="text-xs text-gray-500 font-mono">{t.tenantId}</span>
</button>
))}
</div>
)}
</div>
<button
onClick={() => {
setShowSuggestions(false);
onSubmit();
}}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium shrink-0"
>
Filter
</button>
</div>
);
}
@@ -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() {
<TipOfTheDay />
{/* 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 && (
<div className="mt-4 bg-white shadow rounded-lg p-6">
<TenantFilterBar
tenantIdFilter={tenantIdFilter}
onChange={applyTenantIdFilter}
onSubmit={submitTenantIdFilter}
onClear={clearTenantIdFilter}
tenantList={tenantList}
/>
{submittedTenantIdFilter.trim() && (
<p className="text-sm text-gray-500">
No sessions found for this tenant yet. Clear the filter or enter another tenant to continue.
</p>
)}
</div>
)}
{/* Welcome message - only show when no sessions */}
{!loading && sessions.length === 0 && <WelcomeMessage />}