Filter chips on any card that shows a health dot
Reported from the Activity card: the header dot was red while every visible row was info, because the critical event was older and sat further down the list. The dot and the list appeared to contradict each other, and the only way to find the critical was to scroll. The dot is computed from all rows the panel received, so a red dot always means a critical IS in the data. The chips are computed from that same array, which means the count sits directly under the dot and the two cannot disagree by construction: the card now reads 'Critical 2 Warning 1 Info 12', and one click surfaces them wherever they sit in time. Derived from the panel's EXISTING status declaration rather than a new one per panel. A card that shows a health dot has already said what it dots on, so Tracked Services and Tracked Processes get chips with no plugin change, and any future list panel with a status rollup does too. Two declarations of the same thing would eventually disagree. Details that matter: a colour-keyed panel is labelled by meaning, so services read Critical and OK rather than Red and Green; chips are ordered worst first rather than alphabetically; and no selection means show everything, not hide everything - a card that emptied when you deselected the last chip would read as broken. Filtering to nothing says so, and gives the hidden count. vitest 267 passed, including the exact reported case.
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