Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof import("@openmapx/core")>()),
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 (
<NextIntlClientProvider locale="en" messages={en}>
<DataSourceFilterContent />
</NextIntlClientProvider>
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function DataSourceFilterContent() {
isFetching,
isError,
partial,
refetch,
} = useDataSourceSearch(
shouldFetch ? activeSource : null,
shouldFetch ? searchBbox : null,
Expand Down Expand Up @@ -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) ?? [];
Expand Down Expand Up @@ -450,18 +463,19 @@ export function DataSourceFilterContent() {
</Box>
)}
</Box>
{partial === "area" && !belowMinZoom && !showLoading && !isError && (
{showPartialNotice && (
<>
<Divider />
<DataSourcePartialNotice onZoomIn={zoomIn} />
<DataSourcePartialNotice
reason={partial}
onZoomIn={zoomIn}
onRetry={() => void refetch()}
retrying={isFetching}
/>
</>
)}
{/* Status footer */}
{(belowMinZoom ||
showLoading ||
isError ||
!sourceMeta.showResultsList ||
filteredResults.length === 0) && (
{showStatusFooter && (
<>
<Divider />
<Box sx={{ px: 2, py: 1.5, textAlign: "center" }}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,53 @@
import type { DataSourcePartialReason } from "@openmapx/core";
import { NextIntlClientProvider } from "next-intl";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@/test";
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(
<NextIntlClientProvider locale={locale} messages={locale === "en" ? en : de}>
<DataSourcePartialNotice onZoomIn={onZoomIn} />
<DataSourcePartialNotice reason={reason} onZoomIn={onZoomIn} onRetry={onRetry} />
</NextIntlClientProvider>,
);
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();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={{ px: 2, py: 1.5 }}>
<ResultNotice tone="info" action={{ label: tm("zoomIn"), kind: "zoom", onClick: onZoomIn }}>
{t("zoomInToLoadMore")}
</ResultNotice>
{reason === "unavailable" ? (
<ResultNotice
tone="info"
pending={retrying}
action={{ label: tc("retry"), kind: "retry", onClick: onRetry }}
>
{t("sourceDidNotAnswer")}
</ResultNotice>
) : (
<ResultNotice tone="info" action={{ label: tm("zoomIn"), kind: "zoom", onClick: onZoomIn }}>
{t("zoomInToLoadMore")}
</ResultNotice>
)}
</Box>
);
}
1 change: 1 addition & 0 deletions packages/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading