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.
This commit is contained in:
131
frontend/src/components/PluginAssetPanels.vue
Normal file
131
frontend/src/components/PluginAssetPanels.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
|
||||
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card">
|
||||
<h3 class="section-title">{{ panel.title }}</h3>
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
<template v-if="rows(panel).length">
|
||||
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="mapMeta(panel, item).length" class="pap-meta">
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
:class="{ mono: m.mono }">{{ m.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.emptylabel || panel.manage.label || 'Add' }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- keyvalue: a label/value grid -->
|
||||
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
|
||||
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
|
||||
<span class="pap-kv-label">{{ field.label }}</span>
|
||||
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
||||
<table class="pap-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in rows(panel)" :key="ri">
|
||||
<td v-for="col in tableColumns(panel)" :key="col.key">{{ cell(row, col) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- badge: a row of colored badges -->
|
||||
<div v-else-if="panel.render === 'badge'" class="pap-badges">
|
||||
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
|
||||
:style="colorStyle(b.color)">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const panels = ref([])
|
||||
|
||||
// Fetch the declared panels for this asset, then each panel's data endpoint.
|
||||
async function load() {
|
||||
panels.value = []
|
||||
if (!props.assetid) return
|
||||
let declared
|
||||
try {
|
||||
const response = await api.get('/pluginui/asset-panels', {
|
||||
params: { assetid: props.assetid },
|
||||
})
|
||||
declared = response.data.data || []
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
const withData = await Promise.all(
|
||||
declared.map(async (panel) => {
|
||||
try {
|
||||
const response = await api.get(toApiPath(panel.endpoint, props.assetid))
|
||||
return { ...panel, _data: response.data.data }
|
||||
} catch (err) {
|
||||
return { ...panel, _data: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
panels.value = withData
|
||||
}
|
||||
|
||||
watch(() => props.assetid, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pap-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.pap-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
|
||||
.pap-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.pap-title { font-weight: 600; color: var(--text); }
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.pap-manage { font-size: 0.82rem; }
|
||||
.pap-kv { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.pap-kv-row { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.pap-kv-label { color: var(--text-light); }
|
||||
.pap-kv-value { color: var(--text); font-weight: 500; }
|
||||
.pap-table-wrap { overflow-x: auto; }
|
||||
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.mono { font-family: monospace; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
107
frontend/src/components/pluginAssetPanels.js
Normal file
107
frontend/src/components/pluginAssetPanels.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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
|
||||
}
|
||||
100
frontend/src/components/pluginAssetPanels.spec.js
Normal file
100
frontend/src/components/pluginAssetPanels.spec.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const warrantyPanel = {
|
||||
id: 'warranty', title: 'Warranty', render: 'list',
|
||||
map: {
|
||||
title: 'vendor',
|
||||
badge: {
|
||||
label: 'status', color: 'statuscolor',
|
||||
labelmap: { active: 'Active', expired: 'Expired' },
|
||||
},
|
||||
meta: [
|
||||
{ key: 'servicelevel' },
|
||||
{ key: 'enddate', label: 'Ends', format: 'date' },
|
||||
{ key: 'servicetag', label: 'Tag', mono: true },
|
||||
],
|
||||
},
|
||||
empty: 'No warranty on record.',
|
||||
manage: { to: '/warranties?addfor={assetid}', label: 'Add / manage' },
|
||||
_data: [
|
||||
{
|
||||
vendor: 'Dell', status: 'active', statuscolor: '#4CAF50',
|
||||
servicelevel: 'ProSupport', enddate: '2027-01-01', servicetag: 'ABC123',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('endpoint + subst', () => {
|
||||
it('substitutes {assetid} and strips the leading /api', () => {
|
||||
expect(toApiPath('/api/warranty/asset/{assetid}', 5)).toBe('/warranty/asset/5')
|
||||
expect(subst('/warranties?addfor={assetid}', 9)).toBe('/warranties?addfor=9')
|
||||
})
|
||||
it('only strips a leading /api segment, not /apixyz', () => {
|
||||
expect(toApiPath('/apixyz/thing', 1)).toBe('/apixyz/thing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('list render mapping (warranty)', () => {
|
||||
it('maps title, badge (labelmap + color), and formatted meta', () => {
|
||||
const item = rows(warrantyPanel)[0]
|
||||
expect(mapTitle(warrantyPanel, item)).toBe('Dell')
|
||||
|
||||
const badge = mapBadge(warrantyPanel, item)
|
||||
expect(badge.label).toBe('Active')
|
||||
expect(badge.color).toBe('#4CAF50')
|
||||
|
||||
const meta = mapMeta(warrantyPanel, item)
|
||||
expect(meta[0].text).toBe('ProSupport')
|
||||
expect(meta[1].text).toMatch(/^Ends /) // date formatted + labelled
|
||||
expect(meta[2]).toEqual({ text: 'Tag ABC123', mono: true })
|
||||
})
|
||||
|
||||
it('drops empty meta values', () => {
|
||||
const item = { vendor: 'HP', status: 'expired', statuscolor: '#F44336' }
|
||||
const meta = mapMeta(warrantyPanel, { ...item })
|
||||
expect(meta).toEqual([]) // no servicelevel/enddate/servicetag
|
||||
expect(mapBadge(warrantyPanel, item).label).toBe('Expired')
|
||||
})
|
||||
|
||||
it('builds the manage link with the assetid', () => {
|
||||
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
|
||||
})
|
||||
|
||||
it('is visible when it has rows or an empty message', () => {
|
||||
expect(panelVisible(warrantyPanel)).toBe(true)
|
||||
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
|
||||
expect(panelVisible({ render: 'list', _data: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyvalue + table + badge', () => {
|
||||
it('keyvalue reads a fields array or a plain object', () => {
|
||||
const fromFields = keyvalueFields({
|
||||
render: 'keyvalue', _data: { fields: [{ label: 'Cal due', value: '2026-01-01', format: 'date' }] },
|
||||
})
|
||||
expect(fromFields[0].label).toBe('Cal due')
|
||||
|
||||
const fromObject = keyvalueFields({ render: 'keyvalue', _data: { hostname: 'PC1', online: true } })
|
||||
expect(fromObject).toEqual([
|
||||
{ label: 'Hostname', value: 'PC1' },
|
||||
{ label: 'Online', value: 'Yes' },
|
||||
])
|
||||
})
|
||||
|
||||
it('table uses declared columns, else infers from row keys', () => {
|
||||
const declared = tableColumns({ columns: [{ key: 'a', label: 'Alpha' }], _data: [{ a: 1 }] })
|
||||
expect(declared).toEqual([{ key: 'a', label: 'Alpha' }])
|
||||
|
||||
const inferred = tableColumns({ _data: [{ vendor: 'x', model: 'y' }] })
|
||||
expect(inferred.map((c) => c.label)).toEqual(['Vendor', 'Model'])
|
||||
})
|
||||
|
||||
it('badge reads an array or a badges field', () => {
|
||||
expect(badges({ render: 'badge', _data: [{ label: 'A', color: '#111' }] })).toEqual([{ label: 'A', color: '#111' }])
|
||||
expect(badges({ render: 'badge', _data: { badges: [{ label: 'B', color: '#222' }] } })).toEqual([{ label: 'B', color: '#222' }])
|
||||
})
|
||||
})
|
||||
@@ -204,8 +204,8 @@
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="machine.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
|
||||
<!-- Plugin-contributed detail panels (ADR-010), incl. Warranty -->
|
||||
<PluginAssetPanels :assetid="machine.assetid" />
|
||||
|
||||
<!-- All relationships (dualpath, controls, ...) -->
|
||||
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
||||
@@ -237,7 +237,7 @@ import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import PluginAssetPanels from '../../components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
@@ -247,7 +247,7 @@ const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
||||
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
|
||||
@@ -83,7 +83,33 @@ class WarrantyPlugin(BasePlugin):
|
||||
'title': 'Warranty',
|
||||
'assettypes': ['*'],
|
||||
'endpoint': '/api/warranty/asset/{assetid}',
|
||||
'render': 'table',
|
||||
'render': 'list',
|
||||
# Field map: the generic renderer builds each list item from the
|
||||
# warranty payload without any warranty-specific frontend code.
|
||||
'map': {
|
||||
'title': 'vendor',
|
||||
'badge': {
|
||||
'label': 'status',
|
||||
'color': 'statuscolor',
|
||||
'labelmap': {
|
||||
'active': 'Active',
|
||||
'expiring': 'Expiring Soon',
|
||||
'expired': 'Expired',
|
||||
'unknown': 'Unknown',
|
||||
},
|
||||
},
|
||||
'meta': [
|
||||
{'key': 'servicelevel'},
|
||||
{'key': 'enddate', 'label': 'Ends', 'format': 'date'},
|
||||
{'key': 'servicetag', 'label': 'Tag', 'mono': True},
|
||||
],
|
||||
},
|
||||
'empty': 'No warranty on record.',
|
||||
'manage': {
|
||||
'to': '/warranties?addfor={assetid}',
|
||||
'label': 'Add / manage',
|
||||
'emptylabel': 'Add one',
|
||||
},
|
||||
'position': 30,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -297,7 +297,9 @@ class BasePlugin(ABC):
|
||||
'title': str, # panel heading
|
||||
'assettypes': List[str], # AssetType keys it appears on; ['*'] = all
|
||||
'endpoint': str, # data endpoint (may contain {assetid})
|
||||
'render': str, # 'keyvalue' | 'table' | 'badge'
|
||||
'render': str, # 'keyvalue' | 'table' | 'badge' | 'list'
|
||||
# ('list' takes a 'map' of title/badge/meta
|
||||
# keys, rendered generically - see warranty)
|
||||
'position': int, # order among panels
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_asset_panels_match_asset_type(app, client, db, auth_headers, monkeypatc
|
||||
assert warranty is not None, 'warranty asset panel missing'
|
||||
assert warranty['id'] == 'warranty'
|
||||
assert warranty['endpoint'] == '/api/warranty/asset/{assetid}'
|
||||
assert warranty['render'] in ('keyvalue', 'table', 'badge')
|
||||
assert warranty['render'] in ('keyvalue', 'table', 'badge', 'list')
|
||||
|
||||
|
||||
def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user