ui: declarative per-page tab titles via definePage title
This commit is contained in:
+6
-1
@@ -522,6 +522,9 @@ Define a page component with reactive state and rendering. Pages access data thr
|
||||
|
||||
```javascript
|
||||
export default definePage({
|
||||
// Browser tab title — applied to document.title on mount
|
||||
title: 'Zones - Vacuum Wall',
|
||||
|
||||
// Return initial state — models are obtained via getModel()
|
||||
init() {
|
||||
return {
|
||||
@@ -560,6 +563,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
|
||||
|
||||
| Property | Required | Description |
|
||||
|---|---|---|
|
||||
| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. |
|
||||
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
|
||||
| `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. |
|
||||
| `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
|
||||
@@ -567,7 +571,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
|
||||
|
||||
### Page Lifecycle
|
||||
|
||||
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
|
||||
1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `load()` fires if defined → component tracked by key.
|
||||
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
|
||||
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
|
||||
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
||||
@@ -1446,6 +1450,7 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||||
## Conventions
|
||||
|
||||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
||||
- **Tab title**: Pages declare `title: '<Page> - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly.
|
||||
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
|
||||
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
||||
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
|
||||
|
||||
@@ -116,11 +116,12 @@ const flush = () => new Promise(r => setTimeout(r, 20));
|
||||
* loadCredentials: refreshing=true before the fetch, credentials=<new array>
|
||||
* and refreshing=false after — fresh values on every run).
|
||||
*/
|
||||
function makePage(label, counters) {
|
||||
function makePage(label, counters, title) {
|
||||
const state = reactive({ loading: true, done: 0 });
|
||||
return {
|
||||
state,
|
||||
page: definePage({
|
||||
title: title || undefined,
|
||||
init: () => state,
|
||||
async load(s) {
|
||||
counters.loads++;
|
||||
@@ -227,6 +228,62 @@ test('two #comp containers: updates in one root do not disturb the other', async
|
||||
assertEq(c.unmounts, 0, 'neither page unmounted');
|
||||
});
|
||||
|
||||
/* ── Tab title (definePage `title`) ─────────────────────────── */
|
||||
|
||||
test('mounting a titled page sets document.title', async () => {
|
||||
const c = freshCounters();
|
||||
const { page } = makePage('T', c, 'Titled - Vacuum Wall');
|
||||
const main = new FakeEl('div');
|
||||
document.title = 'base';
|
||||
render(main, () => hComp(page, '/titled'));
|
||||
await flush();
|
||||
assertEq(document.title, 'Titled - Vacuum Wall', 'title applied on mount');
|
||||
});
|
||||
|
||||
test('a page without a title leaves document.title untouched', async () => {
|
||||
const c = freshCounters();
|
||||
const { page } = makePage('U', c);
|
||||
const main = new FakeEl('div');
|
||||
document.title = 'unchanged';
|
||||
render(main, () => hComp(page, '/untitled'));
|
||||
await flush();
|
||||
assertEq(document.title, 'unchanged', 'no title → document.title untouched');
|
||||
});
|
||||
|
||||
test('navigation updates document.title; remount re-applies idempotently', async () => {
|
||||
const c = freshCounters();
|
||||
const a = makePage('A', c, 'Alpha - Vacuum Wall');
|
||||
const b = makePage('B', c, 'Beta - Vacuum Wall');
|
||||
const nav = reactive({ path: '/page-a' });
|
||||
const main = new FakeEl('div');
|
||||
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
|
||||
await flush();
|
||||
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title on first mount');
|
||||
|
||||
nav.path = '/page-b';
|
||||
await flush();
|
||||
assertEq(document.title, 'Beta - Vacuum Wall', 'B title after navigation');
|
||||
|
||||
nav.path = '/page-a';
|
||||
await flush();
|
||||
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title re-applied on remount');
|
||||
});
|
||||
|
||||
test('mounting an untitled page does not reset a previously set title', async () => {
|
||||
const c = freshCounters();
|
||||
const a = makePage('A', c, 'Alpha - Vacuum Wall');
|
||||
const b = makePage('B', c);
|
||||
const nav = reactive({ path: '/page-a' });
|
||||
const main = new FakeEl('div');
|
||||
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
|
||||
await flush();
|
||||
assertEq(document.title, 'Alpha - Vacuum Wall', 'baseline');
|
||||
|
||||
nav.path = '/page-b';
|
||||
await flush();
|
||||
assertEq(document.title, 'Alpha - Vacuum Wall', 'untitled mount keeps prior title');
|
||||
});
|
||||
|
||||
/* ── Runner ─────────────────────────────────────────────────── */
|
||||
(async () => {
|
||||
for (const { name, fn } of tests) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const _mounted = new Map();
|
||||
* Define a page component.
|
||||
*
|
||||
* @param {object} def — Page definition
|
||||
* @param {string} [def.title] — Full browser tab title; applied to document.title on mount
|
||||
* @param {function} def.init — Return initial state object
|
||||
* @param {function} [def.load] — Optional one-time setup called on mount
|
||||
* @param {function} def.render — Render function that returns vnodes
|
||||
@@ -52,6 +53,7 @@ export function definePage(def) {
|
||||
},
|
||||
load: def.load || null,
|
||||
onUnmount: def.onUnmount || null,
|
||||
title: def.title || null,
|
||||
};
|
||||
|
||||
return renderer;
|
||||
@@ -65,6 +67,8 @@ export function mountComponent(key, renderer) {
|
||||
const pd = renderer._pageDef;
|
||||
if (!pd) return;
|
||||
|
||||
if (pd.title) document.title = pd.title;
|
||||
|
||||
let entry = _mounted.get(key);
|
||||
|
||||
if (entry) {
|
||||
|
||||
@@ -256,6 +256,7 @@ export function openBackendModal(state, backend) {
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default definePage({
|
||||
title: 'Backends - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
backends: getModel('backends'),
|
||||
|
||||
@@ -326,6 +326,7 @@ async function pollCertIssue(rid) {
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
title: 'Certificates - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
acme: getModel('acme'),
|
||||
|
||||
@@ -41,6 +41,7 @@ function diffLine(d) {
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
title: 'Dashboard - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
|
||||
@@ -107,6 +107,7 @@ const addDns = QuickModal({
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
title: 'DHCP & DNS - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
dnsmasq: getModel('dnsmasq'),
|
||||
|
||||
@@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
title: 'Interfaces - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
|
||||
@@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => {
|
||||
};
|
||||
|
||||
const Page = definePage({
|
||||
title: 'Login - Vacuum Wall',
|
||||
init() {
|
||||
document.title = 'Login — Vacuum Wall';
|
||||
return {};
|
||||
},
|
||||
|
||||
load() {
|
||||
|
||||
@@ -9,6 +9,7 @@ const logTabs = [
|
||||
];
|
||||
|
||||
export default definePage({
|
||||
title: 'Logs - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
logs: getModel('logs'),
|
||||
|
||||
@@ -24,6 +24,7 @@ const addFwd = QuickModal({
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
title: 'NAT - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, PageHeader, definePage } from '/static/hoover/index.js';
|
||||
|
||||
export default definePage({
|
||||
title: '404 - Vacuum Wall',
|
||||
init() {
|
||||
return { path: location.hash.slice(1) || '' };
|
||||
},
|
||||
|
||||
@@ -257,8 +257,8 @@ function CredentialsPage() {
|
||||
}
|
||||
|
||||
const Page = definePage({
|
||||
title: 'Passkeys - Vacuum Wall',
|
||||
init() {
|
||||
document.title = 'Passkeys — Vacuum Wall';
|
||||
return state;
|
||||
},
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ function backendSection(section, state, set) {
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
title: 'Proxy - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
nginx: getModel('nginx'),
|
||||
|
||||
@@ -15,6 +15,7 @@ const addRule = QuickModal({
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
title: 'Rules - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
|
||||
@@ -252,6 +252,7 @@ function UsersPage() {
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
title: 'Users - Vacuum Wall',
|
||||
init() {
|
||||
return state;
|
||||
},
|
||||
|
||||
@@ -402,6 +402,7 @@ function renderAccessClasses(config, status) {
|
||||
|
||||
/* ── Main Page ───────────────────────────────────────────────── */
|
||||
export default definePage({
|
||||
title: 'WireGuard - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
wireguard: getModel('wireguard'),
|
||||
|
||||
@@ -24,6 +24,7 @@ const addZone = QuickModal({
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
title: 'Zones - Vacuum Wall',
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
|
||||
Reference in New Issue
Block a user