Every report offers to email itself, and a long drive name stays in its card
Some checks failed
CI / naming (push) Successful in 1m58s
CI / frontend (push) Successful in 1m49s
CI / migrations-mysql (push) Failing after 9m0s
CI / backend (push) Has been cancelled

POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.

The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.

Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
This commit is contained in:
cproudlock
2026-08-27 10:55:33 -04:00
parent 605dd6cb1b
commit de3ca70592
7 changed files with 233 additions and 12 deletions

View File

@@ -24,6 +24,27 @@ ADR-007 and ADR-002.
### Fixed ### Fixed
- **Every report offers to email itself, not just two of them.** `POST
/api/reports/email` has always taken any `{columns, rows}`, but the button was
on the toner and warranty pages only - because those two happen to be their
own routed pages, and the reports page had no button at all. It is now beside
Export CSV for every report the page can build a table from, which is all of
them: the plugin reports through their declared columns, the core reports
through the flat list they return, and the asset inventory summary by
flattening its three tables into one with the grouping named in a column.
Presentation-only fields - a status colour, a link id - are left out, since a
column of hex codes is worse than no column. The button is hidden without the
`reports.export` permission the endpoint enforces, on all three pages: offering
somebody a button that can only answer 403 is worse than not offering it.
- **A long drive name pushed Take and Return out of the card.** On the USB kiosk
the name is a flex item, which defaults to `min-width: auto`, so it kept its
full intrinsic width and shoved the button through the side of the card -
leaving a drive nobody could take. Names now wrap, including the serial-style
ones that carry no spaces at all, and a description is clamped to two lines so
one long name cannot make every card in its row tall. The locker line is never
clipped: it is how somebody finds the thing.
- **Muted text on the USB kiosk and in guides was not muted.** Both named a - **Muted text on the USB kiosk and in guides was not muted.** Both named a
`--textmuted` variable this app has never defined, so the browser dropped the `--textmuted` variable this app has never defined, so the browser dropped the
declaration and the hints, subtitles and locker lines rendered at full declaration and the hints, subtitles and locker lines rendered at full

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('')
})
})

View File

