diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue new file mode 100644 index 0000000..10fa5dc --- /dev/null +++ b/frontend/src/components/PluginAssetPanels.vue @@ -0,0 +1,131 @@ + + + + + {{ panel.title }} + + + + + + + {{ mapTitle(panel, item) }} + + {{ mapBadge(panel, item).label }} + + + + {{ m.text }} + + + + {{ panel.manage.label || 'Manage' }} + + + + {{ panel.empty || 'Nothing to show.' }} + + {{ panel.manage.emptylabel || panel.manage.label || 'Add' }} + + + + + + + + {{ field.label }} + {{ field.value }} + + + + + + + + {{ col.label }} + + + + {{ cell(row, col) }} + + + + + + + + {{ b.label }} + + + + + + + + diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js new file mode 100644 index 0000000..3872e41 --- /dev/null +++ b/frontend/src/components/pluginAssetPanels.js @@ -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 +} diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js new file mode 100644 index 0000000..7c65035 --- /dev/null +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -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' }]) + }) +}) diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue index 566197a..61cab8b 100644 --- a/frontend/src/views/machines/MachineDetail.vue +++ b/frontend/src/views/machines/MachineDetail.vue @@ -204,8 +204,8 @@ - - + + @@ -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 diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py index 49ad761..146a8e3 100644 --- a/plugins/warranty/plugin.py +++ b/plugins/warranty/plugin.py @@ -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, }, ] diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py index 6a39e2a..b423ece 100644 --- a/shopdb/plugins/base.py +++ b/shopdb/plugins/base.py @@ -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 } diff --git a/tests/test_core/test_pluginui.py b/tests/test_core/test_pluginui.py index b218601..d61c68c 100644 --- a/tests/test_core/test_pluginui.py +++ b/tests/test_core/test_pluginui.py @@ -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):