Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8cb030d
feat(stac): browse a static catalog as a tree and search from what yo…
clintonlunn Aug 15, 2026
1592b27
test(stac): cover the panel wiring, the extent lookup and the entries…
clintonlunn Aug 15, 2026
5f709b9
style: auto-format (ruff + oxfmt) [pre-commit.ci]
pre-commit-ci[bot] Aug 15, 2026
015b3ee
fix(stac): refuse a bounding box with no middle, and flatten every on…
clintonlunn Aug 15, 2026
22f8bae
fix(stac): name the group a tree row opens and how deep the row sits
clintonlunn Aug 15, 2026
41c0cdd
test(stac): pin the extent a search flies to, modifier clicks, and fo…
clintonlunn Aug 15, 2026
31eaf2c
fix(stac): drop a collection extent that lands after the user has ask…
clintonlunn Aug 15, 2026
d11f860
fix(stac): keep the arrows working while a modifier is held
clintonlunn Aug 15, 2026
c520115
fix(stac): let a collection that holds collections be closed and open…
clintonlunn Aug 15, 2026
f60cbbc
Merge branch 'main' into feat/stac-catalog-tree
clintonlunn Aug 15, 2026
73b0e4c
fix(stac): search a catalog that carries its own items, and name a fi…
clintonlunn Aug 15, 2026
6d2da70
fix(stac): open a folder with the arrows without changing what is chosen
clintonlunn Aug 15, 2026
8117eb5
fix(stac): search a catalog that holds sub-catalogs and items of its own
clintonlunn Aug 15, 2026
dd2b3b1
fix(stac): read a collection when it is chosen, and let a node with n…
clintonlunn Aug 15, 2026
8b2ff05
fix(stac): drop a request for items the user has since asked to leave
clintonlunn Aug 15, 2026
2854f2d
fix(stac): collapse a chosen collection without letting go of it
clintonlunn Aug 16, 2026
0236bb3
fix(stac): only take a search generation when a search actually runs
clintonlunn Aug 16, 2026
c7c14f9
fix(stac): keep a stale search from reporting its failure over the cu…
clintonlunn Aug 16, 2026
1c459f3
refactor(stac): cancel the reading a catalog switch makes stale inste…
clintonlunn Aug 16, 2026
89625f9
perf(stac): stop a search's walk when another search replaces it
clintonlunn Aug 16, 2026
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
2 changes: 2 additions & 0 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,8 @@ export function TopToolbar({
searching: t("stacPlugin.searching"),
loadingMore: t("stacPlugin.loadingMore"),
noMatchesHere: t("stacPlugin.noMatchesHere"),
treeEmpty: t("stacPlugin.treeEmpty"),
treeOpenFailed: t("stacPlugin.treeOpenFailed"),
noResults: t("stacPlugin.noResults"),
searchFailed: t("stacPlugin.searchFailed"),
showing: (count) => t("stacPlugin.showing", { count }),
Expand Down
2 changes: 2 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3400,6 +3400,8 @@
"searching": "Searching STAC items…",
"loadingMore": "Loading more items…",
"noMatchesHere": "Nothing matched in that part of the catalog. Load more to keep searching.",
"treeEmpty": "Empty",
"treeOpenFailed": "Could not open this catalog",
"noResults": "No STAC items matched these filters.",
"searchFailed": "STAC search failed",
"showing": "Showing {{count}} items.",
Expand Down
172 changes: 172 additions & 0 deletions e2e/stac-api-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForMap } from "./helpers";

// `maplibre-stac.ts` builds its panel by hand and exports nothing to call, so the wiring between
// the catalog tree, the collection list and the search only exists here. An API is the half that
// has no tree at all: it answers item search itself, and offering a tree of its hierarchy would
// promise a way in that its endpoint does not honour.
const API = "https://api.stac.test/v1";

const COLLECTIONS = [
{ id: "sentinel-2", title: "Sentinel-2 L2A", extent: { spatial: { bbox: [[4, 50, 6, 52]] } } },
{ id: "landsat-9", title: "Landsat 9", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } },
];

function item(id: string, collection: string): Record<string, unknown> {
return {
type: "Feature",
stac_version: "1.0.0",
id,
collection,
bbox: [4, 50, 6, 52],
geometry: {
type: "Polygon",
coordinates: [
[
[4, 50],
[6, 50],
[6, 52],
[4, 52],
[4, 50],
],
],
},
properties: { datetime: "2024-05-01T00:00:00Z" },
assets: {},
links: [],
};
}

