diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 10fa5dc..8362bc9 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -34,6 +34,35 @@ + +
+
+ +
+
+
+ {{ field.label }} + {{ field.value }} +
+
+
+
@@ -71,7 +100,7 @@ import api from '../api' import { colorStyle } from '@/utils/colorStyle' import { toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink, - keyvalueFields, tableColumns, cell, badges, + keyvalueFields, tableColumns, cell, badges, tabSections, } from './pluginAssetPanels' const props = defineProps({ @@ -80,6 +109,12 @@ const props = defineProps({ const panels = ref([]) +// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather +// than on the panel object because panels are replaced wholesale on refetch. +const tabs = ref({}) +function activeTab(panel) { return tabs.value[panel.id] || 0 } +function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } } + // Fetch the declared panels for this asset, then each panel's data endpoint. async function load() { panels.value = [] @@ -118,10 +153,44 @@ watch(() => props.assetid, load, { immediate: true }) .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; } +/* Two aligned columns rather than space-between: a ragged right edge is hard + to scan once a panel carries more than a handful of rows, and a long value + (a UNC path) otherwise pushes its label away from the others. */ +.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; } +.pap-kv-row { display: contents; } .pap-kv-label { color: var(--text-light); } -.pap-kv-value { color: var(--text); font-weight: 500; } +.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; } +/* Tab strip: a quiet segmented control, not browser-default buttons. The + active tab is marked by an underline AND weight, so it does not rely on + colour alone. */ +.pap-tabstrip { + display: flex; + flex-wrap: wrap; + gap: 0.15rem; + margin-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} +.pap-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + padding: 0.35rem 0.7rem; + color: var(--text-light); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + white-space: nowrap; +} +.pap-tab:hover { color: var(--text); } +.pap-tab.on { + color: var(--primary); + border-bottom-color: var(--primary); + font-weight: 700; +} + .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); } diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js index 3872e41..7aebfa4 100644 --- a/frontend/src/components/pluginAssetPanels.js +++ b/frontend/src/components/pluginAssetPanels.js @@ -98,7 +98,24 @@ export function badges(panel) { return list.map((b) => ({ label: b.label, color: b.color })) } +// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a +// time, so a panel with 30 values stays the height of its largest section +// instead of dragging one multicol column far past the other. +export function tabSections(panel) { + const data = panel._data + const sections = (data && Array.isArray(data.sections)) ? data.sections : [] + return sections + .map((s) => ({ + label: s.label, + fields: (s.fields || []).map((f) => ({ + label: f.label, value: formatValue(f.value, f), mono: !!f.mono, + })), + })) + .filter((s) => s.fields.length > 0) +} + export function panelVisible(panel) { + if (panel.render === 'tabs') return tabSections(panel).length > 0 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 diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js index 7c65035..c48b00b 100644 --- a/frontend/src/components/pluginAssetPanels.spec.js +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink, - keyvalueFields, tableColumns, badges, panelVisible, + keyvalueFields, tableColumns, badges, panelVisible, tabSections, } from './pluginAssetPanels' const warrantyPanel = { @@ -98,3 +98,35 @@ describe('keyvalue + table + badge', () => { expect(badges({ render: 'badge', _data: { badges: [{ label: 'B', color: '#222' }] } })).toEqual([{ label: 'B', color: '#222' }]) }) }) + +describe('tabSections', () => { + const panel = { + render: 'tabs', + _data: { + sections: [ + { label: 'General', fields: [{ label: 'Cnc', value: 'OKUMA' }] }, + { label: 'eFocas', fields: [{ label: 'IpAddr', value: '192.168.1.1', mono: true }] }, + // A section whose fields all dropped out must not leave an empty tab. + { label: 'NTSHR', fields: [] }, + ], + }, + } + + it('returns one entry per non-empty section', () => { + expect(tabSections(panel).map((s) => s.label)).toEqual(['General', 'eFocas']) + }) + + it('formats field values and carries mono through', () => { + const efocas = tabSections(panel)[1] + expect(efocas.fields[0]).toEqual({ label: 'IpAddr', value: '192.168.1.1', mono: true }) + }) + + it('is empty for a payload with no sections', () => { + expect(tabSections({ render: 'tabs', _data: null })).toEqual([]) + }) + + it('drives panel visibility', () => { + expect(panelVisible(panel)).toBe(true) + expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false) + }) +})