Name the toner on the forecast, keep the number on the hover

This commit is contained in:
2026-08-21 11:23:27 -04:00
parent 04107ff8ab
commit 65312c0502
2 changed files with 134 additions and 5 deletions

View File

@@ -42,7 +42,11 @@
<ul v-else class="order-list">
<li v-for="item in mapped" :key="orderKey(item)">
<span class="quantity">{{ item.quantity }}x</span>
<span class="partnumber">{{ item.partnumber }}</span>
<!-- Name here too, number on hover. 'Copy list' still writes the
part number, which is what an order is placed against. -->
<span class="supplyname" :title="item.partnumber">
{{ item.marketingname || item.partnumber }}
</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
@@ -107,7 +111,16 @@
</td>
<td>{{ c.name }}</td>
<td>
<span v-if="firstPart(c)" class="partnumber">{{ firstPart(c) }}</span>
<!-- The cartridge's NAME, with the part number on hover. A
name says which toner this is; 'W2020A' only identifies it
once you already know. The number is what purchasing wants,
so it stays one hover away and stays in the copied order
list verbatim. Falls back to the number when a supply has
no name on file - marketingname is nullable. -->
<span v-if="firstPart(c)" class="supplyname"
:title="firstPart(c).partnumber">
{{ firstPart(c).marketingname || firstPart(c).partnumber }}
</span>
<span v-else class="muted">-</span>
</td>
<td class="collevel">
@@ -259,9 +272,13 @@ function colorLabel(color) {
return color.charAt(0).toUpperCase() + color.slice(1)
}
// The whole part row, not just its number: the table shows the name and keeps
// the number for the hover, and both come from here. Several capacity tiers can
// match; the first is the standard one, which is what orderlist() groups on and
// what the low-supplies report shows first.
function firstPart(cartridge) {
const parts = cartridge.partnumbers || []
return parts.length ? parts[0].partnumber : null
return parts.length ? parts[0] : null
}
function levelText(cartridge) {
@@ -376,8 +393,10 @@ onMounted(load)
text-align: left;
}
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
.partnumber { font-family: monospace; font-weight: 700; }
.partnumber.unmapped { font-weight: 400; color: var(--warning); }
/* A name is prose, not a code, so it does not get the monospace the part number
used to carry here. The help cursor is the only signal that the number is on
the hover. Replaced .partnumber, which nothing renders any more. */
.supplyname { font-weight: 700; cursor: help; }
.order-detail { font-size: 0.9rem; }
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
// The forecast identifies a cartridge by its NAME - '414X Black' - and keeps the
// part number on the hover. A part number alone ('W2020X') only identifies the
// toner to someone who already knows it, and the two places that name a supply
// (the order list and the Part column) had shown nothing else.
//
// The number has to survive somewhere: it is what an order is placed against.
// It stays in the title attribute and, verbatim and unlabelled, in the text that
// 'Copy list' writes to the clipboard. These specs pin both halves.
//
// marketingname is nullable, so every display falls back to the number.
vi.mock('@/api', () => ({
printersApi: { supplyForecast: vi.fn() },
}))
vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
import { printersApi } from '@/api'
import TonerForecast from './TonerForecast.vue'
function payload({ marketingname = '414X Black', partnumber = 'W2020X' } = {}) {
const part = { partnumber, marketingname, capacitytier: 'high', pageyield: 7500 }
return {
data: {
data: {
available: true,
days: 90,
horizondays: 14,
summary: { bands: { empty: 1 }, replacements: 3, toorder: 1 },
cartridges: [{
printerid: 1, printername: 'GageLab-Xerox-C415', assetnumber: 'P-1',
ipaddress: '10.49.215.12', name: 'Black toner', color: 'black',
currentlevel: 4, band: 'empty', daysleft: 0, burnrateperday: 1.8,
rateunstable: false, replacements: 3, basisdays: 12,
partnumbers: [part],
}],
unestimated: [],
orderlist: [{
partnumber, marketingname, color: 'black', supplytype: 'toner',
model: 'Versalink C415', alternates: [], quantity: 2,
printers: [{ printerid: 1, printername: 'GageLab-Xerox-C415', daysleft: 0 }],
}],
},
},
}
}
async function render(overrides) {
printersApi.supplyForecast.mockResolvedValue(payload(overrides))
const wrapper = mount(TonerForecast, {
global: { stubs: { 'router-link': { template: '<a><slot /></a>' } } },
})
await flushPromises()
return wrapper
}
describe('the supply is named, not numbered', () => {
it('shows the name in the order list', async () => {
const wrapper = await render()
const name = wrapper.find('.order-list .supplyname')
expect(name.text()).toBe('414X Black')
})
it('shows the name in the Part column', async () => {
const wrapper = await render()
const cells = wrapper.findAll('tbody .supplyname')
expect(cells.length).toBeGreaterThan(0)
expect(cells[0].text()).toBe('414X Black')
})
it('keeps the part number on the hover, both places', async () => {
const wrapper = await render()
for (const el of wrapper.findAll('.supplyname')) {
expect(el.attributes('title')).toBe('W2020X')
}
})
it('never renders the part number as the visible label', async () => {
const wrapper = await render()
for (const el of wrapper.findAll('.supplyname')) {
expect(el.text()).not.toBe('W2020X')
}
})
})
describe('a supply with no name on file', () => {
it('falls back to the part number rather than showing nothing', async () => {
const wrapper = await render({ marketingname: null })
for (const el of wrapper.findAll('.supplyname')) {
expect(el.text()).toBe('W2020X')
}
})
})
describe('what purchasing receives is unchanged', () => {
it('copies the part number, not the name', async () => {
const written = []
Object.assign(navigator, {
clipboard: { writeText: (text) => { written.push(text); return Promise.resolve() } },
})
const wrapper = await render()
await wrapper.find('.order-head button').trigger('click')
await flushPromises()
expect(written).toHaveLength(1)
expect(written[0]).toContain('W2020X')
expect(written[0]).not.toContain('414X Black')
})
})