3de82e3b9b
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.
172 lines
5.7 KiB
JavaScript
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,
|
|
}),
|
|
);
|
|
}
|