mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-16 15:50:08 +00:00
Region-filtered scope lists (#113)
This commit is contained in:
+4
-4
@@ -159,10 +159,10 @@ export function getBrokers(): Promise<BrokerStatus[]> {
|
||||
return request("/brokers");
|
||||
}
|
||||
|
||||
// The authoritative list of configured transport scope names (e.g. "#bc", "#west"), used to populate
|
||||
// the scope filter dropdowns. The no-param /scopes endpoint returns the names directly.
|
||||
export function getScopes(): Promise<string[]> {
|
||||
return request("/scopes");
|
||||
// Transport scope names (e.g. "#bc", "#west") for the scope filter dropdowns. With IATAs the server
|
||||
// returns only that region's scopes (manual config + its MeshMapper catalogue); bare, every stored name.
|
||||
export function getScopes(iatas?: string[]): Promise<string[]> {
|
||||
return request("/scopes", { iatas: iatasParam(iatas) });
|
||||
}
|
||||
|
||||
// Wrap a bare-array endpoint into a CursorPage so it can drive the cursor-paginated hooks. A page that
|
||||
|
||||
@@ -43,7 +43,7 @@ function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heard
|
||||
<Timestamp value={msg.sentAt} className="text-[11px] text-text-dim" />
|
||||
{reach > 0 && <Badge variant="text">×{reach}</Badge>}
|
||||
{/* Unscoped is the default, so only a named or unresolved scope earns a chip. */}
|
||||
{msg.scope ? <ScopeTag className="max-w-full break-all">{msg.scope}</ScopeTag>
|
||||
{msg.scope ? <ScopeTag boxed className="max-w-full break-all">{msg.scope}</ScopeTag>
|
||||
: msg.scopeStatus === "unknown" && <span className="rounded-sm bg-text-normal/5 px-1.5 py-px font-mono text-[11px] text-text-dim">{t("channelMessages.unknownScope")}</span>}
|
||||
{onAnalyze && <button type="button" aria-label={t("channelMessages.inspect", { sender: msg.senderName })}
|
||||
className="ml-auto shrink-0 cursor-pointer font-mono text-[11px] text-text-dim transition-colors hover:text-primary group-hover:text-primary"
|
||||
@@ -68,7 +68,7 @@ interface MessagePanelProps {
|
||||
|
||||
export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack, scope = "", onScopeChange }: MessagePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const scopeNames = useScopes();
|
||||
const scopeNames = useScopes(scope);
|
||||
const { data, isLoading, isError, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||
queryKey: ["channel-messages", channel?.id, regionKey, scope],
|
||||
queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50, scope }),
|
||||
|
||||
@@ -186,8 +186,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
|
||||
keepPrevious: true,
|
||||
});
|
||||
|
||||
// scope options are the configured scopes; the filter itself is applied client-side on defaultScope
|
||||
const scopeOptions = useScopes();
|
||||
const scopeOptions = useScopes(scopeFilter);
|
||||
|
||||
const displayNodes = useMemo(
|
||||
() => (scopeFilter ? nodes.filter((n) => n.defaultScope === scopeFilter) : nodes),
|
||||
|
||||
@@ -152,8 +152,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver,
|
||||
return [...types].sort();
|
||||
}, [observers]);
|
||||
|
||||
// scope options are the configured scopes; the filter itself is applied client-side on obs.scopes
|
||||
const scopeOptions = useScopes();
|
||||
const scopeOptions = useScopes(scope);
|
||||
|
||||
const displayObservers = useMemo(
|
||||
() => (scope ? observers.filter((o) => o.scopes?.includes(scope)) : observers),
|
||||
|
||||
@@ -47,7 +47,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
|
||||
// single-value selections go to the server so scrolling pages through matching history
|
||||
const serverFilter = useMemo(() => toServerFilter(filters), [filters]);
|
||||
const pathSearch = useMemo(() => parsePathSearch(filters.search), [filters.search]);
|
||||
const scopeNames = useScopes();
|
||||
const scopeNames = useScopes(filters.scopes);
|
||||
const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]);
|
||||
const { regionKey } = useRegion();
|
||||
|
||||
|
||||
+15
-8
@@ -1,17 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getScopes } from "../api/client";
|
||||
import { useRegion } from "./useRegion";
|
||||
|
||||
// The configured transport scope names (e.g. "#bc", "#west"), from /scopes. This is the authoritative
|
||||
// list — the scope filters use it for their options so they show every configured scope even before any
|
||||
// packet/node/observer has been matched to one. Scopes are near-static, so cache long and share across
|
||||
// tabs via React Query. The filtering itself stays client-side on each record's scope.
|
||||
export function useScopes(): string[] {
|
||||
// Scope names for the filter dropdowns, limited to the selected region ("All" lists every scope).
|
||||
// `keep` is the current selection: it stays an option even when outside the region, so a shared link
|
||||
// still filters. The filtering itself stays client-side on each record's scope.
|
||||
export function useScopes(keep?: string | string[]): string[] {
|
||||
const { iatas, regionKey, isResolved } = useRegion();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["scopes"],
|
||||
queryFn: getScopes,
|
||||
queryKey: ["scopes", regionKey],
|
||||
queryFn: () => getScopes(iatas),
|
||||
enabled: isResolved !== false,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
return useMemo(() => [...(data ?? [])].sort(), [data]);
|
||||
const kept = typeof keep === "string" ? keep : keep?.join("\n");
|
||||
return useMemo(() => {
|
||||
const names = new Set(data ?? []);
|
||||
for (const k of kept ? kept.split("\n") : []) names.add(k);
|
||||
return [...names].sort();
|
||||
}, [data, kept]);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,14 @@ describe("getScopes", () => {
|
||||
expect(url).not.toContain("?"); // no query params on the authoritative list
|
||||
expect(scopes).toEqual(["#bc", "#west"]);
|
||||
});
|
||||
|
||||
it("asks for the selected region's scopes by IATA", async () => {
|
||||
const getUrl = mockFetchOnce(["#yow"]);
|
||||
|
||||
await getScopes(["YOW", "YYZ"]);
|
||||
|
||||
expect(new URL(getUrl()).searchParams.get("iatas")).toBe("YOW,YYZ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownRoutesPage", () => {
|
||||
|
||||
@@ -65,6 +65,11 @@ describe("channel scope evidence", () => {
|
||||
fireEvent.click(view);
|
||||
expect(inspect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("sizes the scope chip like the heard-count badge", async () => {
|
||||
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" />);
|
||||
const chip = await screen.findByText("#yow");
|
||||
expect(chip).toHaveClass("border", "px-2", "py-0.5", "font-semibold");
|
||||
});
|
||||
it("switches French labels without refetching or changing protocol names", async () => {
|
||||
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" />);
|
||||
await screen.findByText("#yow");
|
||||
|
||||
@@ -39,6 +39,7 @@ const multiLineMsg: ChannelMessage = {
|
||||
sentAt: 4000,
|
||||
};
|
||||
|
||||
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: undefined, regionKey: "*" }) }));
|
||||
vi.mock("../../../src/api/client", () => ({
|
||||
getScopes: vi.fn(async () => []),
|
||||
getChannelMessagesPage: vi.fn(() =>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useScopes } from "../../src/hooks/useScopes";
|
||||
import { getScopes } from "../../src/api/client";
|
||||
|
||||
const region = { iatas: ["YOW"] as string[] | undefined, regionKey: "YOW", isResolved: true };
|
||||
vi.mock("../../src/hooks/useRegion", () => ({ useRegion: () => region }));
|
||||
vi.mock("../../src/api/client", () => ({ getScopes: vi.fn(async (iatas?: string[]) => (iatas ? ["#yow"] : ["#can", "#yow", "#yyz"])) }));
|
||||
afterEach(() => { vi.clearAllMocks(); region.iatas = ["YOW"]; region.regionKey = "YOW"; region.isResolved = true; });
|
||||
|
||||
const setup = (keep?: string | string[]) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const hook = renderHook(() => useScopes(keep), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
|
||||
return { ...hook, client };
|
||||
};
|
||||
|
||||
it("lists only the selected region's scopes, and every scope for All", async () => {
|
||||
const { result, rerender, client } = setup();
|
||||
await waitFor(() => expect(result.current).toEqual(["#yow"]));
|
||||
expect(getScopes).toHaveBeenCalledWith(["YOW"]);
|
||||
region.iatas = undefined; region.regionKey = "*"; rerender();
|
||||
await waitFor(() => expect(result.current).toEqual(["#can", "#yow", "#yyz"]));
|
||||
expect(getScopes).toHaveBeenLastCalledWith(undefined);
|
||||
expect(client.getQueryCache().findAll({ queryKey: ["scopes"] })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a selected scope from another region so a shared link still filters", async () => {
|
||||
const { result } = setup(["#yyz"]);
|
||||
await waitFor(() => expect(result.current).toEqual(["#yow", "#yyz"]));
|
||||
});
|
||||
|
||||
it("does not fall back to every scope while the selected region is unresolved", () => {
|
||||
region.isResolved = false;
|
||||
const { result } = setup("#yow");
|
||||
expect(getScopes).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(["#yow"]);
|
||||
});
|
||||
Reference in New Issue
Block a user