@@ -43,6 +43,9 @@
<h2>{{ currentReport.name }}</h2> <h2>{{ currentReport.name }}</h2>
<div class="report-actions"> <div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button> <button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="emailtable && canemail"
:subject="currentReport.name"
:columns="emailtable.columns" :rows="emailtable.rows" />
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button> <button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
</div> </div>
</div> </div>
@@ -266,9 +269,18 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api' import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import EmailReportButton from '@/components/EmailReportButton.vue'
import { emailTableFrom, titleCase } from '@/utils/reportTable'
import { useAuthStore } from '@/stores/auth'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
// Emailing a report is the reports.export permission, the same one the endpoint
// enforces. Offering the button to somebody who will only get a 403 is worse
// than not offering it.
const canemail = computed(() => authStore.hasPermission('reports.export'))
const reports = ref([]) const reports = ref([])
const search = ref('') const search = ref('')
@@ -321,6 +333,12 @@ const genericColumns = computed(() => {
return data.rows?.length ? Object.keys(data.rows[0]) : [] return data.rows?.length ? Object.keys(data.rows[0]) : []
}) })
// The table to email, built from whatever the open report returned. Emailing
// was available on two pages and nowhere else, purely because those two were
// built as their own routed pages - the endpoint has always taken any
// {columns, rows}. Lives in its own module so the shapes can be tested.
const emailtable = computed(() => emailTableFrom(reportData.value))
const activeFilterFields = computed(() => const activeFilterFields = computed(() =>
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : [] currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
) )
@@ -391,12 +409,6 @@ async function loadReports() {
} }
} }
function titleCase(text) {
return String(text || '')
.split(/[\s_-]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Filter by name/description/category, then group by category in the fixed order. // Filter by name/description/category, then group by category in the fixed order.
const groupedReports = computed(() => { const groupedReports = computed(() => {

View File

@@ -4,7 +4,7 @@
<h1>Toner Report</h1> <h1>Toner Report</h1>
<div class="header-actions"> <div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button> <button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report" <EmailReportButton v-if="!loading && !error && canemail" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" /> :columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link> <router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div> </div>
@@ -133,9 +133,16 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import EmailReportButton from '@/components/EmailReportButton.vue' import EmailReportButton from '@/components/EmailReportButton.vue'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings' import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
// Emailing a report is the reports.export permission the endpoint enforces.
// Offering the button to somebody who will only get a 403 is worse than not
// offering it.
const authStore = useAuthStore()
const canemail = computed(() => authStore.hasPermission('reports.export'))
const emailColumns = [ const emailColumns = [
{ key: 'printer', label: 'Printer' }, { key: 'printer', label: 'Printer' },
{ key: 'hostname', label: 'Hostname' }, { key: 'hostname', label: 'Hostname' },

View File

@@ -50,7 +50,7 @@
@click="act(drive, 'checkin')"> @click="act(drive, 'checkin')">
<span class="drive-name"> <span class="drive-name">
{{ drive.device_id }} {{ drive.device_id }}
<small v-if="drive.device_desc">{{ drive.device_desc }}</small> <small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
</span> </span>
<span class="drive-action return">Return</span> <span class="drive-action return">Return</span>
</button> </button>
@@ -83,7 +83,7 @@
@click="act(drive, 'checkout')"> @click="act(drive, 'checkout')">
<span class="drive-name"> <span class="drive-name">
{{ drive.device_id }} {{ drive.device_id }}
<small v-if="drive.device_desc">{{ drive.device_desc }}</small> <small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small> <small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
</span> </span>
<span class="drive-action take">Take</span> <span class="drive-action take">Take</span>
@@ -112,7 +112,7 @@
@click="act(drive, 'checkin', true)"> @click="act(drive, 'checkin', true)">
<span class="drive-name"> <span class="drive-name">
{{ drive.device_id }} {{ drive.device_id }}
<small>{{ drive.holdername || 'somebody else' }}</small> <small class="drive-desc">{{ drive.holdername || 'somebody else' }}</small>
</span> </span>
<span class="drive-action return">Return</span> <span class="drive-action return">Return</span>
</button> </button>
@@ -436,8 +436,29 @@ function reset () {
cursor: pointer; cursor: pointer;
} }
.drive-row:disabled { opacity: 0.5; } .drive-row:disabled { opacity: 0.5; }
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; } /* The name has to YIELD. A flex item defaults to min-width:auto, so a long
label kept its intrinsic width and shoved Take or Return out through the side
of the card - a drive nobody could take. `anywhere` breaks the labels that
carry no spaces at all, which a serial-style name often does not. */
.drive-name {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.drive-name small { color: var(--text-light); font-size: 0.85rem; } .drive-name small { color: var(--text-light); font-size: 0.85rem; }
/* Two lines of description is enough to tell two drives apart; more just makes
one card tall and every card in its row tall with it. The locker line below
is never clipped - it is how somebody finds the thing. */
.drive-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.drive-action { .drive-action {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0.5rem 1.1rem; padding: 0.5rem 1.1rem;

View File

@@ -4,7 +4,7 @@
<h1>Warranty Report</h1> <h1>Warranty Report</h1>
<div class="header-actions"> <div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button> <button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading" subject="Warranty Report" <EmailReportButton v-if="!loading && canemail" subject="Warranty Report"
:columns="emailColumns" :rows="emailRows" /> :columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link> <router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div> </div>
@@ -65,8 +65,15 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { warrantyApi } from '@/api' import { warrantyApi } from '@/api'
import EmailReportButton from '@/components/EmailReportButton.vue' import EmailReportButton from '@/components/EmailReportButton.vue'
import { useAuthStore } from '@/stores/auth'
import MachineMapChip from '../components/MachineMapChip.vue' import MachineMapChip from '../components/MachineMapChip.vue'
// Emailing a report is the reports.export permission the endpoint enforces.
// Offering the button to somebody who will only get a 403 is worse than not
// offering it.
const authStore = useAuthStore()
const canemail = computed(() => authStore.hasPermission('reports.export'))
const loading = ref(true) const loading = ref(true)
const counts = ref({}) const counts = ref({})
const buckets = ref({}) const buckets = ref({})