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.
This commit is contained in:
cproudlock
2026-08-07 15:22:31 -04:00
parent efe2f7e6ca
commit a33470a34b
3 changed files with 123 additions and 5 deletions

View File

@@ -34,6 +34,35 @@
</div>
</div>
<!-- tabs: several label/value sections, one visible at a time. Keeps a
panel with many fields to the height of its largest section - a
30-row card cannot be split by the two-column multicol layout and
drags one column far past the other. -->
<div v-else-if="panel.render === 'tabs'" class="pap-tabs">
<div class="pap-tabstrip" role="tablist">
<button
v-for="(section, si) in tabSections(panel)"
:key="si"
type="button"
role="tab"
class="pap-tab"
:class="{ on: si === activeTab(panel) }"
:aria-selected="si === activeTab(panel)"
@click="setTab(panel, si)"
>{{ section.label }}</button>
</div>
<div class="pap-kv">
<div
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
: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>
</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">
@@ -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); }

View File

@@ -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

View File

@@ -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)
})
})