Files
shopdb-flask/frontend/src/components/pluginAssetPanels.js
cproudlock e3c4b90afe ADR-013 Phase 3: generic asset-panels renderer (Path A)
Wires the ADR-010 get_asset_panels hook to a generic frontend renderer so a
plugin adds detail-page UI as JSON, no Vue. This is the Path A foundation that
lets simple plugins ship UI without a frontend build.

- components/PluginAssetPanels.vue + pluginAssetPanels.js: fetches
  /api/pluginui/asset-panels for an asset, then each panel's data endpoint, and
  renders by mode: list (title + status badge + meta lines via a field map),
  keyvalue, table (declared or inferred columns), badge. Pure mapping logic is
  in the .js module and unit tested (9 specs), same pattern as entryForm.js.
- New 'list' render mode with a declarative field map (title/badge/meta),
  documented on the hook in base.py.
- Warranty migrated to it: get_asset_panels now declares a 'list' panel + map
  that reproduces WarrantyPanel's output (vendor title, status badge with color
  + label map, servicelevel/ends/tag meta, manage link) with zero
  warranty-specific frontend code.
- MachineDetail swapped from <WarrantyPanel> to <PluginAssetPanels> (pilot); the
  hero warranty badge is unchanged. Verified end to end: the API serves the list
  panel + map and the warranty rows; the page renders without error.

Rollout of the other 4 detail pages (PCDetail, PrinterDetail, NetworkDeviceDetail,
MeasuringToolDetail) and the map-overlays / asset-presentation renderers are
follow-up Phase 3 commits. 58 vitest, build clean, 1067 backend pass, naming green.
2026-07-18 22:43:54 -04:00

108 lines
3.5 KiB
JavaScript

// Pure render helpers for PluginAssetPanels.vue (ADR-010 get_asset_panels, Path
// A). Kept separate from the .vue so the mapping logic - which turns a plugin's
// JSON panel declaration + its endpoint data into rendered rows - is unit
// tested directly (same pattern as geenforce/entryForm.js).
export function subst(text, assetid) {
return String(text).replace('{assetid}', assetid)
}
// Panel endpoints are declared absolute (/api/...); the api instance already
// carries the /api base, so strip it before fetching through the instance.
export function toApiPath(endpoint, assetid) {
return subst(endpoint, assetid).replace(/^\/api(?=\/)/, '')
}
export function formatValue(value, spec = {}) {
if (value === null || value === undefined || value === '') return ''
if (spec.format === 'date') {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
export function humanize(key) {
return key.charAt(0).toUpperCase() + key.slice(1)
}
export function rows(panel) {
const data = panel._data
if (Array.isArray(data)) return data
if (data && Array.isArray(data.rows)) return data.rows
if (data && Array.isArray(data.items)) return data.items
return []
}
export function mapTitle(panel, item) {
const key = panel.map && panel.map.title
return key ? item[key] : ''
}
export function mapBadge(panel, item) {
const badge = panel.map && panel.map.badge
if (!badge) return null
const raw = item[badge.label]
const label = (badge.labelmap && badge.labelmap[raw]) || raw
return { label, color: badge.color ? item[badge.color] : undefined }
}
export function mapMeta(panel, item) {
const meta = (panel.map && panel.map.meta) || []
return meta
.map((spec) => {
const value = formatValue(item[spec.key], spec)
if (value === '') return null
const text = spec.label ? `${spec.label} ${value}` : value
return { text, mono: !!spec.mono }
})
.filter(Boolean)
}
export function manageLink(panel, assetid) {
return panel.manage ? subst(panel.manage.to, assetid) : null
}
export function keyvalueFields(panel) {
const data = panel._data
if (data && Array.isArray(data.fields)) {
return data.fields.map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
}))
}
if (data && typeof data === 'object' && !Array.isArray(data)) {
return Object.entries(data).map(([k, v]) => ({
label: humanize(k), value: formatValue(v),
}))
}
return []
}
export function tableColumns(panel) {
if (Array.isArray(panel.columns) && panel.columns.length) {
return panel.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) }))
}
const first = rows(panel)[0]
if (!first) return []
return Object.keys(first).map((k) => ({ key: k, label: humanize(k) }))
}
export function cell(row, col) {
return formatValue(row[col.key], col)
}
export function badges(panel) {
const data = panel._data
const list = Array.isArray(data) ? data : (data && data.badges) || []
return list.map((b) => ({ label: b.label, color: b.color }))
}
export function panelVisible(panel) {
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0
if (panel.render === 'badge') return badges(panel).length > 0
return false
}