tabbed DNC card, machine-numbered downloads, themed backup history

This commit is contained in:
2026-08-07 15:22:59 -04:00
parent 2487981d91
commit 3ebc1f353a
9 changed files with 233 additions and 50 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)
})
})