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
|
```javascript
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
// Browser tab title — applied to document.title on mount
|
||||||
|
title: 'Zones - Vacuum Wall',
|
||||||
|
|
||||||
// Return initial state — models are obtained via getModel()
|
// Return initial state — models are obtained via getModel()
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
@@ -560,6 +563,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
|
|||||||
|
|
||||||
| Property | Required | Description |
|
| 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. |
|
| `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. |
|
| `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`. |
|
| `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
|
### 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.
|
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()`.
|
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.
|
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
|
## Conventions
|
||||||
|
|
||||||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
- **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()`.
|
- **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.
|
- **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.
|
- **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>
|
* loadCredentials: refreshing=true before the fetch, credentials=<new array>
|
||||||
* and refreshing=false after — fresh values on every run).
|
* 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 });
|
const state = reactive({ loading: true, done: 0 });
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
page: definePage({
|
page: definePage({
|
||||||
|
title: title || undefined,
|
||||||
init: () => state,
|
init: () => state,
|
||||||
async load(s) {
|
async load(s) {
|
||||||
counters.loads++;
|
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');
|
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 ─────────────────────────────────────────────────── */
|
/* ── Runner ─────────────────────────────────────────────────── */
|
||||||
(async () => {
|
(async () => {
|
||||||
for (const { name, fn } of tests) {
|
for (const { name, fn } of tests) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const _mounted = new Map();
|
|||||||
* Define a page component.
|
* Define a page component.
|
||||||
*
|
*
|
||||||
* @param {object} def — Page definition
|
* @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.init — Return initial state object
|
||||||
* @param {function} [def.load] — Optional one-time setup called on mount
|
* @param {function} [def.load] — Optional one-time setup called on mount
|
||||||
* @param {function} def.render — Render function that returns vnodes
|
* @param {function} def.render — Render function that returns vnodes
|
||||||
@@ -52,6 +53,7 @@ export function definePage(def) {
|
|||||||
},
|
},
|
||||||
load: def.load || null,
|
load: def.load || null,
|
||||||
onUnmount: def.onUnmount || null,
|
onUnmount: def.onUnmount || null,
|
||||||
|
title: def.title || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return renderer;
|
return renderer;
|
||||||
@@ -65,6 +67,8 @@ export function mountComponent(key, renderer) {
|
|||||||
const pd = renderer._pageDef;
|
const pd = renderer._pageDef;
|
||||||
if (!pd) return;
|
if (!pd) return;
|
||||||
|
|
||||||
|
if (pd.title) document.title = pd.title;
|
||||||
|
|
||||||
let entry = _mounted.get(key);
|
let entry = _mounted.get(key);
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export function openBackendModal(state, backend) {
|
|||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Backends - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
backends: getModel('backends'),
|
backends: getModel('backends'),
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ async function pollCertIssue(rid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Certificates - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
acme: getModel('acme'),
|
acme: getModel('acme'),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function diffLine(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Dashboard - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
firewall: getModel('firewall'),
|
firewall: getModel('firewall'),
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const addDns = QuickModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'DHCP & DNS - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
dnsmasq: getModel('dnsmasq'),
|
dnsmasq: getModel('dnsmasq'),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Interfaces - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
firewall: getModel('firewall'),
|
firewall: getModel('firewall'),
|
||||||
|
|||||||
@@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Page = definePage({
|
const Page = definePage({
|
||||||
|
title: 'Login - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
document.title = 'Login — Vacuum Wall';
|
return {};
|
||||||
},
|
},
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const logTabs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Logs - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
logs: getModel('logs'),
|
logs: getModel('logs'),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const addFwd = QuickModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'NAT - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
firewall: getModel('firewall'),
|
firewall: getModel('firewall'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { html, PageHeader, definePage } from '/static/hoover/index.js';
|
import { html, PageHeader, definePage } from '/static/hoover/index.js';
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: '404 - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return { path: location.hash.slice(1) || '' };
|
return { path: location.hash.slice(1) || '' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ function CredentialsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Page = definePage({
|
const Page = definePage({
|
||||||
|
title: 'Passkeys - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
document.title = 'Passkeys — Vacuum Wall';
|
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ function backendSection(section, state, set) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Proxy - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
nginx: getModel('nginx'),
|
nginx: getModel('nginx'),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const addRule = QuickModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Rules - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
firewall: getModel('firewall'),
|
firewall: getModel('firewall'),
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Users - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ function renderAccessClasses(config, status) {
|
|||||||
|
|
||||||
/* ── Main Page ───────────────────────────────────────────────── */
|
/* ── Main Page ───────────────────────────────────────────────── */
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'WireGuard - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
wireguard: getModel('wireguard'),
|
wireguard: getModel('wireguard'),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const addZone = QuickModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
|
title: 'Zones - Vacuum Wall',
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
firewall: getModel('firewall'),
|
firewall: getModel('firewall'),
|
||||||
|
|||||||
Reference in New Issue
Block a user