Add the expanded packet region with skeleton, error and empty states

This commit is contained in:
MrAlders0n committed 2026-07-27 19:37:18 -04:00
1 parent 16ee220abe
commit f400dc5f76
2 files changed
+207

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
import type { PacketSummary } from "../../types/api";
import { formatPropagation } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { usePacketDetail } from "./usePacketDetail";
import { ObservationTable } from "./ObservationTable";
// Roughly what fits the scroll cap; observations are unbounded server-side.
const SKELETON_ROW_CAP = 12;
const ACTION_BUTTON_CLASS =
"border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors";
interface Props {
packet: PacketSummary;
onOpenAnalyzer: () => void;
onViewPath: () => void;
selectedObservationId: number | null;
onSelectObservation: (id: number) => void;
}
// Expanded region under a packet row: a summary-driven timing strip (instant, no fetch wait) plus
// the per-observer table, which does wait on usePacketDetail.
export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) {
const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash);
// firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is
// already in ms for formatPropagation -- no *1000 here.
const spread = packet.lastHeardAt - packet.firstHeardAt;
const ready = !isLoading && !isError;
// The summary already knows the count is zero, so skip the fetch-driven states entirely rather
// than showing a blank (0-row) skeleton while it loads.
const noObservations = packet.observationCount === 0;
const emptyState = <div className="text-[10px] text-text-dim py-2">No observations</div>;
return (
<div className="bg-bg-surface border-l-2 border-primary pl-6 pr-3 py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-text-muted pb-2">
<span>first <Timestamp value={packet.firstHeardAt} /></span>
<span>last <Timestamp value={packet.lastHeardAt} /></span>
<span>spread {formatPropagation(spread)}</span>
<button type="button" onClick={onOpenAnalyzer} disabled={!ready} className={ACTION_BUTTON_CLASS}>
Open analyzer
</button>
<button type="button" onClick={onViewPath} disabled={!ready} className={ACTION_BUTTON_CLASS}>
View path on map
</button>
</div>
<div className="max-h-[360px] overflow-y-auto">
{noObservations ? (
emptyState
) : isLoading ? (
<div>
{Array.from({ length: Math.min(packet.observationCount, SKELETON_ROW_CAP) }).map((_, i) => (
<div
key={i}
data-testid="observation-skeleton"
className="h-[22px] border-t border-border-subtle animate-pulse bg-bg-raised/30"
/>
))}
</div>
) : isError ? (
<div className="flex items-center gap-3 text-[10px] text-danger py-2">
<span>Failed to load observations</span>
<button type="button" onClick={() => refetch()} className="border border-border rounded-sm px-2 py-0.5 bg-bg-raised cursor-pointer">
Retry
</button>
</div>
) : data && data.observations.length === 0 ? (
emptyState
) : data ? (
<ObservationTable observations={data.observations} selectedId={selectedObservationId} onSelect={onSelectObservation} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PacketExpansion } from "../../../src/features/packets/PacketExpansion";
import type { PacketSummary, Observation } from "../../../src/types/api";
const usePacketDetail = vi.fn();
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
usePacketDetail: (h: string | null) => usePacketDetail(h),
}));
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
routeType: 1, routeTypeName: "FLOOD",
firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 3, ...over,
});
const obs = (id: number, over: Partial<Observation> = {}): Observation => ({
id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR",
heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 },
sourceBroker: "b1", resolvedPath: [], ...over,
});
const props = {
packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {},
selectedObservationId: null, onSelectObservation: () => {},
};
beforeEach(() => usePacketDetail.mockReset());
describe("PacketExpansion", () => {
it("shows one skeleton row per expected observation while loading", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} />);
expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(3);
});
it("caps skeleton rows at the scroll cap for a large observation count", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} packet={pkt({ observationCount: 50 })} />);
expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(12);
});
it("shows an error line with retry on failure", () => {
usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
render(<PacketExpansion {...props} />);
expect(screen.getByText("Failed to load observations")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("calls refetch when retry is clicked", () => {
const refetch = vi.fn();
usePacketDetail.mockReturnValue({ isError: true, refetch });
render(<PacketExpansion {...props} />);
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(refetch).toHaveBeenCalled();
});
it("shows an empty state when the packet has no observations", () => {
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
render(<PacketExpansion {...props} />);
expect(screen.getByText("No observations")).toBeInTheDocument();
});
it("shows the empty state immediately when the summary count is zero, without a skeleton", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} packet={pkt({ observationCount: 0 })} />);
expect(screen.getByText("No observations")).toBeInTheDocument();
expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument();
});
it("renders the timing strip from the summary without waiting for the fetch", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} />);
expect(screen.getByText(/spread/i)).toBeInTheDocument();
});
it("formats the spread as first/last converted from milliseconds, not re-scaled", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} packet={pkt({ firstHeardAt: 1000, lastHeardAt: 3500 })} />);
expect(screen.getByText("spread 2.500s")).toBeInTheDocument();
});
it("renders the observation table once data resolves", () => {
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } });
render(<PacketExpansion {...props} />);
expect(screen.getByText("Observer 1")).toBeInTheDocument();
expect(screen.getByText("Observer 2")).toBeInTheDocument();
});
it("wires selectedObservationId and onSelectObservation through to the observation table", () => {
const onSelectObservation = vi.fn();
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } });
render(<PacketExpansion {...props} selectedObservationId={2} onSelectObservation={onSelectObservation} />);
const rows = screen.getAllByRole("row").slice(1);
expect(rows[1]).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByText("Observer 1"));
expect(onSelectObservation).toHaveBeenCalledWith(1);
});
it("disables both action buttons while loading", () => {
usePacketDetail.mockReturnValue({ isLoading: true });
render(<PacketExpansion {...props} />);
expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled();
expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
});
it("disables both action buttons on error", () => {
usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
render(<PacketExpansion {...props} />);
expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled();
expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
});
it("enables both action buttons once the fetch resolves", () => {
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
render(<PacketExpansion {...props} />);
expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled();
expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled();
});
it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => {
const onOpenAnalyzer = vi.fn();
const onViewPath = vi.fn();
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
render(<PacketExpansion {...props} onOpenAnalyzer={onOpenAnalyzer} onViewPath={onViewPath} />);
fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
expect(onOpenAnalyzer).toHaveBeenCalledTimes(1);
expect(onViewPath).toHaveBeenCalledTimes(1);
});
});