USB external import, and filter chips on health-dot cards
This commit is contained in:
@@ -31,8 +31,25 @@
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
<template v-if="rows(panel).length">
|
||||
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
|
||||
<!-- Filter chips for any list panel that shows a health dot. Client
|
||||
side over rows already fetched, so the counts cannot disagree with
|
||||
what is on screen. -->
|
||||
<div v-if="facets(panel).length > 1" class="pap-facets">
|
||||
<button
|
||||
v-for="facet in facets(panel)"
|
||||
:key="facet.value"
|
||||
type="button"
|
||||
class="pap-facet"
|
||||
:class="{ on: isFacetOn(panel, facet.value) }"
|
||||
@click="toggleFacet(panel, facet.value)"
|
||||
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
|
||||
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
|
||||
type="button" class="pap-facet pap-facet-clear"
|
||||
@click="facetSelection[panel.id] = []">Clear</button>
|
||||
</div>
|
||||
|
||||
<template v-if="visibleRows(panel).length">
|
||||
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
@@ -59,6 +76,9 @@
|
||||
{{ actionError[panel.id] }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="rows(panel).length" class="pap-empty">
|
||||
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
|
||||
</div>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
// Which filter chips are active, per panel id. Deliberately NOT reset when the
|
||||
// data refreshes: a viewer who filtered to criticals and then hit refresh means
|
||||
// to still be looking at criticals.
|
||||
const facetSelection = ref({})
|
||||
|
||||
function isFacetOn(panel, value) {
|
||||
return (facetSelection.value[panel.id] || []).includes(value)
|
||||
}
|
||||
|
||||
function toggleFacet(panel, value) {
|
||||
const current = facetSelection.value[panel.id] || []
|
||||
facetSelection.value = {
|
||||
...facetSelection.value,
|
||||
[panel.id]: current.includes(value)
|
||||
? current.filter((v) => v !== value)
|
||||
: [...current, value],
|
||||
}
|
||||
}
|
||||
|
||||
function visibleRows(panel) {
|
||||
return facetedRows(panel, facetSelection.value[panel.id])
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
})
|
||||
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.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-facets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.pap-facet {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pap-facet:hover { border-color: var(--primary); }
|
||||
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
|
||||
.pap-facet-clear { border-style: dashed; }
|
||||
|
||||
/* The count is the point: a red dot over a list of info rows is confusing
|
||||
until the chip says "Critical 2" and one click shows them. */
|
||||
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
|
||||
.pap-manage { font-size: 0.82rem; }
|
||||
/* 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
|
||||
|
||||
@@ -45,6 +45,73 @@ export function rows(panel) {
|
||||
return list
|
||||
}
|
||||
|
||||
// Optional in-card filter chips. A list panel declares facets: {key, order,
|
||||
// labels} and the renderer offers one chip per distinct value of that key,
|
||||
// with a count. Filtering is client-side over rows the panel already fetched -
|
||||
// no extra request, and the counts stay honest because they are computed from
|
||||
// the same array.
|
||||
//
|
||||
// Declared rather than hardcoded because the reason it exists is generic: the
|
||||
// activity feed mixes info, warning and critical, and somebody triaging wants
|
||||
// the criticals alone. Any list panel with a category column has that problem.
|
||||
// Colour names carry no meaning to a reader. A panel keyed on statecolor
|
||||
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
|
||||
// a word (info/warning/critical) is labelled by the word itself.
|
||||
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
|
||||
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
|
||||
|
||||
// A panel that shows a health dot has already declared what it is dotting on.
|
||||
// Reuse that instead of asking every panel to declare filters a second time:
|
||||
// any card with a status rollup gets chips, and the two cannot disagree.
|
||||
function facetSpec(panel) {
|
||||
if (panel.facets && panel.facets.key) return panel.facets
|
||||
const status = panel.status
|
||||
if (!status || !status.key) return null
|
||||
const levels = status.levels || {}
|
||||
const order = Object.keys(levels).sort(
|
||||
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
|
||||
)
|
||||
const labels = {}
|
||||
Object.keys(levels).forEach((value) => {
|
||||
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
|
||||
? humanize(levels[value])
|
||||
: humanize(value)
|
||||
})
|
||||
return { key: status.key, order, labels }
|
||||
}
|
||||
|
||||
export function facets(panel) {
|
||||
const spec = facetSpec(panel)
|
||||
if (!spec || !spec.key) return []
|
||||
const all = rows(panel)
|
||||
const counts = new Map()
|
||||
all.forEach((item) => {
|
||||
const value = item[spec.key]
|
||||
if (value === null || value === undefined || value === '') return
|
||||
const key = String(value)
|
||||
counts.set(key, (counts.get(key) || 0) + 1)
|
||||
})
|
||||
// Declared order first, so severity reads critical-warning-info rather than
|
||||
// alphabetically; anything unexpected in the data still gets a chip.
|
||||
const declared = (spec.order || []).filter((value) => counts.has(value))
|
||||
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
|
||||
return [...declared, ...extra].map((value) => ({
|
||||
value,
|
||||
label: (spec.labels && spec.labels[value]) || humanize(value),
|
||||
count: counts.get(value)
|
||||
}))
|
||||
}
|
||||
|
||||
// rows() after the viewer's chip selection. An empty selection means "no filter
|
||||
// applied", NOT "hide everything": a card that vanished when you deselected the
|
||||
// last chip would read as broken.
|
||||
export function facetedRows(panel, selected) {
|
||||
const all = rows(panel)
|
||||
const spec = facetSpec(panel)
|
||||
if (!spec || !spec.key || !selected || !selected.length) return all
|
||||
return all.filter((item) => selected.includes(String(item[spec.key])))
|
||||
}
|
||||
|
||||
export function mapTitle(panel, item) {
|
||||
const key = panel.map && panel.map.title
|
||||
return key ? item[key] : ''
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const warrantyPanel = {
|
||||
@@ -228,3 +228,68 @@ describe('rowAction (inline MTConnect toggle)', () => {
|
||||
expect(info.toggleLabel).toBe('Start')
|
||||
})
|
||||
})
|
||||
|
||||
describe('facet chips on a card that shows a health dot', () => {
|
||||
// The case that prompted this: the Activity card shows a RED dot because a
|
||||
// critical event is in the data, while the visible rows are all info because
|
||||
// the critical is older and sits further down. The dot and the list appear to
|
||||
// disagree. Chips resolve it: the count sits under the dot, and one click
|
||||
// brings the critical rows to the surface.
|
||||
const activity = {
|
||||
id: 'endpoints-activity',
|
||||
render: 'list',
|
||||
status: {
|
||||
key: 'severity',
|
||||
levels: { info: 'ok', warning: 'warning', critical: 'critical' },
|
||||
},
|
||||
_data: [
|
||||
{ title: 'Successful logon', severity: 'info' },
|
||||
{ title: 'Successful logon', severity: 'info' },
|
||||
{ title: 'Scheduled task created', severity: 'warning' },
|
||||
{ title: 'Unexpected shutdown', severity: 'critical' },
|
||||
],
|
||||
}
|
||||
|
||||
it('derives chips from the status declaration, so no panel declares filters twice', () => {
|
||||
const chips = facets(activity)
|
||||
expect(chips.map((c) => c.value)).toEqual(['critical', 'warning', 'info'])
|
||||
})
|
||||
|
||||
it('orders worst first rather than alphabetically', () => {
|
||||
expect(facets(activity)[0].value).toBe('critical')
|
||||
})
|
||||
|
||||
it('counts from the same rows the dot is computed from, so they cannot disagree', () => {
|
||||
const chips = facets(activity)
|
||||
expect(chips.find((c) => c.value === 'critical').count).toBe(1)
|
||||
expect(chips.find((c) => c.value === 'info').count).toBe(2)
|
||||
expect(rollupStatus(activity).level).toBe('critical')
|
||||
})
|
||||
|
||||
it('labels a colour-keyed panel by its level, not by the colour name', () => {
|
||||
const services = {
|
||||
id: 'svc',
|
||||
render: 'list',
|
||||
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
|
||||
_data: [{ statecolor: 'green' }, { statecolor: 'red' }],
|
||||
}
|
||||
const labels = facets(services).map((c) => c.label)
|
||||
expect(labels).toContain('Critical')
|
||||
expect(labels).not.toContain('Red')
|
||||
})
|
||||
|
||||
it('shows everything when nothing is selected, rather than hiding the card', () => {
|
||||
expect(facetedRows(activity, []).length).toBe(4)
|
||||
expect(facetedRows(activity, undefined).length).toBe(4)
|
||||
})
|
||||
|
||||
it('filters to the selected severities, and allows more than one', () => {
|
||||
expect(facetedRows(activity, ['critical']).map((r) => r.title))
|
||||
.toEqual(['Unexpected shutdown'])
|
||||
expect(facetedRows(activity, ['critical', 'warning']).length).toBe(2)
|
||||
})
|
||||
|
||||
it('offers no chips for a panel with no status rollup', () => {
|
||||
expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user