diff --git a/apps/web/src/components/panels/datasource/DataSourceFilterContent.test.tsx b/apps/web/src/components/panels/datasource/DataSourceFilterContent.test.tsx new file mode 100644 index 000000000..a580d8471 --- /dev/null +++ b/apps/web/src/components/panels/datasource/DataSourceFilterContent.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { + type DataSourcePartialReason, + type DataSourceResult, + useDataSourceStore, +} from "@openmapx/core"; +import en from "@openmapx/i18n/locales/en.json"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ + results: [] as DataSourceResult[], + partial: null as DataSourcePartialReason | null, + isFetching: false, + refetch: vi.fn(), + zoomIn: vi.fn(), +})); + +vi.mock("@openmapx/core", async (original) => ({ + ...(await original()), + useDataSources: () => ({ + data: { + sources: [ + { + id: "fuel", + name: "Fuel", + categoryChipLabel: "Fuel", + minZoom: 10, + markerStyle: { color: "#000", iconPath: "" }, + showResultsList: true, + placeCategory: "Fuel station", + placeCategoryRaw: "fuel", + filters: [], + }, + ], + }, + }), + useDataSourceSearch: () => ({ + data: fixture.results, + isLoading: false, + isFetching: fixture.isFetching, + isError: false, + partial: fixture.partial, + refetch: fixture.refetch, + attributions: [], + freshness: undefined, + }), +})); +vi.mock("@/integration-api/map/MapContext", () => ({ useMap: () => ({ zoomIn: fixture.zoomIn }) })); +vi.mock("@/components/panels/place/useDataSourceI18nResolver", () => ({ + useDataSourceI18nResolver: () => () => "", +})); + +import { DataSourceFilterContent } from "./DataSourceFilterContent"; + +function view() { + return ( + + + + ); +} + +function station(id: string, name: string): DataSourceResult { + return { id, name, coordinates: [13.4, 52.5], source: "fuel" } as DataSourceResult; +} + +beforeEach(() => { + fixture.results = []; + fixture.partial = null; + fixture.isFetching = false; + fixture.refetch.mockReset(); + fixture.zoomIn.mockReset(); + const store = useDataSourceStore.getState(); + store.setActiveSource("fuel"); + store.setSearchBbox({ south: 52.4, west: 13.3, north: 52.6, east: 13.5 }); + store.setViewport({ south: 52.4, west: 13.3, north: 52.6, east: 13.5 }, 14); +}); + +describe("data-source answers that lack results", () => { + it("says a source did not answer instead of reporting none in view, and asks again", () => { + fixture.partial = "unavailable"; + render(view()); + + expect(screen.getByRole("status").textContent).toContain("Not every source answered"); + expect(screen.queryByText("0 in view")).toBeNull(); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(fixture.refetch).toHaveBeenCalledTimes(1); + expect(fixture.zoomIn).not.toHaveBeenCalled(); + }); + + it("keeps the results that did arrive beside the notice", () => { + fixture.partial = "unavailable"; + fixture.results = [station("a", "Aral Alexanderplatz")]; + render(view()); + + expect(screen.getByText("Aral Alexanderplatz")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("Not every source answered"); + }); + + it("shows the retry as pending while the source is asked again beside earlier results", () => { + fixture.partial = "unavailable"; + fixture.results = [station("a", "Aral Alexanderplatz")]; + fixture.isFetching = true; + render(view()); + + expect(screen.getByRole("status").querySelector('[role="progressbar"]')).not.toBeNull(); + }); + + it("shows loading, not the notice, while an empty view is asked again", () => { + fixture.partial = "unavailable"; + fixture.isFetching = true; + render(view()); + + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByText("Loading...")).toBeTruthy(); + }); + + it("still offers to zoom in when a source fetched only part of the view", () => { + fixture.partial = "area"; + render(view()); + + expect(screen.getByRole("status").textContent).toContain("Zoom in to load more"); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(fixture.zoomIn).toHaveBeenCalledTimes(1); + }); + + it("reports a complete empty answer as none in view, with no notice", () => { + render(view()); + + expect(screen.getByText("0 in view")).toBeTruthy(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("drops the notice once the answer for the new view, provider or filters is complete", () => { + fixture.partial = "unavailable"; + const { rerender } = render(view()); + expect(screen.getByRole("status")).toBeTruthy(); + + fixture.partial = null; + rerender(view()); + + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByText("0 in view")).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx b/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx index ca6a8a958..bb7d24559 100644 --- a/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx +++ b/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx @@ -133,6 +133,7 @@ export function DataSourceFilterContent() { isFetching, isError, partial, + refetch, } = useDataSourceSearch( shouldFetch ? activeSource : null, shouldFetch ? searchBbox : null, @@ -201,6 +202,18 @@ export function DataSourceFilterContent() { if (!sourceMeta) return null; const belowMinZoom = sourceMeta && viewportZoom < sourceMeta.minZoom; + const showPartialNotice = partial !== null && !belowMinZoom && !showLoading && !isError; + // A source that did not answer may hold matches here, so an empty answer gets + // no count: "0 in view" would read as "there are none". + const countUnknown = + showPartialNotice && partial === "unavailable" && filteredResults.length === 0; + const showStatusFooter = + !countUnknown && + (belowMinZoom || + showLoading || + isError || + !sourceMeta.showResultsList || + filteredResults.length === 0); const handleToggleMultiSelect = (filterId: string, optionId: string | number) => { const current = (filters[filterId] as (string | number)[] | undefined) ?? []; @@ -450,18 +463,19 @@ export function DataSourceFilterContent() { )} - {partial === "area" && !belowMinZoom && !showLoading && !isError && ( + {showPartialNotice && ( <> - + void refetch()} + retrying={isFetching} + /> )} {/* Status footer */} - {(belowMinZoom || - showLoading || - isError || - !sourceMeta.showResultsList || - filteredResults.length === 0) && ( + {showStatusFooter && ( <> diff --git a/apps/web/src/components/panels/datasource/DataSourcePartialNotice.test.tsx b/apps/web/src/components/panels/datasource/DataSourcePartialNotice.test.tsx index 20e49e666..2fd9b3807 100644 --- a/apps/web/src/components/panels/datasource/DataSourcePartialNotice.test.tsx +++ b/apps/web/src/components/panels/datasource/DataSourcePartialNotice.test.tsx @@ -1,3 +1,4 @@ +import type { DataSourcePartialReason } from "@openmapx/core"; import { NextIntlClientProvider } from "next-intl"; import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@/test"; @@ -5,28 +6,48 @@ import de from "../../../../../../packages/i18n/locales/de.json"; import en from "../../../../../../packages/i18n/locales/en.json"; import { DataSourcePartialNotice } from "./DataSourcePartialNotice"; -function renderIn(locale: "en" | "de", onZoomIn = vi.fn()) { +function renderIn(locale: "en" | "de", reason: DataSourcePartialReason) { + const onZoomIn = vi.fn(); + const onRetry = vi.fn(); render( - + , ); - return onZoomIn; + return { onZoomIn, onRetry }; } describe("DataSourcePartialNotice", () => { it("asks to zoom in to load more, naming no kind of place, and zooms in", () => { - const onZoomIn = renderIn("en"); + const { onZoomIn, onRetry } = renderIn("en", "area"); expect(screen.getByRole("status").textContent).toContain("Zoom in to load more"); expect(screen.getByRole("status").textContent).not.toContain("stations"); fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); expect(onZoomIn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("says a source did not answer and offers to ask again, not to zoom in", () => { + const { onZoomIn, onRetry } = renderIn("en", "unavailable"); + expect(screen.getByRole("status").textContent).toContain( + "Not every source answered, so results may be missing here.", + ); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onZoomIn).not.toHaveBeenCalled(); }); it("is translated", () => { - renderIn("de"); + renderIn("de", "area"); expect(screen.getByRole("status").textContent).toContain( "Für mehr Ergebnisse näher heranzoomen", ); }); + + it("translates the unavailable notice", () => { + renderIn("de", "unavailable"); + expect(screen.getByRole("status").textContent).toContain("Nicht jede Quelle hat geantwortet"); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeTruthy(); + }); }); diff --git a/apps/web/src/components/panels/datasource/DataSourcePartialNotice.tsx b/apps/web/src/components/panels/datasource/DataSourcePartialNotice.tsx index 39b90f70b..cd0b5f739 100644 --- a/apps/web/src/components/panels/datasource/DataSourcePartialNotice.tsx +++ b/apps/web/src/components/panels/datasource/DataSourcePartialNotice.tsx @@ -1,21 +1,45 @@ "use client"; import Box from "@mui/material/Box"; +import type { DataSourcePartialReason } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { ResultNotice } from "@/components/ui/ResultNotice"; /** - * Says a data-source answer is partial: a source fetched only part of the - * view, or not yet. A closer view asks it for less, so it can load the rest. + * Says a data-source answer is partial. For `area`, a source fetched only part + * of the view, so a closer view asks it for less and loads the rest. For + * `unavailable`, a source did not answer at all: zooming in does not change + * that, so the notice offers to ask again instead. */ -export function DataSourcePartialNotice({ onZoomIn }: { onZoomIn: () => void }) { +export function DataSourcePartialNotice({ + reason, + onZoomIn, + onRetry, + retrying = false, +}: { + reason: DataSourcePartialReason; + onZoomIn: () => void; + onRetry: () => void; + retrying?: boolean; +}) { const t = useTranslations("dataSources"); const tm = useTranslations("map"); + const tc = useTranslations("common"); return ( - - {t("zoomInToLoadMore")} - + {reason === "unavailable" ? ( + + {t("sourceDidNotAnswer")} + + ) : ( + + {t("zoomInToLoadMore")} + + )} ); } diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 67ae659c9..1f75cfc88 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -1864,6 +1864,7 @@ "zoomIn": "Näher heranzoomen um Ergebnisse zu sehen", "zoomInToSee": "Für Stationen näher heranzoomen", "zoomInToLoadMore": "Für mehr Ergebnisse näher heranzoomen", + "sourceDidNotAnswer": "Nicht jede Quelle hat geantwortet, daher fehlen hier möglicherweise Ergebnisse.", "failedToLoadData": "Daten konnten nicht geladen werden. Erneut versuchen.", "lowToHigh": "Aufsteigend", "highToLow": "Absteigend", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 900c5e289..6213ac559 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -1864,6 +1864,7 @@ "zoomIn": "Zoom in to see results", "zoomInToSee": "Zoom in to see stations", "zoomInToLoadMore": "Zoom in to load more", + "sourceDidNotAnswer": "Not every source answered, so results may be missing here.", "failedToLoadData": "Failed to load data. Try again.", "lowToHigh": "Low to high", "highToLow": "High to low",