Files
MobilityOps/frontend/src/components/Layout.tsx
T
NuklearRabbit d17af1c52a feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
2026-08-04 03:08:07 +02:00

353 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
booking: "bookings",
data_quality_issue: "quality",
section: "chevron",
};
// The backend only ever sends a stable code + raw data params (never English prose) --
// see app/api/routers/search.py. Localizing here means the label/detail always follow
// the operator's selected locale, in every namespace search results can point to.
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
return item.label;
}
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
switch (item.type) {
case "section":
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
case "vehicle":
return t("searchVehicleSummary", { ...item.detail_params });
case "booking":
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
case "data_quality_issue":
return t(`quality:ruleTypes.${item.detail_code}`, {
defaultValue: item.detail_code.replace(/_/g, " "),
});
default:
return item.detail_code;
}
}
interface NavItem {
to: string;
labelKey: string;
icon: IconName;
roles?: Role[];
}
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{
labelKey: "groups.operate",
items: [
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
],
},
{
labelKey: "groups.assure",
items: [
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
],
},
];
export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
const navGroups = useMemo(
() =>
NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))),
})).filter((group) => group.items.length > 0),
[user],
);
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchResults([]);
setSearchLoading(false);
setSearchError(false);
setActiveIndex(-1);
return;
}
setSearchLoading(true);
setSearchError(false);
const timeout = window.setTimeout(() => {
api
.get<{ query: string; results: SearchResultItem[] }>(
`/api/v1/search?q=${encodeURIComponent(query)}`,
)
.then((response) => {
setSearchResults(response.results);
setActiveIndex(-1);
})
.catch(() => setSearchError(true))
.finally(() => setSearchLoading(false));
}, 250);
return () => window.clearTimeout(timeout);
}, [searchQuery]);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
setSearchOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, []);
async function handleLogout() {
await logout();
navigate("/login");
}
async function handleDemoReset() {
setResetError(null);
setResetting(true);
try {
await api.post("/api/v1/demo/reset");
// The server invalidates the acting session as part of reset; drop local state the
// same way an explicit logout would and return to the login screen.
await logout();
navigate("/login");
} catch (err) {
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
}
}
function selectResult(item: SearchResultItem) {
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
navigate(item.link);
}
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
setSearchOpen(false);
return;
}
if (!searchOpen || searchResults.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => (i + 1) % searchResults.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const target = searchResults[activeIndex] ?? searchResults[0];
if (target) selectResult(target);
}
}
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
</div>
<div className="sidebar-language">
<LanguageSwitcher />
</div>
<nav aria-label={t("primaryNavLabel")}>
{navGroups.map((group) => (
<div className="nav-group" key={group.labelKey}>
<p>{t(group.labelKey)}</p>
<ul>
{group.items.map((item) => (
<li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} />
<span>{t(item.labelKey)}</span>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
<p>{t("resetConfirmBody")}</p>
<button type="button" onClick={handleDemoReset} disabled={resetting}>
{resetting ? t("resetting") : t("resetConfirmYes")}
</button>
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
{t("resetCancel")}
</button>
</div>
)}
</div>
)}
</aside>
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
<Icon name="menu" />
</button>
<div className="global-search" role="search" ref={searchBox}>
<Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
<input
id="global-search-input"
ref={searchInput}
type="search"
role="combobox"
aria-expanded={searchOpen}
aria-controls="global-search-results"
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
value={searchQuery}
placeholder={t("searchPlaceholder")}
onFocus={() => setSearchOpen(true)}
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchOpen(true);
}}
onKeyDown={handleSearchKeyDown}
/>
<kbd>{t("searchShortcutHint")}</kbd>
{searchOpen && searchQuery.trim() && (
<div className="search-results" id="global-search-results" role="listbox">
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
{!searchLoading && !searchError && searchResults.length === 0 && (
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
)}
{!searchLoading &&
!searchError &&
searchResults.map((item, index) => (
<button
key={`${item.type}-${item.link}`}
id={`search-result-${index}`}
role="option"
aria-selected={index === activeIndex}
type="button"
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectResult(item)}
>
<Icon name={SEARCH_ICON[item.type]} />
<span className="search-result-copy">
<strong>{searchResultLabel(t, item)}</strong>
<small>{searchResultDetail(t, item)}</small>
</span>
</button>
))}
</div>
)}
</div>
<div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger />
<DemoBadge />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && (
<div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</div>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
</div>
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
{mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{t(item.labelKey)}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>{t("more")}</span>
</button>
</nav>
<DemoGuide />
</div>
);
}