From a33470a34b537546866d7db3c495d32314f76ae1 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 7 Aug 2026 15:22:31 -0400 Subject: [PATCH] asset panels: add a tabs render mode, align the keyvalue grid A panel with many fields was unusable. The detail page is a two-column multicol (.content-column is display:contents, so the left/right wrappers are dissolved and cards flow into one balanced flow), and a card cannot be split across a column break. One 30-row panel therefore became a single unbreakable block that dragged its column far past the other. The tabs mode renders sections one at a time, so such a panel stays the height of its largest section. Payload is {sections: [{label, fields}]}; sections that end up with no fields are dropped rather than left as empty tabs. The active tab is marked by an underline AND weight, not colour alone. keyvalue rows now sit in two aligned columns instead of space-between: a ragged right edge is hard to scan, and one long value (a UNC path) otherwise pushed its label away from every other row. Four specs cover the new mode, including that it drives panel visibility. --- frontend/src/components/PluginAssetPanels.vue | 77 ++++++++++++++++++- frontend/src/components/pluginAssetPanels.js | 17 ++++ .../src/components/pluginAssetPanels.spec.js | 34 +++++++- 3 files changed, 123 insertions(+), 5 deletions(-) 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) + }) +})