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.
132 lines
5.3 KiB
Vue
132 lines
5.3 KiB
Vue
<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>
|