From 39eb0b7a7054557a7777f8a666ab412b0f0d426f Mon Sep 17 00:00:00 2001 From: exzvor Date: Tue, 2 Jun 2026 21:53:48 +0300 Subject: [PATCH] fix(health): make +N more and the critical/warning pills interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported via Habr (#34): on the Health dashboard the "+N more" indicator and the "N critical / N warning" header pills looked actionable but did nothing. - BloatCard's "+N more" is now a button that expands the list to all rows (and collapses again); tone is still computed over all rows so severity doesn't drift when expanding. - The critical/warning header pills are now buttons that scroll to the first card of that severity and briefly flash it. Card roots carry data-card-tone (CardShell) and DOM order matches display order, so the first match is the first such card; the scroll respects prefers-reduced-motion. Also stubs window.localStorage in BloatCard.test (the runner doesn't expose it, which crashed any card test rendering ActionButton via isEasyMode) — fixes two pre-existing failures in that file. Fixes #34 Signed-off-by: exzvor --- src/features/health/HealthHeader.test.tsx | 25 ++++++++++++- src/features/health/HealthHeader.tsx | 35 +++++++++++++++--- src/features/health/cards/BloatCard.test.tsx | 37 +++++++++++++++++++- src/features/health/cards/BloatCard.tsx | 32 +++++++++++++---- src/features/health/cards/CardShell.tsx | 8 ++++- src/i18n/locales/en.json | 4 ++- src/i18n/locales/ru.json | 4 ++- src/styles/design.css | 32 +++++++++++++++++ 8 files changed, 162 insertions(+), 15 deletions(-) diff --git a/src/features/health/HealthHeader.test.tsx b/src/features/health/HealthHeader.test.tsx index c709f45..4b1713c 100644 --- a/src/features/health/HealthHeader.test.tsx +++ b/src/features/health/HealthHeader.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import i18n from "../../i18n"; import type { Connection } from "../../lib/tauri"; import { useConnections } from "../connections/store"; -import { HealthHeader } from "./HealthHeader"; +import { HealthHeader, jumpToTone } from "./HealthHeader"; import { useHealth } from "./store"; const fakeConn = ( @@ -177,4 +177,27 @@ describe("HealthHeader", () => { }); expect(setRefreshInterval).toHaveBeenCalledWith("c1", null); }); + + it("jumpToTone scrolls to and flashes the first card of the tone (#34)", () => { + const other = document.createElement("div"); + other.setAttribute("data-card-tone", "warn"); + const target = document.createElement("div"); + target.setAttribute("data-card-tone", "danger"); + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + document.body.append(other, target); + try { + jumpToTone("danger"); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(target.classList.contains("health-card-flash")).toBe(true); + } finally { + other.remove(); + target.remove(); + } + }); + + it("jumpToTone is a no-op when no card of that tone exists", () => { + // No data-card-tone elements in the document → must not throw. + expect(() => jumpToTone("danger")).not.toThrow(); + }); }); diff --git a/src/features/health/HealthHeader.tsx b/src/features/health/HealthHeader.tsx index 2d2f38b..0beaf11 100644 --- a/src/features/health/HealthHeader.tsx +++ b/src/features/health/HealthHeader.tsx @@ -29,6 +29,21 @@ function getEnvForConnection( return found?.environment ?? "local"; } +/** + * Scroll to the first Health card of the given severity and briefly flash it, + * so the critical/warning pills act as jump-to navigation (#34). Card roots + * carry `data-card-tone` (set in CardShell) and the DOM order matches the + * dashboard display order, so the first match is the first such card. + */ +export function jumpToTone(tone: "danger" | "warn"): void { + const el = document.querySelector(`[data-card-tone="${tone}"]`); + if (!el) return; + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" }); + el.classList.add("health-card-flash"); + window.setTimeout(() => el.classList.remove("health-card-flash"), 1200); +} + function rtfFor(locale: string): Intl.RelativeTimeFormat | null { if (typeof Intl === "undefined" || !Intl.RelativeTimeFormat) return null; try { @@ -151,14 +166,26 @@ export function HealthHeader({
{counts.danger > 0 ? ( - + ) : null} {counts.warn > 0 ? ( - + ) : null} {counts.ok > 0 ? ( diff --git a/src/features/health/cards/BloatCard.test.tsx b/src/features/health/cards/BloatCard.test.tsx index 30dd9c1..64faca1 100644 --- a/src/features/health/cards/BloatCard.test.tsx +++ b/src/features/health/cards/BloatCard.test.tsx @@ -1,8 +1,24 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, describe, expect, it } from "vitest"; import i18n from "../../../i18n"; import { BloatCard } from "./BloatCard"; +// jsdom in this runner doesn't expose window.localStorage; ActionButton's +// easy-mode check (isEasyMode) reads it, which otherwise crashes any card test +// that renders rows. Provide a minimal in-memory stub. +if (typeof window !== "undefined" && !window.localStorage) { + const store = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, String(v)), + removeItem: (k: string) => store.delete(k), + clear: () => store.clear(), + }, + }); +} + beforeAll(async () => { await i18n.changeLanguage("en"); }); @@ -55,4 +71,23 @@ describe("BloatCard", () => { ); expect(screen.getByTestId("health-card-bloat-status").getAttribute("data-tone")).toBe("warn"); }); + + it("clicking +N more expands to all rows, then collapses (#34)", () => { + const rows = Array.from({ length: 5 }, (_, i) => ({ + schema: "public", + table: `t${i}`, + bloatPct: 35 - i, + bloatBytes: (i + 1) * 1024 * 1024, + })); + render( + , + ); + expect(screen.getAllByTestId("bloat-row")).toHaveLength(3); + + fireEvent.click(screen.getByTestId("bloat-more")); + expect(screen.getAllByTestId("bloat-row")).toHaveLength(5); + + fireEvent.click(screen.getByTestId("bloat-more")); + expect(screen.getAllByTestId("bloat-row")).toHaveLength(3); + }); }); diff --git a/src/features/health/cards/BloatCard.tsx b/src/features/health/cards/BloatCard.tsx index 1b74aac..755f005 100644 --- a/src/features/health/cards/BloatCard.tsx +++ b/src/features/health/cards/BloatCard.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import { type JSX, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActionButton } from "../actions/ActionButton"; import type { CardState } from "../store"; @@ -30,14 +30,18 @@ export function BloatCard({ connId, state }: CardProps): JSX.Element { ); } - const top3 = rows.slice(0, 3); - const more = rows.length - top3.length; + const TOP_N = 3; + const [expanded, setExpanded] = useState(false); + const visible = expanded ? rows : rows.slice(0, TOP_N); + const more = rows.length - TOP_N; + // tone is computed over ALL rows (not just the visible slice) so expanding + // the list never changes the card's severity. const maxPct = rows.reduce((acc, r) => Math.max(acc, r.bloatPct), 0); const tone = bloatTone(maxPct); const body = (
    - {top3.map((r) => ( + {visible.map((r) => (
  • ))} {more > 0 ? ( -
  • - {t("health.card.bloat.more", { count: more })} +
  • +
  • ) : null}
diff --git a/src/features/health/cards/CardShell.tsx b/src/features/health/cards/CardShell.tsx index 4eec079..172da68 100644 --- a/src/features/health/cards/CardShell.tsx +++ b/src/features/health/cards/CardShell.tsx @@ -102,7 +102,13 @@ export function CardShell({ cardId, connId, state, body, status }: CardShellProp } return ( -
+
{/* shimmer keyframes — scoped via plain