Email report on every report page; kiosk long-name fix

This commit is contained in:
2026-08-27 11:01:28 -04:00
parent 4dfbf5f8ec
commit 5f069b1a5f
7 changed files with 233 additions and 12 deletions

View File

@@ -0,0 +1,69 @@
// Turning a report payload into the one table shape everything else speaks.
//
// POST /api/reports/email has always taken {columns, rows} from any caller, but
// only two pages ever offered an Email report button - the two that happen to
// be their own routed pages. Every other report was reachable only from the
// reports page, which had no button at all. This builds that table from
// whatever shape the open report returned, so the offer is the same everywhere.
export function titleCase (text) {
return String(text || '')
.split(/[\s_-]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Presentation-only keys. A colour swatch and a row id mean nothing in an
// email, and a column of hex codes is worse than no column at all.
const EMAIL_SKIP = ['color', 'linkid', 'linkurl', 'id']
function emailcolumns (keys) {
return keys.filter(key => !EMAIL_SKIP.includes(key))
.map(key => ({ key, label: titleCase(key) }))
}
export function emailTableFrom (data) {
if (!data) return null
// Any report that speaks the {columns, rows} contract - every plugin report,
// and any core one that adopts it. Declared columns win; otherwise take them
// from the first row, the same way the on-screen table does.
if (Array.isArray(data.rows) && data.rows.length) {
const keys = (Array.isArray(data.columns) && data.columns.length)
? data.columns
: Object.keys(data.rows[0])
return { columns: emailcolumns(keys), rows: data.rows }
}
// The core reports, which return a flat list under `data`.
if (Array.isArray(data.data) && data.data.length) {
return { columns: emailcolumns(Object.keys(data.data[0])), rows: data.data }
}
// Asset inventory is three tables. One email, so they become one table with
// the grouping named in a column - sending a third of it would be worse.
if (data.data && typeof data.data === 'object') {
const rows = []
for (const [section, entries] of Object.entries(data.data)) {
if (!Array.isArray(entries)) continue
for (const entry of entries) {
rows.push({
section: titleCase(section),
name: entry.type || entry.status || entry.location || '',
count: entry.count,
})
}
}
if (!rows.length) return null
return {
columns: [{ key: 'section', label: 'Grouping' },
{ key: 'name', label: 'Name' },
{ key: 'count', label: 'Count' }],
rows,
}
}
// An empty report has nothing to send. The button hides rather than mailing
// somebody a table with no rows in it.
return null
}

View File

@@ -0,0 +1,84 @@
// Every shape a report on this app actually returns, because the Email report
// button now has to build a table out of all of them rather than only the two
// that had their own page.
import { describe, it, expect } from 'vitest'
import { emailTableFrom, titleCase } from './reportTable'
describe('emailTableFrom', () => {
it('has nothing to send for a report that has not run', () => {
expect(emailTableFrom(null)).toBe(null)
expect(emailTableFrom({})).toBe(null)
})
it('uses the declared columns of a {columns, rows} report', () => {
const table = emailTableFrom({
columns: ['timestamp', 'action', 'device_id'],
rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }],
})
expect(table.columns).toEqual([
{ key: 'timestamp', label: 'Timestamp' },
{ key: 'action', label: 'Action' },
{ key: 'device_id', label: 'Device Id' },
])
expect(table.rows).toHaveLength(1)
})
it('falls back to the first row when a report declares no columns', () => {
const table = emailTableFrom({ rows: [{ item: 'a', takes: 2 }] })
expect(table.columns.map(c => c.key)).toEqual(['item', 'takes'])
})
it('builds a table from a core report that returns a flat list', () => {
const table = emailTableFrom({
report: 'machines_by_type',
total: 231,
data: [{ machinetype: 'Lathe', description: 'd', count: 12 }],
})
expect(table.columns.map(c => c.key)).toEqual(['machinetype', 'description', 'count'])
expect(table.rows[0].count).toBe(12)
})
it('drops the keys that only mean something on screen', () => {
const table = emailTableFrom({
data: [{ status: 'In Use', color: '#28a745', count: 572 }],
})
expect(table.columns.map(c => c.key)).toEqual(['status', 'count'])
const kb = emailTableFrom({
data: [{ linkid: 4, linkurl: 'http://x', shortdescription: 'How to', clicks: 9 }],
})
expect(kb.columns.map(c => c.key)).toEqual(['shortdescription', 'clicks'])
})
it('flattens the three tables of asset inventory into one, named', () => {
const table = emailTableFrom({
data: {
bytype: [{ type: 'computer', count: 289 }],
bystatus: [{ status: 'In Use', color: '#0f0', count: 572 }],
bylocation: [{ location: 'Cell A', count: 3 }],
},
})
expect(table.columns.map(c => c.key)).toEqual(['section', 'name', 'count'])
expect(table.rows).toEqual([
{ section: 'Bytype', name: 'computer', count: 289 },
{ section: 'Bystatus', name: 'In Use', count: 572 },
{ section: 'Bylocation', name: 'Cell A', count: 3 },
])
})
it('has nothing to send for a report that came back empty', () => {
expect(emailTableFrom({ data: [], total: 0 })).toBe(null)
expect(emailTableFrom({ columns: ['a'], rows: [] })).toBe(null)
expect(emailTableFrom({ data: { bytype: [], bystatus: [] } })).toBe(null)
})
})
describe('titleCase', () => {
it('turns a column key into a heading', () => {
expect(titleCase('locker_location')).toBe('Locker Location')
expect(titleCase('machines-by-type')).toBe('Machines By Type')
expect(titleCase('')).toBe('')
})
})