mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 13:02:11 +00:00
Show an error with retry, not an empty list, when packets, channels, messages or adverts fail to load (#178)
This commit is contained in:
1 parent
b8d404b63b
commit
008b2afad8
9 files changed
+74
-9
No files matched your search
@@ -253,7 +253,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
)}
|
||||
{!keyedLoading && filteredChannels.length === 0 && (
|
||||
{!keyedLoading && filteredChannels.length === 0 && !keyed.isError && !others.isError && (
|
||||
<p className="px-3 py-2 text-xs font-mono text-text-muted">{t("channels.noMatches")}</p>
|
||||
)}
|
||||
{(keyed.isError || others.isError) && <p role="alert" className="px-3 py-2 text-xs text-danger">{t("channels.loadError")}</p>}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
|
||||
<MessageRow key={msg.packetHash || msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} onAnalyze={onAnalyze} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
) : isError ? null : (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
|
||||
{t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick: ()
|
||||
|
||||
export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: string; onAnalyzePacket: (hash: string, observationId: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: adverts, isPending } = useQuery({
|
||||
const { data: adverts, isPending, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["observer-adverts", observerId],
|
||||
queryFn: () => getObserverAdverts(observerId, { limit: 50 }),
|
||||
staleTime: 30_000,
|
||||
@@ -41,7 +41,12 @@ export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: s
|
||||
<summary className="cursor-pointer font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("observerPage.advertsHeard")}</summary>
|
||||
<div className="mt-2.5">
|
||||
{isPending ? <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>
|
||||
: adverts?.items.length ? (
|
||||
: isError && !adverts?.items.length ? (
|
||||
<p role="alert" className="font-mono text-[13px] text-danger">
|
||||
{t("common.loadFailed")}{" · "}
|
||||
<button type="button" disabled={isFetching} className="cursor-pointer underline disabled:opacity-40" onClick={() => void refetch()}>{t("observerPage.retry")}</button>
|
||||
</p>
|
||||
) : adverts?.items.length ? (
|
||||
<div className="grid gap-1.5 md:grid-cols-2">
|
||||
{adverts.items.map(a => <AdvertRow key={a.id} advert={a} onClick={() => onAnalyzePacket(a.packetHash, a.id)} />)}
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
observersByHash,
|
||||
handlePacketObservation,
|
||||
handleLagged,
|
||||
@@ -232,6 +233,12 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
|
||||
|
||||
{isLoading && packets.length === 0 ? (
|
||||
<SkeletonRows />
|
||||
) : isError && allPackets.length === 0 ? (
|
||||
// a failed history load is not an empty region: say so and offer a retry
|
||||
<p role="alert" className="py-4 text-center text-xs font-mono text-danger">
|
||||
{t("loadingPill.packets.failed")}{" · "}
|
||||
<button type="button" disabled={isFetching} className="underline cursor-pointer disabled:opacity-40" onClick={() => void refetch()}>{t("packetList.retry")}</button>
|
||||
</p>
|
||||
) : (
|
||||
<PacketVirtualList
|
||||
key={listResetKey}
|
||||
@@ -251,7 +258,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
|
||||
)}
|
||||
<LoadingPill
|
||||
loading={isLoading || isFetchingNextPage}
|
||||
error={isError}
|
||||
error={isError && allPackets.length > 0}
|
||||
count={packets.length}
|
||||
noun="packets"
|
||||
position="bottom-3 right-3"
|
||||
|
||||
@@ -215,6 +215,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
// The unfiltered key must stay 2-element so its cached entry survives filter toggling; the
|
||||
// lagged/mount resets above match both shapes by prefix.
|
||||
@@ -262,6 +263,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
|
||||
isFetchingNextPage,
|
||||
isLoading: isLoading || pending,
|
||||
isError,
|
||||
refetch,
|
||||
observersByHash,
|
||||
handlePacketObservation,
|
||||
handleLagged,
|
||||
|
||||
@@ -117,6 +117,14 @@ describe("channel directory", () => {
|
||||
expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the load error, not an empty list, when the first page fails", async () => {
|
||||
keyed(new Error("unavailable"));
|
||||
show();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load channels.");
|
||||
expect(screen.queryByText("No matching channels loaded.")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry loading channels" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can retry an initial failure", async () => {
|
||||
keyed(new Error("temporary failure"), page([channel(1)]));
|
||||
show();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
|
||||
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
|
||||
import i18n from "../../../src/i18n";
|
||||
import { getChannelMessagesPage } from "../../../src/api/client";
|
||||
|
||||
// Older live WS messages have no id — preserve that compatible runtime shape.
|
||||
const restMsg: ChannelMessage = {
|
||||
@@ -149,3 +150,20 @@ describe("MessagePanel live follow", () => {
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessagePanel load failure", () => {
|
||||
it("shows the error and retry instead of the empty state", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
vi.mocked(getChannelMessagesPage).mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<MessagePanel channel={channel} heardCounts={{}} regionKey="*" scope="#ops" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load messages.");
|
||||
expect(screen.queryByText(/No messages/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("live two")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -42,4 +42,15 @@ describe("ObserverAdverts", () => {
|
||||
renderAdverts();
|
||||
expect(await screen.findByText("No adverts heard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error with a retry instead of the empty state when the request fails", async () => {
|
||||
mockGetObserverAdverts.mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
|
||||
mockGetObserverAdverts.mockResolvedValueOnce({ items: [advert(1, "Node Alpha")], nextCursor: null, hasMore: false });
|
||||
renderAdverts();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
|
||||
expect(screen.queryByText("No adverts heard")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("Node Alpha")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const basePackets = () => ({
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
observersByHash: new Map(),
|
||||
handlePacketObservation: () => {},
|
||||
handleLagged: () => {},
|
||||
@@ -180,11 +181,24 @@ describe("PacketList loading feedback", () => {
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading packets… (1)");
|
||||
});
|
||||
|
||||
it("surfaces a failed history fetch through the pill", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), isError: true }));
|
||||
it("shows an error with a retry, not an empty list, when history fails with nothing loaded", () => {
|
||||
const refetch = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), isError: true, refetch }));
|
||||
renderList();
|
||||
|
||||
expect(screen.getByRole("status").textContent).toContain("Failed to load packets");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load packets");
|
||||
expect(screen.queryByTestId("expanded")).toBeNull();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps loaded packets and surfaces a later history failure through the pill", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("h1")], isError: true }));
|
||||
renderList();
|
||||
|
||||
expect(screen.getByTestId("expanded")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toContain("Some packets failed to load (1 shown)");
|
||||
});
|
||||
|
||||
it("renders neither pill nor skeletons when idle", () => {
|
||||
|
||||
Reference in new issue
Block a user