/** A STAC API that answers item search, and a hierarchy underneath it that must not be offered. */
async function serveApi(page: Page, searches: string[]): Promise<void> {
await page.route("https://api.stac.test/**", async (route) => {
const request = route.request();
const url = request.url();
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });

if (url.endsWith("/collections")) return json({ collections: COLLECTIONS });
if (url.includes("/search")) {
const asked = request.postDataJSON() ?? {};
searches.push(JSON.stringify(asked.collections ?? []));
const collection = asked.collections?.[0] ?? "sentinel-2";
return json({
type: "FeatureCollection",
features: [item(`${collection}-1`, collection), item(`${collection}-2`, collection)],
numberMatched: 2,
links: [],
});
}
return json({
type: "Catalog",
id: "api",
title: "E2E STAC API",
conformsTo: [
"https://api.stacspec.org/v1.0.0/core",
"https://api.stacspec.org/v1.0.0/item-search",
],
links: [
{ rel: "data", href: `${API}/collections` },
{ rel: "search", href: `${API}/search`, method: "POST" },
// An API may also advertise a hierarchy. It is not a way in: only the endpoint is.
{ rel: "child", href: `${API}/providers/ESA`, title: "ESA" },
],
});
});
}

async function connect(page: Page, url: string): Promise<void> {
await page.getByRole("button", { name: "Plugins", exact: true }).click();
await page.getByRole("menuitem", { name: "Web Services" }).click();
await page.getByRole("menuitem", { name: "STAC Catalogs" }).click();
await page.getByPlaceholder("https://example.org/stac/").fill(url);
await page.getByRole("button", { name: "Connect", exact: true }).click();
}

test("an API is offered as a collection list, never as a tree", async ({ page }) => {
const searches: string[] = [];
await serveApi(page, searches);
await waitForMap(page);
await connect(page, API);

await expect(page.getByText("E2E STAC API")).toBeVisible();
const list = page.getByRole("listbox");
await expect(list).toBeVisible();
await expect(list.getByRole("option", { name: "Sentinel-2 L2A" })).toBeVisible();

// The catalog advertises a child link; offering it would hand the user a branch this panel
// cannot search, because the branch answers on its own endpoint rather than this one.
await expect(page.getByRole("tree")).toBeHidden();
await expect(page.getByRole("treeitem")).toHaveCount(0);
});

test("double-clicking a collection in the list searches it and moves the map", async ({ page }) => {
const searches: string[] = [];
await serveApi(page, searches);
await waitForMap(page);
await connect(page, API);

await page.getByLabel("Limit search to the current map extent").uncheck();
// The status bar's own reading of where the map is, so this asserts the view moved rather than
// that some text somewhere changed.
const view = async (): Promise<string> => {
const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? "";
return /BBox:[^A-Z]*/.exec(text)?.[0] ?? "";
};
const bounds = async (): Promise<number[]> => {
const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? "";
const found = /BBox: (-?[\d.]+), (-?[\d.]+), (-?[\d.]+), (-?[\d.]+)/.exec(text);
return found ? found.slice(1, 5).map(Number) : [];
};
const before = await view();
const landsat = page.getByRole("option", { name: "Landsat 9" });
await landsat.click();
await landsat.dblclick();

// The same gesture as in the tree, and it must reach the search on its own rather than leaving
// the user to find the button.
await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible();
expect(searches.at(-1)).toBe(JSON.stringify(["landsat-9"]));

await expect.poll(async () => await view(), { timeout: 10_000 }).not.toBe(before);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Landsat's extent, not the items': the fixture returns items over Belgium precisely so a fit
// to the results would fail this.
const [west, south, east, north] = await bounds();
expect(west).toBeLessThanOrEqual(-114);
expect(east).toBeGreaterThanOrEqual(-109);
expect(south).toBeLessThanOrEqual(37);
expect(north).toBeGreaterThanOrEqual(42);
expect(east - west).toBeLessThan(60);
});

test("connecting to an API after a static catalog clears the tree", async ({ page }) => {
const searches: string[] = [];
await serveApi(page, searches);
await page.route("https://static.stac.test/**", async (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
type: "Catalog",
id: "static",
title: "E2E Static",
links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }],
}),
}),
);
await waitForMap(page);
await connect(page, "https://static.stac.test/catalog.json");

const hazards = page.getByRole("treeitem", { name: "Hazards" });
await expect(hazards).toBeVisible();
await hazards.click();
await expect(hazards).toHaveAttribute("aria-selected", "true");

// A catalog the user has left must not leave its rows, or its selection, behind.
await page.getByPlaceholder("https://example.org/stac/").fill(API);
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByText("E2E STAC API")).toBeVisible();
await expect(page.getByRole("tree")).toBeHidden();
await expect(page.getByRole("treeitem")).toHaveCount(0);
});
Loading
Loading