From ed5d64fa3e872f833507a2438fbdae81d8440496 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 26 Aug 2026 13:13:30 -0400 Subject: [PATCH] Name the record in the browser tab, not just the section Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines 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. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the " ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced. --- 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 + .../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 +- 19 files changed, 518 insertions(+), 226 deletions(-) create mode 100644 frontend/src/composables/pageTitle.js create mode 100644 frontend/src/composables/pageTitle.spec.js 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/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' } } ]