feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin sidebar and the player portal now read their override row, and /admin/navigation writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a row into another existing section. The merge always runs BEFORE the role and shard-feature filters in the layouts, which are unchanged and remain the boundary. An override is presentation: it cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored `hidden: false` on a gated item shows nobody anything. The design scoped these phases as client work, but the server had no way to store a nav row: updateSettings validates and stringifies theme_visual and brand_assets and lets everything else through, so a nav object would have been written as "[object Object]" and read as absent for ever. utils/navOverrides.js mirrors utils/brandAssets.js — strict on write with the offending key named, forgiving on read. It validates shape only; whether a `to` exists is settled client-side at merge time, because the base NAV arrays are client constants and a server-side copy would be a second source of truth that drifts. The nav editor cannot be hidden — its own toggle is disabled, the write path drops `hidden` on that one `to`, and AdminLayout strips it again before merging, which also covers a row edited straight in the database. Orders are written only when the sequence actually differs from the code's, and the comparison is restricted to the rows the editing admin can see, so renaming one item does not pin the position of every other one and a role- or feature-gated item missing from their palette is not mistaken for a reorder. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyNavOverrides } from '../src/lib/navOverrides.js'
|
||||
import { applyNavOverrides, buildNavRows, buildNavOverrides } from '../src/lib/navOverrides.js'
|
||||
|
||||
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
|
||||
// of this feature with real correctness risk, so it is tested in isolation from
|
||||
@@ -201,3 +201,150 @@ test('a non-array base nav yields an empty nav rather than throwing', () => {
|
||||
test('an empty base nav stays empty', () => {
|
||||
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
|
||||
})
|
||||
|
||||
// ── The editor's round trip (phase 7) ─────────────────────────────────────
|
||||
//
|
||||
// buildNavRows and buildNavOverrides are inverse, and the property that matters
|
||||
// is that the editor and the site agree: the rows an admin drags come out of the
|
||||
// same merge the layouts render, hidden ones included.
|
||||
|
||||
const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
|
||||
|
||||
test('rows with no override are the coded nav, in code order', () => {
|
||||
const rows = buildNavRows(FLAT, null)
|
||||
assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
|
||||
assert.equal(rows[0].items.every((i) => i.hidden === false), true)
|
||||
})
|
||||
|
||||
test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
|
||||
assert.equal(buildNavRows(FLAT, null).length, 1)
|
||||
assert.equal(buildNavRows(GROUPED, null).length, 3)
|
||||
})
|
||||
|
||||
test('rows keep hidden items, in place and marked — the site drops them', () => {
|
||||
const overrides = { '/site/news': { hidden: true } }
|
||||
// The layout must not render it...
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
|
||||
// ...while the editor must, or there is no way to un-hide it.
|
||||
const rows = buildNavRows(FLAT, overrides)[0].items
|
||||
assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
|
||||
assert.equal(rows[1].hidden, true)
|
||||
assert.equal(rows[0].hidden, false)
|
||||
})
|
||||
|
||||
test('rows carry the coded label alongside the overridden one', () => {
|
||||
const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
|
||||
assert.equal(rows[1].label, 'Announcements')
|
||||
assert.equal(rows[1].defaultLabel, 'News')
|
||||
})
|
||||
|
||||
test('rows show the same order the site renders', () => {
|
||||
const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
|
||||
})
|
||||
|
||||
test('rows keep an emptied group so something can be moved back into it', () => {
|
||||
// applyNavOverrides drops a group whose every item is hidden; the editor must
|
||||
// still show the header, or the section is unreachable forever.
|
||||
const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
|
||||
assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
|
||||
assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
|
||||
})
|
||||
|
||||
test('an untouched editor saves nothing at all', () => {
|
||||
// Opening the screen and pressing Save must not pin the position of every
|
||||
// item — the caller deletes the row when this comes back empty.
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
|
||||
})
|
||||
|
||||
test('a rename alone writes a label and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[1].label = 'Announcements'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
|
||||
})
|
||||
|
||||
test('a label typed back to the coded one is not stored as an override', () => {
|
||||
const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
|
||||
groups[0].items[1].label = 'News'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
// Whitespace-only reads as "use the default" too.
|
||||
groups[0].items[1].label = ' '
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
})
|
||||
|
||||
test('hiding alone writes hidden and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[3].hidden = true
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
|
||||
})
|
||||
|
||||
test('reordering writes an order for every row in the list', () => {
|
||||
// §7.1: explicit and implicit sort keys share one number line, so a partial
|
||||
// set of orders is the stale-row case rather than something the editor makes.
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const [home] = groups[0].items.splice(0, 1)
|
||||
groups[0].items.push(home)
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {
|
||||
'/site/news': { order: 0 },
|
||||
'/wiki': { order: 1 },
|
||||
'/site/shard': { order: 2 },
|
||||
'/': { order: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
test('the round trip is stable: save, reload, save again yields the same thing', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items.reverse()
|
||||
groups[0].items[0].label = 'The Shard'
|
||||
const first = buildNavOverrides(groups, FLAT)
|
||||
const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
|
||||
assert.deepEqual(second, first)
|
||||
// And it renders what the editor showed.
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
|
||||
})
|
||||
|
||||
test('moving an item to another section writes group, and moving it back clears it', () => {
|
||||
const groups = buildNavRows(GROUPED, null)
|
||||
const [posts] = groups[1].items.splice(0, 1)
|
||||
groups[2].items.push(posts)
|
||||
const saved = buildNavOverrides(groups, GROUPED)
|
||||
assert.equal(saved['/admin/posts'].group, 'System')
|
||||
assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Wiki']],
|
||||
['System', ['Settings', 'Users', 'Posts']],
|
||||
])
|
||||
const back = buildNavRows(GROUPED, saved)
|
||||
const [moved] = back[2].items.splice(2, 1)
|
||||
back[1].items.unshift(moved)
|
||||
assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
|
||||
})
|
||||
|
||||
test('an override for an item outside this admin’s palette survives a save', () => {
|
||||
// §8.1 filters the editor to what the editing admin can themselves see. An
|
||||
// item filtered out has no row, and must not be quietly reset by their save.
|
||||
const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((i) => !i.feature),
|
||||
}))
|
||||
const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
|
||||
const out = buildNavOverrides(visible, FLAT, stored)
|
||||
assert.deepEqual(out['/site/shard'], { hidden: true })
|
||||
// The rows they *could* see still win over what was stored.
|
||||
assert.equal(out['/site/news'], undefined)
|
||||
})
|
||||
|
||||
test('a stored entry for a route the code no longer declares is dropped on save', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
|
||||
assert.deepEqual(out, {})
|
||||
})
|
||||
|
||||
test('degenerate input yields an empty result rather than throwing', () => {
|
||||
assert.deepEqual(buildNavRows(null, {}), [])
|
||||
assert.deepEqual(buildNavRows([], {}), [])
|
||||
assert.deepEqual(buildNavOverrides(null, FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides([], null), {})
|
||||
})
|
||||
|
||||
28
client/test/settingsJson.test.js
Normal file
28
client/test/settingsJson.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { parseJsonSetting } from '../src/lib/settingsJson.js'
|
||||
|
||||
// The client counterpart to the server's parseJsonSetting. The property that
|
||||
// matters is the fail-safe one: anything unusable reads as **absent**, so the
|
||||
// consumer falls back to its coded default rather than rendering an error or a
|
||||
// half-applied object (THEMING_AND_NAV.md §4.4).
|
||||
|
||||
test('absent, empty and malformed values read as absent', () => {
|
||||
for (const bad of [undefined, null, '', '{', 'not json', 4, {}, []]) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${JSON.stringify(bad)} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('valid JSON that is not a plain object reads as absent', () => {
|
||||
// A stored `null`, number, string or array is as unusable to every consumer of
|
||||
// these keys as a syntax error is.
|
||||
for (const bad of ['null', '4', '"x"', '[]', '[{"to":"/"}]', 'true']) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${bad} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a well-formed object is returned as parsed', () => {
|
||||
assert.deepEqual(parseJsonSetting('{"/site/news":{"order":2}}'), { '/site/news': { order: 2 } })
|
||||
assert.deepEqual(parseJsonSetting('{}'), {})
|
||||
})
|
||||
Reference in New Issue
Block a user