Reworked to the consuming dashboard's contract. It emits a zone id or null on zone-change, not a selector: an id is serialisable into a query parameter, a profile config and a deep link, which is the chain that dashboard already resolves its list from, and a predicate is none of those. It also keeps the component promotable -- a selector would have to know what it selects over, and the consumer holds four different row shapes across its tabs. The join stays in one place, where the diacritic folding and the province bridge already are. A leaf emits its zone id; a region row emits the region's id and the consumer expands it, because that is where the zone data lives. And it cannot silently drop a zone. Four joinable zones are absent from the region file today, one of them the consumer's largest at a hundred groups, so grouping by region loses them with no error and no empty state -- just a shorter tree. Given the full zone set, the legend renders the difference in an explicit bucket, each one selectable. That makes the drop structurally impossible rather than something to keep checking. Omit the set and the tree is untouched, so the guard is additive. Its check includes a negative control that the unaugmented tree really does omit the largest zone, so the bucket is demonstrably not decoration.
70 lines
2.9 KiB
JavaScript
70 lines
2.9 KiB
JavaScript
/**
|
|
* listSelection.js — what a vertical-legend row selects, and the zones a tree forgot.
|
|
*
|
|
* Extracted from ListSelectorLegend.vue so both rules can be tested without mounting a component.
|
|
* Small rules, large consequences: a dashboard filters thousands of rows by the first, and the
|
|
* second decides whether 100+ groups are reachable at all.
|
|
*/
|
|
|
|
/** Every leaf under a node, or the node itself when it is a leaf. */
|
|
export function leavesOf(node) {
|
|
if (!node) return []
|
|
const kids = node.children || node.nodes || []
|
|
if (!kids.length) return [node]
|
|
const out = []
|
|
for (const k of kids) out.push(...leavesOf(k))
|
|
return out
|
|
}
|
|
|
|
/** Every zone id present anywhere in a tree. */
|
|
export function zoneIdsIn(nodes) {
|
|
const out = new Set()
|
|
for (const n of nodes || []) for (const l of leavesOf(n)) if (l && l.id) out.add(String(l.id))
|
|
return out
|
|
}
|
|
|
|
export const UNASSIGNED_ID = '__unassigned__'
|
|
|
|
/**
|
|
* ★ A ZONE THE TREE DOES NOT CONTAIN MUST STILL BE REACHABLE. Four joinable zones are absent from
|
|
* zone-regions.json today — guizhou, xinjiang (100+ groups on its own), bihar, madhya-pradesh — so a
|
|
* legend that renders only the region tree DROPS them, silently, and a reader has no way to select
|
|
* groups that plainly exist. The fix is not to remember: it is to hand this function the full zone
|
|
* set and let it render the difference. A drop then becomes structurally impossible rather than a
|
|
* thing someone has to keep checking.
|
|
*
|
|
* Returns the tree unchanged when nothing is missing, so the bucket never appears without cause.
|
|
*
|
|
* @param {Array} nodes the region-grouped tree
|
|
* @param {Array<string>} allZoneIds every zone the data can join, tree or no tree
|
|
* @param {string} label what to call the bucket (the caller localises it)
|
|
*/
|
|
export function withUnassigned(nodes, allZoneIds, label = 'Unassigned') {
|
|
const present = zoneIdsIn(nodes)
|
|
const missing = (allZoneIds || []).map(String).filter((id) => !present.has(id))
|
|
if (!missing.length) return nodes || []
|
|
return [
|
|
...(nodes || []),
|
|
{
|
|
id: UNASSIGNED_ID,
|
|
label,
|
|
depth: 0,
|
|
// Each missing zone is a selectable leaf, not a lump: a reader wants xinjiang, not "the four
|
|
// zones nobody filed".
|
|
children: missing.map((id) => ({ id, label: id, zoneKey: id })),
|
|
},
|
|
]
|
|
}
|
|
|
|
/**
|
|
* The value the legend emits. gospel's contract: a single SERIALISABLE id, so it survives ?zone=,
|
|
* profile-config and a deep link, and stays usable across their four tab shapes.
|
|
*
|
|
* A leaf emits its zone id; a REGION row emits the region's own id (dx-shepherd: a region selects
|
|
* the union of its zones). The consumer expands a region id to its zones using the zone data it
|
|
* already holds — which is also where the diacritic-folding join lives, so there is no second copy
|
|
* of it here to drift.
|
|
*/
|
|
export function selectedId(node) {
|
|
return node && node.id != null ? String(node.id) : null
|
|
}
|