Email report on every report page; kiosk long-name fix
This commit is contained in:
69
frontend/src/utils/reportTable.js
Normal file
69
frontend/src/utils/reportTable.js
Normal 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
|
||||
}
|
||||
84
frontend/src/utils/reportTable.spec.js
Normal file
84
frontend/src/utils/reportTable.spec.js
Normal 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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user