mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 15:05:34 +00:00
Add the two-line packet table row
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { formatHex } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { ScopeTag } from "../../components/ScopeTag";
|
||||
import { payloadTypeVariant } from "../../components/badge-utils";
|
||||
import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import { GRID_TEMPLATE } from "./packet-grid";
|
||||
import { PacketPathLine } from "./PacketPathLine";
|
||||
|
||||
interface PacketTableRowProps {
|
||||
packet: PacketSummary;
|
||||
expanded: boolean;
|
||||
isFresh?: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2
|
||||
// is the latest path. The whole row is the expansion click target.
|
||||
export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) {
|
||||
const observer = packet.latestObserver;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-b ${
|
||||
expanded
|
||||
? "border-primary bg-primary/10"
|
||||
: isFresh
|
||||
? "packet-fresh border-border-subtle"
|
||||
: "border-border-subtle hover:bg-bg-raised/50"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="grid w-full items-center gap-x-3 px-3 py-1.5 text-left text-[11px] cursor-pointer"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
<span className={`text-text-dim transition-transform ${expanded ? "rotate-90" : ""}`} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
|
||||
{formatHex(packet.packetHash)}
|
||||
</span>
|
||||
<span>
|
||||
<Badge variant={payloadTypeVariant(packet.payloadType)}>
|
||||
{PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-text-muted uppercase tracking-wider">
|
||||
{packet.routeTypeName || "Unknown"}
|
||||
{packet.scope && <ScopeTag>{packet.scope}</ScopeTag>}
|
||||
</span>
|
||||
<span className="font-mono text-text-muted">×{packet.observationCount}</span>
|
||||
<span className="text-text-normal truncate">
|
||||
{observer ? (observer.displayName ?? observer.id.slice(0, 8)) : <span className="text-text-dim">—</span>}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-primary tracking-wider">
|
||||
{observer?.iata ?? <span className="text-text-dim font-normal">—</span>}
|
||||
</span>
|
||||
<span className="text-right text-text-muted">
|
||||
<Timestamp value={packet.lastHeardAt} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="px-3 pb-1.5 pl-[2.25rem]">
|
||||
<PacketPathLine packet={packet} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PacketTableRow } from "../../../src/features/packets/PacketTableRow";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
|
||||
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
|
||||
packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over,
|
||||
});
|
||||
|
||||
describe("PacketTableRow", () => {
|
||||
it("exposes one button carrying the expansion state", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("toggles on click", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders line 2 even when there is no path data, so row height is constant", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the observer id when there is no display name", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("abcdef12")).toBeInTheDocument();
|
||||
expect(screen.getByText("YVR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers the observer display name over the id", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", displayName: "Cypress Peak", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("Cypress Peak")).toBeInTheDocument();
|
||||
expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the expanded state on the button and chevron", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={true} onToggle={() => {}} />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("›")).toHaveClass("rotate-90");
|
||||
});
|
||||
|
||||
it("marks a fresh row with the pulse class", () => {
|
||||
const { container } = render(<PacketTableRow packet={pkt()} expanded={false} isFresh onToggle={() => {}} />);
|
||||
expect(container.querySelector(".packet-fresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a scope tag when the packet has a scope", () => {
|
||||
render(<PacketTableRow packet={pkt({ scope: "#bc" })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("#bc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the raw payload type name for an unrecognized payload type", () => {
|
||||
render(<PacketTableRow packet={pkt({ payloadType: 99, payloadTypeName: "CUSTOM_99" })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("CUSTOM_99")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to Unknown when routeTypeName is empty", () => {
|
||||
render(<PacketTableRow packet={pkt({ routeTypeName: "" })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("Unknown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user