The printer card was a name followed by a comma-joined string of cartridges and
a location - the widest row on the board, and the one running past the card
edge.
Now: the printer name links to its page and reveals its location on hover, and
each depleted cartridge is its own chip showing "Black 4%", revealing the part
number to order on hover. The percentage says something is wrong; the part
number says what to do about it, which today means opening the printer's page
to find out. Every capacity tier is listed, as the report has always done.
Two additions to the card contract, both general: 'chips' maps a row key to a
list of {text, title, level}, and 'titletooltip' puts context on the row title.
Nothing load-bearing goes in a tooltip - hover is not discoverable and does not
exist on touch - so a chip always states the fact and only explains it on hover.
Chips are bordered rather than filled: a row of solid red pills reads as an
emergency even when a cartridge is merely low.
Also repaired a self-inflicted mess. A string-slice edit used a marker that
appears EARLIER in the file, so the slice was empty and two helpers were
injected at line 1, above the module docstring. Removed; the file parses and
the helpers live beside the route they serve.
205 lines
7.0 KiB
JavaScript
205 lines
7.0 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
|
cardVisible, sortCards, permittedCards, renderableCards,
|
|
visibleRows, overflowCount,
|
|
} from './dashboardCards'
|
|
|
|
const failuresCard = {
|
|
id: 'geenforce-failures',
|
|
render: 'exceptions',
|
|
severity: 'critical',
|
|
position: 10,
|
|
map: {
|
|
title: 'hostname',
|
|
detail: 'entryname',
|
|
meta: [{ key: 'message' }, { key: 'exitcode', label: 'exit' }],
|
|
link: '/pcs/{computerid}',
|
|
},
|
|
}
|
|
|
|
describe('endpoint paths', () => {
|
|
it('strips the api prefix the instance already carries', () => {
|
|
expect(toApiPath('/api/geenforce/dashboard/failures'))
|
|
.toBe('/geenforce/dashboard/failures')
|
|
})
|
|
|
|
it('leaves a relative endpoint alone', () => {
|
|
expect(toApiPath('/geenforce/x')).toBe('/geenforce/x')
|
|
})
|
|
})
|
|
|
|
describe('reading rows from whatever shape the endpoint returns', () => {
|
|
it('accepts a bare array, {rows} or {items}', () => {
|
|
expect(rows({ _data: [1, 2] })).toEqual([1, 2])
|
|
expect(rows({ _data: { rows: [1] } })).toEqual([1])
|
|
expect(rows({ _data: { items: [1, 2, 3] } })).toEqual([1, 2, 3])
|
|
})
|
|
|
|
it('treats a failed fetch as no rows rather than throwing', () => {
|
|
expect(rows({ _data: null })).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('mapping a row', () => {
|
|
it('builds title, detail, meta and link from the declaration', () => {
|
|
const card = { ...failuresCard, _data: [{
|
|
hostname: 'WJSF1234', entryname: 'Install OpenText',
|
|
exitcode: 1603, message: 'Fatal error', computerid: 42,
|
|
}] }
|
|
const [row] = cardRows(card)
|
|
expect(row.title).toBe('WJSF1234')
|
|
expect(row.detail).toBe('Install OpenText')
|
|
expect(row.meta.map((m) => m.text)).toEqual(['Fatal error', 'exit 1603'])
|
|
expect(row.link).toBe('/pcs/42')
|
|
})
|
|
|
|
it('supports a trailing unit so the text reads as a sentence', () => {
|
|
const card = {
|
|
render: 'exceptions',
|
|
map: { title: 'hostname', meta: [{ key: 'quietdays', label: 'quiet for', suffix: ' days' }] },
|
|
_data: [{ hostname: 'QUIETPC', quietdays: 3 }],
|
|
}
|
|
expect(cardRows(card)[0].meta[0].text).toBe('quiet for 3 days')
|
|
})
|
|
|
|
it('drops empty meta values instead of rendering a stray label', () => {
|
|
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
|
|
expect(cardRows(card)[0].meta).toEqual([])
|
|
})
|
|
|
|
it('omits the link when the substitution value is missing, keeping the row', () => {
|
|
// A PC shopdb does not know still reports its failure - that is the bay
|
|
// most likely to be misconfigured. It must not link to /pcs/undefined.
|
|
const card = { ...failuresCard, _data: [{ hostname: 'GHOSTPC', computerid: null }] }
|
|
const [row] = cardRows(card)
|
|
expect(row.title).toBe('GHOSTPC')
|
|
expect(row.link).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('long lists', () => {
|
|
const many = (n) => ({
|
|
render: 'exceptions',
|
|
map: { title: 'hostname' },
|
|
_data: Array.from({ length: n }, (_v, i) => ({ hostname: `PC${i}` })),
|
|
})
|
|
|
|
it('shows at most five rows so one card cannot bury the rest', () => {
|
|
expect(visibleRows(many(40))).toHaveLength(5)
|
|
expect(overflowCount(many(40))).toBe(35)
|
|
})
|
|
|
|
it('does not claim an overflow when everything fits', () => {
|
|
expect(visibleRows(many(3))).toHaveLength(3)
|
|
expect(overflowCount(many(3))).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('empty handling', () => {
|
|
it('hides a card with nothing to report by default', () => {
|
|
// The whole point: a card saying "nothing wrong" daily trains people to
|
|
// stop reading the page.
|
|
expect(cardVisible({ render: 'exceptions', _data: [] })).toBe(false)
|
|
})
|
|
|
|
it('keeps a card whose absence is itself news when it opts in', () => {
|
|
expect(cardVisible({ render: 'exceptions', _data: [], empty: 'line' })).toBe(true)
|
|
})
|
|
|
|
it('shows a card that has findings', () => {
|
|
expect(cardVisible({ render: 'exceptions', _data: [{ a: 1 }] })).toBe(true)
|
|
})
|
|
|
|
it('hides a card whose fetch failed rather than drawing it empty', () => {
|
|
expect(cardVisible({ render: 'exceptions', _data: null })).toBe(false)
|
|
})
|
|
|
|
it('hides a zero metric but shows a non-zero one', () => {
|
|
expect(cardVisible({ render: 'metric', _data: { value: 0 } })).toBe(false)
|
|
expect(cardVisible({ render: 'metric', _data: { value: 3 } })).toBe(true)
|
|
expect(metricValue({ render: 'metric', _data: { value: 3 } })).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('ordering', () => {
|
|
it('puts severity before position, so info never sits above a failure', () => {
|
|
const ordered = sortCards([
|
|
{ id: 'info-recent', severity: 'info', position: 1 },
|
|
{ id: 'crit-failures', severity: 'critical', position: 90 },
|
|
{ id: 'warn-toner', severity: 'warning', position: 50 },
|
|
])
|
|
expect(ordered.map((c) => c.id))
|
|
.toEqual(['crit-failures', 'warn-toner', 'info-recent'])
|
|
})
|
|
|
|
it('falls back to position within one severity', () => {
|
|
const ordered = sortCards([
|
|
{ id: 'b', severity: 'critical', position: 20 },
|
|
{ id: 'a', severity: 'critical', position: 10 },
|
|
])
|
|
expect(ordered.map((c) => c.id)).toEqual(['a', 'b'])
|
|
})
|
|
})
|
|
|
|
describe('gating', () => {
|
|
it('drops a card the user lacks permission for', () => {
|
|
// The dashboard must not become a way around RBAC.
|
|
const has = (name) => name === 'printers.view'
|
|
const kept = permittedCards([
|
|
{ id: 'toner', permission: 'printers.view' },
|
|
{ id: 'enforce', permission: 'geenforce.manage' },
|
|
{ id: 'open', permission: null },
|
|
], has)
|
|
expect(kept.map((c) => c.id)).toEqual(['toner', 'open'])
|
|
})
|
|
|
|
it('skips a render mode this core does not have', () => {
|
|
// A plugin built against a newer core degrades instead of leaving a hole.
|
|
const kept = renderableCards([
|
|
{ id: 'ok', render: 'exceptions' },
|
|
{ id: 'future', render: 'sparkline' },
|
|
])
|
|
expect(kept.map((c) => c.id)).toEqual(['ok'])
|
|
})
|
|
})
|
|
|
|
describe('chips and tooltips', () => {
|
|
const printerCard = {
|
|
render: 'exceptions',
|
|
map: {
|
|
title: 'printername',
|
|
titletooltip: 'location',
|
|
chips: 'supplies',
|
|
link: '/printers/{printerid}',
|
|
},
|
|
_data: [{
|
|
printerid: 7,
|
|
printername: 'WJ-HP-402',
|
|
location: 'Cell B, north wall',
|
|
supplies: [
|
|
{ text: 'Black 4%', title: 'CF226X (high)', level: 'critical' },
|
|
{ text: 'Cyan 9%', title: 'No part number on file for this model', level: 'low' },
|
|
],
|
|
}],
|
|
}
|
|
|
|
it('builds one chip per depleted supply, each with its reorder tooltip', () => {
|
|
const [row] = cardRows(printerCard)
|
|
expect(row.chips.map((c) => c.text)).toEqual(['Black 4%', 'Cyan 9%'])
|
|
expect(row.chips[0].title).toBe('CF226X (high)')
|
|
expect(row.chips[0].level).toBe('critical')
|
|
})
|
|
|
|
it('puts the location on the title as a tooltip, not in the line', () => {
|
|
const [row] = cardRows(printerCard)
|
|
expect(row.titletip).toBe('Cell B, north wall')
|
|
expect(row.detail).toBe('')
|
|
})
|
|
|
|
it('has no chips when a card declares none', () => {
|
|
expect(cardRows({ render: 'exceptions', map: { title: 'x' }, _data: [{ x: 'y' }] })[0].chips)
|
|
.toEqual([])
|
|
})
|
|
})
|