Files
mteehan 3de82e3b9b Remove query-string cache-busting from static assets
Drop ?v=N version pins from all JS imports and HTML <link>/<script> tags.
Cache invalidation is now handled solely by server-side cache-control headers.
Update docs and AGENTS.md accordingly.
2026-07-28 13:50:22 +00:00

172 lines
5.7 KiB
JavaScript

/**
* Hoover — components/layout.js
*
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js';
import { Table } from './data.js';
import { collectLoadingModels } from '../model.js';
/**
* Page header with title, optional subtitle, and action buttons.
*
* @param {object} props
* @param {string} props.title
* @param {string} [props.subtitle]
* @param {VNode} [props.actions]
*/
export function PageHeader(props = {}) {
return h('div', { class: 'page-header' },
h('div', null,
h('h1', null, props.title || ''),
props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null,
),
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
);
}
/**
* Handle loading/error/no-data states and return early if applicable.
* Returns null when data is ready for the page to render its content.
*
* Accepts a model object (with loading/refreshing/error/data properties) as
* the `data` parameter to check the model's data property directly.
*
* @param {object} state - Page state with loading/error flags
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {*} [data] - Data to check (or model object with .data property)
* @returns {VNode[]|null}
*/
export function renderGuard(state, title, subtitle, data) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' },
state.refreshing ? 'Refreshing...' : 'Loading...',
),
),
];
}
if (state.error) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
if (isEmpty(data) && !state.loading) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'no-data' },
h('div', { class: 'card-body loading' }, 'No data available'),
),
];
}
return null;
}
/**
* Convenience wrapper for pages consuming multiple models.
* Internally calls collectLoadingModels then delegates to renderGuard.
*
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {...object} models - Model objects to combine
* @returns {VNode[]|null}
*/
export function renderGuardMulti(title, subtitle, ...models) {
const combined = collectLoadingModels(...models);
return renderGuard(combined, title, subtitle, models.map(m => m.data));
}
/**
* Check if a value is "empty" for renderGuard's no-data check.
* @param {*} data
* @returns {boolean}
*/
function isEmpty(data) {
if (data === null || data === undefined || data === '') return true;
if (Array.isArray(data)) {
// Array of model data values (from renderGuardMulti) — empty only if all models have no data
if (data.length === 0) return true;
return data.every(d => d === null || d === undefined ||
(Array.isArray(d) && d.length === 0) ||
(typeof d === 'object' && Object.keys(d).length === 0));
}
if (typeof data === 'object') return Object.keys(data).length === 0;
if (typeof data === 'number') return false;
return !data;
}
/**
* Tab bar component. Writes to state[prop] on tab click.
* The caller is responsible for rendering tab body content.
*
* @param {object} props
* @param {object} props.state - Reactive state object
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
*/
export function Tabs(props = {}) {
const tabKeys = props.tabs || [];
const prop = props.prop || 'activeTab';
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
return h('div', { class: 'tabs' },
tabKeys.map(t => h('span', {
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
'on:click': () => {
props.state[prop] = t;
if (props.onTabClick) props.onTabClick(t);
},
style: 'cursor:pointer;',
}, formatLabel(t))),
);
}
/**
* Section header.
*
* @param {object} props
* @param {string} props.title
*/
export function SectionTitle(props = {}) {
return h('h3', { class: 'section-title' }, props.title);
}
/**
* Flex button container with 8px gap.
*
* @param {VNode[]} children
*/
export function ActionGroup(...children) {
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
}
/**
* DataTableSection — SectionTitle heading followed by a Table.
*
* @param {object} props
* @param {string} props.title - Section heading
* @param {string[]} props.columns
* @param {VNode[]} props.rows
* @param {string} [props.emptyText]
* @param {string} [props.key]
*/
export function DataTableSection(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
return h('div', { class: 'data-table-section', ...key },
SectionTitle({ title: props.title }),
Table({
columns: props.columns,
rows: props.rows,
emptyText: props.emptyText,
}),
);
}