From 4e38303326fe0b004ed6b434f886837acbf4da94 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Wed, 26 Aug 2026 13:31:13 -0400 Subject: [PATCH] Tab titles name the record; Guides reach a site and the contract surface --- CHANGELOG.md | 18 + deploy/site-profile-universal.json | 1 + frontend/src/composables/pageTitle.js | 91 ++++ frontend/src/composables/pageTitle.spec.js | 177 ++++++++ frontend/src/router/index.js | 16 +- .../frontend/views/ApplicationDetail.vue | 2 + plugins/computers/frontend/routes.js | 2 +- plugins/computers/frontend/views/PCDetail.vue | 2 + .../frontend/views/EmployeeDetail.vue | 2 + plugins/guides/frontend/views/GuideView.vue | 2 + plugins/guides/models/guide.py | 3 +- .../frontend/views/KnowledgeBaseDetail.vue | 426 +++++++++--------- .../machines/frontend/views/MachineDetail.vue | 2 + plugins/measuringtools/frontend/routes.js | 2 +- .../frontend/views/MeasuringToolDetail.vue | 2 + .../frontend/views/NetworkDeviceDetail.vue | 2 + .../network/frontend/views/SubnetDetail.vue | 2 + .../frontend/views/PrintedItemDetail.vue | 2 + plugins/printers/frontend/routes.js | 4 +- .../printers/frontend/views/PrinterDetail.vue | 4 + plugins/usb/frontend/views/USBDetail.vue | 2 + plugins/warranty/frontend/routes.js | 2 +- 22 files changed, 538 insertions(+), 228 deletions(-) create mode 100644 frontend/src/composables/pageTitle.js create mode 100644 frontend/src/composables/pageTitle.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d46109c..8cc81df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,24 @@ ADR-007 and ADR-002. ### Fixed +- **Every detail page had the same browser tab title.** A machine, a PC and a + printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two + tabs on two different records were indistinguishable, a bookmark said nothing + about what it pointed at, and a link shared into Teams previewed as the + section rather than the thing. Detail pages now name the record: machines, + PCs, printers, network devices, subnets, measuring tools, USB devices, + printed parts, applications, employees, knowledge base articles and guides. + A record with no usable name, or one that failed to load, keeps the section + name, and a long name is cut at a word boundary. Report pages gained proper + titles too, which also fixes "Pc Relationships". + +- **Guides would have been dropped at provisioning.** The plugin was missing + from `deploy/site-profile-universal.json`, the profile the released Windows + installer is built from, so the wizard would not have offered it and + `flask plugin prune-schema` drops the tables of any plugin absent from the + profile (ADR-014). Its model also imported core directly rather than through + `shopdb.api`, which the contract test catches. + - **The shipped catalog offered two ways to spell the same vendor, and one row that should never have left a test.** `HP Inc.` and `Zebra Technologies` were empty rows duplicating `HP` and `Zebra`, which carry every model between diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 64b7539e..7308f77d 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -5,6 +5,7 @@ "computers", "employees", "geenforce", + "guides", "knowledgebase", "machines", "measuringtools", diff --git a/frontend/src/composables/pageTitle.js b/frontend/src/composables/pageTitle.js new file mode 100644 index 00000000..b466eb6f --- /dev/null +++ b/frontend/src/composables/pageTitle.js @@ -0,0 +1,91 @@ +// The browser tab title, including WHICH record is open. +// +// The router can only name the KIND of page, because it runs before the view +// has fetched anything: every application read "West Jefferson ShopDB - +// Application", so two tabs on two different machines were indistinguishable, +// and a shared link previewed as the section rather than the thing. +// +// So a detail view names itself once its record arrives: +// +// setRecordTitle(machine.assetnumber) +// +// THE ORDERING IS THE WHOLE PROBLEM. The router's afterEach awaits the site +// settings before it can build " ShopDB - ", so on a cold load +// it finishes AFTER a view that fetched quickly has already set a good title, +// and would overwrite it with the generic one. Both writers therefore go +// through here, and the generic write is dropped when a record title already +// belongs to the route being landed on. The key is the route's fullPath, so +// navigating away invalidates the previous record's claim. + +import { getFacilityName } from '@/utils/siteSettings' + +let activeKey = null // fullPath of the route the tab is currently showing +let recordTitle = null // record name set by a view for activeKey, if any +let sitePrefix = 'ShopDB' // ' ShopDB', resolved once and cached + +function compose(page) { + return page && page !== 'Home' ? `${sitePrefix} - ${page}` : sitePrefix +} + +async function resolveSitePrefix() { + try { + const facility = await getFacilityName() + if (facility && facility !== 'ShopDB') sitePrefix = `${facility} ShopDB` + } catch (settingsError) { + // settings unavailable: plain ShopDB is a fine tab title + } + return sitePrefix +} + +// Called by the router on every navigation, BEFORE it awaits anything, so a +// view that resolves first is not treated as belonging to the previous route. +export function beginNavigation(key) { + activeKey = key + recordTitle = null +} + +// Called by the router once it knows the site prefix. Skipped when a view has +// already named the record for this route. +export async function applyGenericTitle(key, page) { + await resolveSitePrefix() + if (key !== activeKey) return // navigated on while we awaited + // A view that named the record before the facility name arrived composed its + // title against the placeholder prefix, so rewrite it rather than leaving + // 'ShopDB - CSF16' on a site called West Jefferson. + document.title = compose(recordTitle !== null ? recordTitle : page) +} + +// A tab shows very little, and the browser truncates mid-word wherever it runs +// out. Cut at a word boundary instead, so what survives is readable. Knowledge +// base articles are the case that forces this: their name is a whole sentence +// of description, not a short label. +const MAXLENGTH = 60 + +function shorten(text) { + if (text.length <= MAXLENGTH) return text + const cut = text.slice(0, MAXLENGTH) + const space = cut.lastIndexOf(' ') + return `${(space > MAXLENGTH / 2 ? cut.slice(0, space) : cut).trimEnd()}...` +} + +// Called by a detail view once its record has loaded. An empty or missing name +// leaves the generic title in place rather than showing a bare id: "Machine" +// beats "Machine 4192" for somebody scanning a row of tabs, and a record that +// failed to load has no name to show anyway. +export function setRecordTitle(name) { + const text = (name === null || name === undefined) ? '' : String(name).trim() + if (!text) return + recordTitle = shorten(text) + document.title = compose(recordTitle) +} + +// Exposed for tests. +export function _state() { + return { activeKey, recordTitle, sitePrefix } +} + +export function _reset() { + activeKey = null + recordTitle = null + sitePrefix = 'ShopDB' +} diff --git a/frontend/src/composables/pageTitle.spec.js b/frontend/src/composables/pageTitle.spec.js new file mode 100644 index 00000000..ae7aab07 --- /dev/null +++ b/frontend/src/composables/pageTitle.spec.js @@ -0,0 +1,177 @@ +// The tab title names the record, and the two writers do not fight. +// +// The router can only name the KIND of page - it runs before the view has +// fetched anything - so a detail view names the record itself once it loads. +// That leaves two writers for one string, and the router's is asynchronous +// (it awaits the site settings), so on a cold load it can finish LAST and +// overwrite a good title with the generic one. These pin that ordering. + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The real getFacilityName caches, so only the first call is slow and every +// later one is instant. Mirror that: pending calls are held until the test +// releases them, and the value is served synchronously from then on. A mock +// that hands out a fresh unresolved promise per call hangs the moment a test +// navigates twice. +let pending = [] +let settled = null + +function resolveFacility(value) { + settled = value + const waiting = pending + pending = [] + waiting.forEach((resolve) => resolve(value)) +} + +vi.mock('@/utils/siteSettings', () => ({ + getFacilityName: () => (settled !== null + ? Promise.resolve(settled) + : new Promise((resolve) => { pending.push(resolve) })), +})) + +import { + beginNavigation, applyGenericTitle, setRecordTitle, _reset, +} from './pageTitle' + +beforeEach(() => { + _reset() + document.title = '' + pending = [] + settled = null +}) + +describe('the generic title', () => { + it('names the section when no record has been set', async () => { + beginNavigation('/machines') + const done = applyGenericTitle('/machines', 'Machines') + resolveFacility('West Jefferson') + await done + + expect(document.title).toBe('West Jefferson ShopDB - Machines') + }) + + it('falls back to plain ShopDB when the facility is unset', async () => { + beginNavigation('/machines') + const done = applyGenericTitle('/machines', 'Machines') + resolveFacility('ShopDB') + await done + + expect(document.title).toBe('ShopDB - Machines') + }) + + it('drops the site name entirely on the dashboard', async () => { + beginNavigation('/') + const done = applyGenericTitle('/', 'Home') + resolveFacility('West Jefferson') + await done + + expect(document.title).toBe('West Jefferson ShopDB') + }) +}) + +describe('a record title', () => { + it('names the record once the view has loaded it', async () => { + beginNavigation('/machines/12') + const done = applyGenericTitle('/machines/12', 'Machines') + resolveFacility('West Jefferson') + await done + + setRecordTitle('CSF16') + + expect(document.title).toBe('West Jefferson ShopDB - CSF16') + }) + + it('SURVIVES a slow router that resolves afterwards', async () => { + // The regression this file exists for. The view wins the race, the + // settings fetch lands second, and the generic write must stand down. + beginNavigation('/machines/12') + const done = applyGenericTitle('/machines/12', 'Machines') + + setRecordTitle('CSF16') // view got there first + resolveFacility('West Jefferson') + await done // router finishes last + + expect(document.title).toBe('West Jefferson ShopDB - CSF16') + }) + + it('is ignored when the record has no usable name', async () => { + beginNavigation('/machines/12') + const done = applyGenericTitle('/machines/12', 'Machines') + resolveFacility('West Jefferson') + await done + + setRecordTitle('') + setRecordTitle(null) + setRecordTitle(undefined) + setRecordTitle(' ') + + // the section name is a better tab than a blank or a bare id + expect(document.title).toBe('West Jefferson ShopDB - Machines') + }) + + it('shortens a name too long for a tab, at a word boundary', async () => { + // Knowledge base articles are named with a sentence of description, and a + // browser truncates those mid-word wherever it happens to run out. + beginNavigation('/knowledgebase/2') + const done = applyGenericTitle('/knowledgebase/2', 'Knowledge Base') + resolveFacility('West Jefferson') + await done + + const full = 'Documentation on how to image a Standard or Business PC ' + + 'in GCC High using MediaCreator Lite' + setRecordTitle(full) + + expect(document.title.length).toBeLessThan(90) + expect(document.title.startsWith('West Jefferson ShopDB - ')).toBe(true) + expect(document.title.endsWith('...')).toBe(true) + + // what survives is a whole-word prefix of the original: the character + // following the cut in the source is a space, so no word was split + const kept = document.title + .replace('West Jefferson ShopDB - ', '') + .replace(/\.\.\.$/, '') + expect(full.startsWith(kept)).toBe(true) + expect(full[kept.length]).toBe(' ') + }) + + it('leaves a name that already fits alone', async () => { + beginNavigation('/machines/14') + const done = applyGenericTitle('/machines/14', 'Machines') + resolveFacility('West Jefferson') + await done + + setRecordTitle('0600') + + expect(document.title).toBe('West Jefferson ShopDB - 0600') + }) + + it('does not leak onto the next page', async () => { + beginNavigation('/machines/12') + const first = applyGenericTitle('/machines/12', 'Machines') + resolveFacility('West Jefferson') + await first + setRecordTitle('CSF16') + + beginNavigation('/printers') + const second = applyGenericTitle('/printers', 'Printers') + await second + + expect(document.title).toBe('West Jefferson ShopDB - Printers') + }) + + it('is discarded when the user navigates away mid-flight', async () => { + // Two navigations overlap; the first one's late generic write must not + // land on the second page. + beginNavigation('/machines/12') + const stale = applyGenericTitle('/machines/12', 'Machines') + + beginNavigation('/printers/3') + const fresh = applyGenericTitle('/printers/3', 'Printers') + setRecordTitle('WJPRT04') + + resolveFacility('West Jefferson') + await Promise.all([stale, fresh]) + + expect(document.title).toBe('West Jefferson ShopDB - WJPRT04') + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index f01a802c..37562394 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState, needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState' import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' import { useToast } from '../composables/toast' -import { getFacilityName } from '../utils/siteSettings' +import { beginNavigation, applyGenericTitle } from '../composables/pageTitle' import { stagedChildren, stagedToplevel } from './routes.gen' // Auto-discover all route modules from routes/ directory. A module's default @@ -147,14 +147,12 @@ function pageTitleFor(route) { word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } -router.afterEach(async (to) => { - let siteTitle = 'ShopDB' - try { - const facility = await getFacilityName() - if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB` - } catch (titleError) { /* settings unavailable - plain ShopDB */ } - const page = pageTitleFor(to) - document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle +router.afterEach((to) => { + // Claim the route BEFORE awaiting anything: a detail view that resolves + // quickly sets the record title, and the generic write below must not + // overwrite it. See composables/pageTitle.js. + beginNavigation(to.fullPath) + applyGenericTitle(to.fullPath, pageTitleFor(to)) }) // A deploy replaces the hashed chunk files, so a tab that was open across it diff --git a/plugins/applications/frontend/views/ApplicationDetail.vue b/plugins/applications/frontend/views/ApplicationDetail.vue index 88fb5734..2f31b286 100644 --- a/plugins/applications/frontend/views/ApplicationDetail.vue +++ b/plugins/applications/frontend/views/ApplicationDetail.vue @@ -168,6 +168,7 @@ import { applicationsApi } from '@/api' import { getContactEmailDomain } from '@/utils/siteSettings' import { sanitizeNotesHtml } from '@/utils/sanitizeHtml' import { fileHref } from '@/utils/basePath' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() @@ -202,6 +203,7 @@ onMounted(async () => { // Load application details const response = await applicationsApi.get(route.params.id) app.value = response.data.data + setRecordTitle(app.value?.appname) // Load versions try { diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index cd724a09..828d9122 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -6,7 +6,7 @@ export default [ path: 'reports/pc-relationships', name: 'report-pc-relationships', component: () => import('./views/PCRelationshipsReport.vue'), - meta: { plugin: 'computers' } + meta: { plugin: 'computers', title: 'PC Relationships Report' } }, { path: 'settings/pctypemapping', diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 633aa2e4..09f7a0ba 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -263,6 +263,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const { isEnabled } = useIdentifierFlags() @@ -280,6 +281,7 @@ onMounted(async () => { try { const response = await computersApi.get(route.params.id) computer.value = response.data.data + setRecordTitle(computer.value?.assetnumber || computer.value?.name) // Load installed applications try { diff --git a/plugins/employees/frontend/views/EmployeeDetail.vue b/plugins/employees/frontend/views/EmployeeDetail.vue index 56dc9674..c9ab54fc 100644 --- a/plugins/employees/frontend/views/EmployeeDetail.vue +++ b/plugins/employees/frontend/views/EmployeeDetail.vue @@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath' import { employeesApi, usbApi, notificationsApi } from '@/api' import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins' import { useToast } from '@/composables/toast' +import { setRecordTitle } from '@/composables/pageTitle' const toast = useToast() const route = useRoute() @@ -223,6 +224,7 @@ async function loadEmployee() { try { const response = await employeesApi.lookup(route.params.sso) employee.value = response.data.data + setRecordTitle(employee.value?.name || employee.value?.sso) } catch (err) { console.error('Error loading employee:', err) error.value = 'Employee not found' diff --git a/plugins/guides/frontend/views/GuideView.vue b/plugins/guides/frontend/views/GuideView.vue index e0ff3686..8b32133a 100644 --- a/plugins/guides/frontend/views/GuideView.vue +++ b/plugins/guides/frontend/views/GuideView.vue @@ -59,6 +59,7 @@ import { useRoute } from 'vue-router' import api from '@/api' import { apiError } from '@/utils/apiError' import GuideBlocks from '../components/GuideBlocks.vue' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const guide = ref(null) @@ -146,6 +147,7 @@ async function load () { try { const response = await api.get('/guides/' + route.params.slug) guide.value = response.data?.data || response.data + setRecordTitle(guide.value?.title) loadProgress() } catch (err) { error.value = apiError(err, 'Could not load this guide') diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py index a3587798..809445b8 100644 --- a/plugins/guides/models/guide.py +++ b/plugins/guides/models/guide.py @@ -33,8 +33,7 @@ without an author thinking about it. import json from datetime import datetime -from shopdb.api import db -from shopdb.core.models.base import BaseModel +from shopdb.api import db, BaseModel def _utcnow(): diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseDetail.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseDetail.vue index 2d2d8b85..25364a19 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseDetail.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseDetail.vue @@ -1,212 +1,214 @@ - - - - - + + + + + diff --git a/plugins/machines/frontend/views/MachineDetail.vue b/plugins/machines/frontend/views/MachineDetail.vue index 4e7810c0..464484c7 100644 --- a/plugins/machines/frontend/views/MachineDetail.vue +++ b/plugins/machines/frontend/views/MachineDetail.vue @@ -257,6 +257,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const { isEnabled } = useIdentifierFlags() @@ -271,6 +272,7 @@ onMounted(async () => { try { const response = await machinesApi.get(route.params.id) machine.value = response.data.data + setRecordTitle(machine.value?.assetnumber || machine.value?.name) } catch (error) { console.error('Error loading machine:', error) } finally { diff --git a/plugins/measuringtools/frontend/routes.js b/plugins/measuringtools/frontend/routes.js index 95d6094e..7e0fc3d5 100644 --- a/plugins/measuringtools/frontend/routes.js +++ b/plugins/measuringtools/frontend/routes.js @@ -43,7 +43,7 @@ export default [ path: 'reports/calibration', name: 'calibration-report', component: () => import('./views/CalibrationReport.vue'), - meta: { plugin: 'measuringtools' } + meta: { plugin: 'measuringtools', title: 'Calibration Report' } }, { path: 'settings/measuringtooltypes', diff --git a/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue b/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue index 5a9da8ea..d64a8b55 100644 --- a/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue +++ b/plugins/measuringtools/frontend/views/MeasuringToolDetail.vue @@ -172,6 +172,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() @@ -193,6 +194,7 @@ onMounted(async () => { ? await measuringtoolsApi.getByAsset(route.params.assetid) : await measuringtoolsApi.get(route.params.id) tool.value = response.data.data + setRecordTitle(tool.value?.assetnumber || tool.value?.name) } catch (err) { console.error('Error loading measuring tool:', err) } finally { diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index 6921b403..f947549d 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useIdentifierFlags } from '@/composables/identifierSettings' import { useToast } from '@/composables/toast' +import { setRecordTitle } from '@/composables/pageTitle' const toast = useToast() const { isEnabled } = useIdentifierFlags() @@ -250,6 +251,7 @@ async function loadDevice() { try { const response = await networkApi.get(deviceId) device.value = response.data.data + setRecordTitle(device.value?.assetnumber || device.value?.name) } catch (error) { console.error('Error loading device:', error) device.value = null diff --git a/plugins/network/frontend/views/SubnetDetail.vue b/plugins/network/frontend/views/SubnetDetail.vue index 6975044c..ca121ad0 100644 --- a/plugins/network/frontend/views/SubnetDetail.vue +++ b/plugins/network/frontend/views/SubnetDetail.vue @@ -59,6 +59,7 @@ import { ref, computed, onMounted } from 'vue' import { useRoute } from 'vue-router' import { networkApi } from '@/api' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const subnet = ref(null) @@ -79,6 +80,7 @@ async function load() { try { const response = await networkApi.subnets.get(route.params.id) subnet.value = response.data?.data || response.data + setRecordTitle(subnet.value?.name || subnet.value?.cidr) } finally { loading.value = false } diff --git a/plugins/printedparts/frontend/views/PrintedItemDetail.vue b/plugins/printedparts/frontend/views/PrintedItemDetail.vue index e60d7345..e7fcbdc2 100644 --- a/plugins/printedparts/frontend/views/PrintedItemDetail.vue +++ b/plugins/printedparts/frontend/views/PrintedItemDetail.vue @@ -187,6 +187,7 @@ import { useRoute } from 'vue-router' import { printedpartsApi } from '@/api' import { withBase } from '@/utils/basePath' import Modal from '@/components/Modal.vue' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const item = ref(null) @@ -196,6 +197,7 @@ onMounted(async () => { try { const response = await printedpartsApi.get(route.params.id) item.value = response.data.data + setRecordTitle(item.value?.itemname || item.value?.itemcode) await loadFiles() } catch (loadError) { console.error('Error loading printed item:', loadError) diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index 83b4f23d..f5428243 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -6,13 +6,13 @@ export default [ path: 'reports/toner', name: 'toner-report', component: () => import('./views/TonerReport.vue'), - meta: { plugin: 'printers' } + meta: { plugin: 'printers', title: 'Toner Report' } }, { path: 'reports/toner-forecast', name: 'toner-forecast', component: () => import('./views/TonerForecast.vue'), - meta: { plugin: 'printers' } + meta: { plugin: 'printers', title: 'Toner Forecast' } }, { path: 'settings/printertypes', diff --git a/plugins/printers/frontend/views/PrinterDetail.vue b/plugins/printers/frontend/views/PrinterDetail.vue index 88e05507..751bcaf6 100644 --- a/plugins/printers/frontend/views/PrinterDetail.vue +++ b/plugins/printers/frontend/views/PrinterDetail.vue @@ -305,6 +305,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { setRecordTitle } from '@/composables/pageTitle' const route = useRoute() const { isEnabled } = useIdentifierFlags() @@ -344,6 +345,9 @@ onMounted(async () => { ]) printer.value = printerRes.data.data + // the page's own heading logic, which already discards the literal + // name 'NONE' that a discovered queue can carry + setRecordTitle(displayTitle.value) // supplies endpoint returns {ipaddress, pingstatus, supplies:[...]} supplies.value = suppliesRes.data.data?.supplies || [] // drivers are attached to the printer detail, matched by model diff --git a/plugins/usb/frontend/views/USBDetail.vue b/plugins/usb/frontend/views/USBDetail.vue index 0a687cd3..cf504f64 100644 --- a/plugins/usb/frontend/views/USBDetail.vue +++ b/plugins/usb/frontend/views/USBDetail.vue @@ -160,6 +160,7 @@ import { usbApi } from '@/api' import Modal from '@/components/Modal.vue' import { useToast } from '@/composables/toast' import { apiError } from '@/utils/apiError' +import { setRecordTitle } from '@/composables/pageTitle' const toast = useToast() const route = useRoute() @@ -181,6 +182,7 @@ async function loadDevice() { try { const response = await usbApi.get(route.params.id) device.value = response.data.data + setRecordTitle(device.value?.label || device.value?.serialnumber) } catch (error) { console.error('Error loading USB device:', error) } finally { diff --git a/plugins/warranty/frontend/routes.js b/plugins/warranty/frontend/routes.js index a535b415..54661f4c 100644 --- a/plugins/warranty/frontend/routes.js +++ b/plugins/warranty/frontend/routes.js @@ -18,7 +18,7 @@ export default [ path: 'reports/warranty', name: 'warranty-report', component: () => import('./views/WarrantyReport.vue'), - meta: { plugin: 'warranty' } + meta: { plugin: 'warranty', title: 'Warranty Report' } } ]