Skip to content

Commit 4b94b70

Browse files
committed
feat(geolens): add a Sample server dropdown to the panel
Trying the plugin previously meant already knowing a GeoLens URL. The panel now offers the two public deployments — datasets.geolibre.app and demo.getgeolens.com — above the URL field. Picking one fills the field and connects (that is the whole intent of the choice; leaving Connect to a second click would only add a step), then resets to the placeholder, because the URL field stays the source of truth and the user can edit it afterwards. Also makes a blocked request legible. demo.getgeolens.com serves its catalog to curl but sends no `Access-Control-Allow-Origin` at all, so no browser can reach it — where datasets.geolibre.app allowlists the requesting origin and works. `fetch` reports that as a bare TypeError ("Failed to fetch") with no detail by design, which read as "GeoLibre is broken" rather than "this server does not allow browser access". Every failure this module raises itself is a plain Error, so the constructor cleanly separates the two, and a transport failure now names the host and says the server refused a cross-origin request. Verified in the browser, light and dark: the dropdown lists both entries, the GeoLibre catalog loads (22 datasets), and the demo entry shows the CORS message instead of a bare fetch error.
1 parent 87e7607 commit 4b94b70

3 files changed

Lines changed: 113 additions & 4 deletions

File tree

packages/plugins/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,10 +363,12 @@ export {
363363
DEFAULT_GEOLENS_FEATURE_LIMIT,
364364
GEOLENS_FEATURES_SOURCE_KIND,
365365
GEOLENS_PLUGIN_ID,
366+
GEOLENS_SAMPLE_SERVERS,
366367
maplibreGeoLensPlugin,
367368
normalizeGeoLensFeatureLimit,
368369
setGeoLensLabels,
369370
type GeoLensLabels,
371+
type GeoLensSampleServer,
370372
} from "./plugins/maplibre-geolens";
371373
export {
372374
buildListObjectsUrl,

packages/plugins/src/plugins/maplibre-geolens.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ export const GEOLENS_PLUGIN_ID = "maplibre-gl-geolens";
5959
*/
6060
export const GEOLENS_FEATURES_SOURCE_KIND = "geolens-features";
6161

62+
/** One entry in the sample-server dropdown. */
63+
export interface GeoLensSampleServer {
64+
/** Shown in the dropdown; the URL is the title text. */
65+
label: string;
66+
baseUrl: string;
67+
}
68+
69+
/**
70+
* Public GeoLens deployments offered in the panel, so the plugin can be tried
71+
* without hunting for a server URL. Both are open catalogs that need no key.
72+
*
73+
* The labels are the deployments' own names rather than translatable strings:
74+
* they identify a specific server, the way a bookmark does.
75+
*/
76+
export const GEOLENS_SAMPLE_SERVERS: readonly GeoLensSampleServer[] = [
77+
{ label: "GeoLibre datasets", baseUrl: "https://datasets.geolibre.app" },
78+
{ label: "GeoLens demo", baseUrl: "https://demo.getgeolens.com" },
79+
];
80+
6281
/** Number of datasets requested per catalog search. */
6382
const SEARCH_LIMIT = 50;
6483
/** Default maximum number of editable GeoJSON features loaded per dataset. */
@@ -80,6 +99,8 @@ const TOKEN_REFRESH_MAX_RETRY_SECONDS = 300;
8099

81100
export interface GeoLensLabels {
82101
hint: string;
102+
sampleServer: string;
103+
sampleServerTitle: string;
83104
baseUrlPlaceholder: string;
84105
apiKeyPlaceholder: string;
85106
connect: string;
@@ -89,6 +110,7 @@ export interface GeoLensLabels {
89110
searching: string;
90111
noResults: string;
91112
loadError: (message: string) => string;
113+
blockedError: (host: string) => string;
92114
showing: (count: number) => string;
93115
vectorBadge: string;
94116
rasterBadge: string;
@@ -125,6 +147,8 @@ export interface GeoLensLabels {
125147

126148
export const DEFAULT_GEOLENS_LABELS: GeoLensLabels = {
127149
hint: "Connect to a GeoLens server to browse and add its catalog datasets.",
150+
sampleServer: "Sample server…",
151+
sampleServerTitle: "Connect to a public GeoLens deployment",
128152
baseUrlPlaceholder: "GeoLens URL, e.g. https://datasets.geolibre.app",
129153
apiKeyPlaceholder: "API key (optional, for private data)",
130154
connect: "Connect",
@@ -134,6 +158,9 @@ export const DEFAULT_GEOLENS_LABELS: GeoLensLabels = {
134158
searching: "Searching…",
135159
noResults: "No matching datasets.",
136160
loadError: (message) => `Could not reach GeoLens: ${message}`,
161+
blockedError: (host) =>
162+
`Could not reach ${host}. The server refused a cross-origin request from ` +
163+
`GeoLibre — its administrator has to allow this origin (CORS).`,
137164
showing: (count) => `${count} dataset${count === 1 ? "" : "s"}.`,
138165
vectorBadge: "vector",
139166
rasterBadge: "raster",
@@ -299,6 +326,32 @@ function messageOf(error: unknown): string {
299326
return error instanceof Error ? error.message : String(error);
300327
}
301328

329+
/**
330+
* Whether a failure was the request never reaching the server at all.
331+
*
332+
* `fetch` rejects with a bare `TypeError` ("Failed to fetch") when the browser
333+
* blocks the request — a CORS policy, a DNS failure, being offline — and gives
334+
* the page no detail beyond that, by design. Every failure this module raises
335+
* itself is a plain `Error`, so the constructor is a reliable discriminator.
336+
*
337+
* It matters because CORS is the likeliest cause and the least guessable: a
338+
* GeoLens deployment that serves its catalog happily to `curl` is unreachable
339+
* from a browser unless it sends `Access-Control-Allow-Origin` for the app's
340+
* origin, and `demo.getgeolens.com` currently sends none at all.
341+
*/
342+
function isTransportFailure(error: unknown): boolean {
343+
return error instanceof TypeError;
344+
}
345+
346+
/** The host of a base URL, for an error message; falls back to the whole URL. */
347+
function hostOf(baseUrl: string): string {
348+
try {
349+
return new URL(baseUrl).host;
350+
} catch {
351+
return baseUrl;
352+
}
353+
}
354+
302355
// ---------------------------------------------------------------------------
303356
// Layer creation + tile-token lifecycle.
304357
// ---------------------------------------------------------------------------
@@ -920,6 +973,22 @@ function buildPanel(
920973
featureLimitLabel.append(featureLimitInput);
921974
settingsPanel.append(featureLimitLabel, el("div", CSS.hint, labels.featureLimitHelp));
922975

976+
// A shortcut to the public deployments. It fills the URL field and connects,
977+
// rather than only filling it: picking a sample server is the whole intent, so
978+
// leaving the user to press Connect afterwards would just be a second click.
979+
const sampleSelect = el("select", CSS.input) as HTMLSelectElement;
980+
sampleSelect.title = labels.sampleServerTitle;
981+
sampleSelect.setAttribute("aria-label", labels.sampleServerTitle);
982+
const samplePlaceholder = el("option", "", labels.sampleServer);
983+
samplePlaceholder.value = "";
984+
sampleSelect.append(samplePlaceholder);
985+
for (const server of GEOLENS_SAMPLE_SERVERS) {
986+
const option = el("option", "", server.label);
987+
option.value = server.baseUrl;
988+
option.title = server.baseUrl;
989+
sampleSelect.append(option);
990+
}
991+
923992
const baseUrlInput = el("input", CSS.input) as HTMLInputElement;
924993
baseUrlInput.placeholder = labels.baseUrlPlaceholder;
925994
baseUrlInput.autocomplete = "off";
@@ -953,6 +1022,7 @@ function buildPanel(
9531022
panel.append(
9541023
hintRow,
9551024
settingsPanel,
1025+
sampleSelect,
9561026
baseUrlInput,
9571027
apiKeyInput,
9581028
connectRow,
@@ -999,7 +1069,11 @@ function buildPanel(
9991069
} catch (error) {
10001070
if (isAbort(error) || generation !== state.generation) return false;
10011071
status.textContent = "";
1002-
showError(labels.loadError(messageOf(error)));
1072+
showError(
1073+
isTransportFailure(error)
1074+
? labels.blockedError(hostOf(state.client?.baseUrl ?? ""))
1075+
: labels.loadError(messageOf(error)),
1076+
);
10031077
return false;
10041078
}
10051079
};
@@ -1284,6 +1358,15 @@ function buildPanel(
12841358
};
12851359

12861360
connectButton.addEventListener("click", () => void connect());
1361+
sampleSelect.addEventListener("change", () => {
1362+
const baseUrl = sampleSelect.value;
1363+
// Reset to the placeholder: the URL field is the source of truth (the user
1364+
// can edit it afterwards), so a stuck selection would soon be a lie.
1365+
sampleSelect.value = "";
1366+
if (!baseUrl) return;
1367+
baseUrlInput.value = baseUrl;
1368+
void connect();
1369+
});
12871370
settingsButton.addEventListener("click", () => {
12881371
const open = settingsPanel.style.display !== "flex";
12891372
settingsPanel.style.display = open ? "flex" : "none";

tests/geolens-editing.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import assert from "node:assert/strict";
22
import { beforeEach, describe, it } from "node:test";
33
import { useAppStore } from "@geolibre/core";
44
import type { FeatureCollection } from "geojson";
5-
import type {
6-
GeoLensFetch,
7-
GeoLensHttpResponse,
5+
import {
6+
normalizeBaseUrl,
7+
type GeoLensFetch,
8+
type GeoLensHttpResponse,
89
} from "../packages/plugins/src/plugins/geolens-api";
910
import {
1011
clearEditSessions,
1112
GEOLENS_FEATURES_SOURCE_KIND,
13+
GEOLENS_SAMPLE_SERVERS,
1214
pendingCountsFor,
1315
saveLayerEdits,
1416
type GeoLensEditableLayer,
@@ -228,3 +230,25 @@ describe("saveLayerEdits", () => {
228230
);
229231
});
230232
});
233+
234+
describe("GEOLENS_SAMPLE_SERVERS", () => {
235+
it("offers both public deployments as ready-to-use https URLs", () => {
236+
assert.deepEqual(
237+
GEOLENS_SAMPLE_SERVERS.map((s) => s.baseUrl),
238+
["https://datasets.geolibre.app", "https://demo.getgeolens.com"],
239+
);
240+
});
241+
242+
it("lists each server once, labelled, and already normalized", () => {
243+
// normalizeBaseUrl runs on whatever reaches the URL field, so a sample that
244+
// is not already in canonical form would connect to a different string than
245+
// the one shown — and a trailing slash would double up in every path join.
246+
const seen = new Set<string>();
247+
for (const server of GEOLENS_SAMPLE_SERVERS) {
248+
assert.ok(server.label.trim().length > 0, "sample server needs a label");
249+
assert.equal(server.baseUrl, normalizeBaseUrl(server.baseUrl));
250+
assert.equal(seen.has(server.baseUrl), false, `duplicate ${server.baseUrl}`);
251+
seen.add(server.baseUrl);
252+
}
253+
});
254+
});

0 commit comments

Comments
 (0)