fix(ui): name the missing provider key on a key-gated layer row (#575)

Supersedes #296 by @Matthew-Selvam, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: Matthew-Selvam <[email protected]>
This commit is contained in:
Bilawal SidhuandMatthew-Selvam authored and GitHub committed 2026-09-14 21:26:09 -05:00
1 parent 7fa0cfbb26
commit 6fa25c349a
8 files changed
+206 -3

No files matched your search

+6
View File
@@ -166,6 +166,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
the bundled place. Anything else, including anything malformed, goes to the
existing geocoders unchanged. Degrees/minutes/seconds and grid references are
not parsed and fall through the same way (#388 — thanks @KuraPiee).
- A data-layer control a provider key is holding back now names that key. With
no FIRMS key the fire layer's control read KEY REQUIRED without saying which
key or where to put it; it now reads "Needs FIRMS_MAP_KEY — add it in Provider
Settings", on the control and in its accessible name. A layer that needs no
key, or already holds one, carries no such text, and an unrecognised key name
produces none rather than a guess (#296 — thanks @Matthew-Selvam).
- Draped annotation geometry — area fills and outlines, routes and arrows —
classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's
+7 -1
View File
@@ -787,7 +787,13 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> is present — everyone gets the same tile.
>
> Keyless, the honest surface is the **layer row**, which reads
> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`, and the earthquakes half
> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`. Its control also names the
> key: hovering it, and its accessible name, read
> `Needs FIRMS_MAP_KEY — add it in Provider Settings`. A layer declares which
> key it needs as a key-registry id and reports `stats.keyRequired` while that
> key is absent; the panel builds the text from the pair, and produces none for
> a layer that needs no key, already holds one, or names a key the registry does
> not recognise. The earthquakes half
> still delivers in full. The shared loading reducer now treats an explicitly
> declared missing optional key as a configured terminal state rather than a
> failed multi-layer mission, so the global chip completes without showing
+1
View File
@@ -180,6 +180,7 @@
"scripts/qa-map-source-controls.mjs",
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/layerKeyRequirement.test.mjs",
"src/ui/clearLayersControl.js",
"scripts/qa-layer-panel.mjs",
"scripts/qa-directions.mjs",
+2 -1
View File
@@ -353,7 +353,8 @@
"src/ui/layerPanel.js",
"src/ui/clearLayersControl.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js"
"src/data/installationFeedback.js",
"src/keySetupCore.mjs"
],
"external": []
},
+4
View File
@@ -2200,6 +2200,10 @@ export class LayerLifecycle {
icon: entry.module.icon,
source: entry.module.source,
showInTogglePanel: entry.module.showInTogglePanel !== false,
// Registry id of the provider key this layer needs, if any (mirrors
// showInTogglePanel). The layer reports stats.keyRequired while that
// key is absent; the panel builds the guidance text from the pair.
requiresKeyId: entry.module.requiresKeyId || null,
enabled: entry.enabled,
lifecycleState: entry.lifecycleState,
lifecycleUncertain: entry.lifecycleUncertain,
+11
View File
@@ -38,6 +38,12 @@ export function createQueries({
source,
// The one data-layer control a provider key gates: the proxy answers
// 503 {error:'no_key'} without a FIRMS key. Declared as a key-registry id
// rather than an env-var string, so the panel can name the key from the
// one place that owns what each key is called.
requiresKeyId: 'firms',
// Live layer: the manager calls update() every 10 minutes while enabled,
// which refetches through the /api/firms proxy (the proxy's 30 min TTL —
// not this interval — is what protects the upstream FIRMS quota).
@@ -75,6 +81,11 @@ export function createQueries({
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
// The machine-readable half of the keyless state, ahead of the human
// strings below: without it "no key configured" is indistinguishable
// from a broken feed, and the row reads as a fault instead of a step
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? 'KEY REQUIRED'
: layerState._stale
+143
View File
@@ -0,0 +1,143 @@
// NAMING THE MISSING KEY — a control a provider key holds back must say which
// key, or the operator is left with a dead row and no next step. The three
// halves are pinned here: the layer declares which key it needs and reports
// that it is missing, the manager publishes that on the row, and the panel
// turns the pair into guidance that names the environment variable.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { layerKeyRequirementTooltip } from './layerPanel.js';
import { DataLayerManager } from '../data/manager.js';
import { KEY_SETUP_KEYS } from '../keySetupCore.mjs';
import { createFirmsState } from '../layers/firms/state.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
/** The fire layer's query half, with the scene services it never calls stubbed. */
function firmsQueries() {
const services = {
render: { governorRequestRender() {} },
anchors: { fireAnchorHeight: () => 0, FIRE_ANCHOR_LIFT_M: 0 },
overlays: {},
sprites: {},
context: {},
picking: {},
focus: {},
};
const config = { id: 'local-firms', name: 'FIRMS Active Fires' };
const layerState = createFirmsState({ services, config });
const components = {};
const context = {
layerState,
services,
components,
config,
feed: { getSnapshot: async () => ({}) },
};
components.model = createModel(context);
return { layerState, methods: createQueries(context).methods };
}
test('the fire layer names the key it needs and reports when it is missing', () => {
const { layerState, methods } = firmsQueries();
assert.equal(methods.requiresKeyId, 'firms');
assert.ok(
KEY_SETUP_KEYS.some((entry) => entry.id === 'firms'),
'the declared id must exist in the key registry',
);
assert.equal(
methods.getStats().keyRequired,
false,
'no key is missing until the feed says so',
);
layerState._keyRequired = true;
const stats = methods.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(
stats.error,
'KEY REQUIRED',
'the human string the row already shows is unchanged',
);
});
test('the guidance names the environment variable and where to set it', () => {
const text = layerKeyRequirementTooltip({
requiresKeyId: 'firms',
stats: { keyRequired: true },
});
assert.match(text, /FIRMS_MAP_KEY/);
assert.match(text, /Provider Settings/);
});
test('guidance appears only for a key that is actually missing and actually named', () => {
for (const layer of [
undefined,
{},
{ requiresKeyId: 'firms' },
{ requiresKeyId: 'firms', stats: {} },
{ requiresKeyId: 'firms', stats: { keyRequired: false } },
// Truthy but not the boolean the contract asks for: a stray string must
// not be read as "the key is missing".
{ requiresKeyId: 'firms', stats: { keyRequired: 'yes' } },
// Missing key, but the layer never said which one.
{ stats: { keyRequired: true } },
{ requiresKeyId: '', stats: { keyRequired: true } },
{ requiresKeyId: ' ', stats: { keyRequired: true } },
// An id the registry does not know must not be guessed at: guidance
// naming the wrong variable sends the operator to the wrong provider.
{ requiresKeyId: 'not-a-provider', stats: { keyRequired: true } },
]) {
assert.equal(
layerKeyRequirementTooltip(layer),
'',
`${JSON.stringify(layer)} must produce no guidance`,
);
}
});
test('the manager publishes the declared key id on the row', async () => {
const manager = new DataLayerManager();
const base = {
name: 'Test layer',
icon: '•',
source: 'Test',
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
};
manager.register({
...base,
id: 'gated',
requiresKeyId: 'firms',
getStats: () => ({ count: 0, keyRequired: true }),
});
manager.register({
...base,
id: 'ungated',
getStats: () => ({ count: 3, lastUpdate: Date.now() }),
});
// The manager consults a layer's getStats() only once it is initialized, so
// the rows are read the way the panel reads them: after the layers are on.
await manager.setEnabled('gated', true);
await manager.setEnabled('ungated', true);
const rows = manager.getAll();
const gated = rows.find((row) => row.id === 'gated');
const ungated = rows.find((row) => row.id === 'ungated');
assert.equal(gated.requiresKeyId, 'firms');
assert.equal(gated.stats.keyRequired, true);
assert.match(layerKeyRequirementTooltip(gated), /FIRMS_MAP_KEY/);
assert.equal(
ungated.requiresKeyId,
null,
'a layer that needs no key declares none',
);
assert.equal(layerKeyRequirementTooltip(ungated), '');
});
+32 -1
View File
@@ -1,4 +1,5 @@
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
@@ -8,6 +9,26 @@ const FEED_STATE_LABELS = Object.freeze({
unavailable: 'UNAVAILABLE',
});
/**
* Guidance for a control a missing provider key is holding back.
*
* The key registry already owns what each key is called and which environment
* variables enable it, so a layer only declares WHICH key it needs
* (`requiresKeyId`) and reports `stats.keyRequired` while that key is absent.
* Naming the variable turns an unexplained dead control into a next step.
*
* An unnamed or unknown key returns '' rather than guessing: guidance naming
* the wrong variable sends the operator to the wrong provider.
*
* @param {object} [layer] Row from the layer manager's getAll().
* @returns {string} Guidance text, or '' when no key guidance applies.
*/
export function layerKeyRequirementTooltip(layer = {}) {
if (layer?.stats?.keyRequired !== true) return '';
const requiresKeyId = String(layer.requiresKeyId || '').trim();
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
}
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
@@ -511,7 +532,17 @@ export class LayerPanel {
: layer.enabled
? FEED_STATE_LABELS[feedState]
: 'OFF';
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
const keyGuidance = layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
button.title = keyGuidance;
button.setAttribute(
'aria-label',
keyGuidance
? `${layer.name}: ${button.textContent}. ${keyGuidance}`
: `${layer.name}: ${button.textContent}`,
);
}
_formatCount(n) {