Tab titles name the record; Guides reach a site and the contract surface

This commit is contained in:
2026-08-26 13:31:13 -04:00
parent f221fba540
commit 4e38303326
22 changed files with 538 additions and 228 deletions

View File

@@ -90,6 +90,24 @@ ADR-007 and ADR-002.
### Fixed ### 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 - **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 that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
empty rows duplicating `HP` and `Zebra`, which carry every model between empty rows duplicating `HP` and `Zebra`, which carry every model between

View File

@@ -5,6 +5,7 @@
"computers", "computers",
"employees", "employees",
"geenforce", "geenforce",
"guides",
"knowledgebase", "knowledgebase",
"machines", "machines",
"measuringtools", "measuringtools",

View File

@@ -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 "<facility> ShopDB - <page>", 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' // '<facility> 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'
}

View File

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

View File

@@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState' needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast' import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings' import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
import { stagedChildren, stagedToplevel } from './routes.gen' import { stagedChildren, stagedToplevel } from './routes.gen'
// Auto-discover all route modules from routes/ directory. A module's default // 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(' ') word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
} }
router.afterEach(async (to) => { router.afterEach((to) => {
let siteTitle = 'ShopDB' // Claim the route BEFORE awaiting anything: a detail view that resolves
try { // quickly sets the record title, and the generic write below must not
const facility = await getFacilityName() // overwrite it. See composables/pageTitle.js.
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB` beginNavigation(to.fullPath)
} catch (titleError) { /* settings unavailable - plain ShopDB */ } applyGenericTitle(to.fullPath, pageTitleFor(to))
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
}) })
// A deploy replaces the hashed chunk files, so a tab that was open across it // A deploy replaces the hashed chunk files, so a tab that was open across it

View File

@@ -168,6 +168,7 @@ import { applicationsApi } from '@/api'
import { getContactEmailDomain } from '@/utils/siteSettings' import { getContactEmailDomain } from '@/utils/siteSettings'
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml' import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath' import { fileHref } from '@/utils/basePath'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
@@ -202,6 +203,7 @@ onMounted(async () => {
// Load application details // Load application details
const response = await applicationsApi.get(route.params.id) const response = await applicationsApi.get(route.params.id)
app.value = response.data.data app.value = response.data.data
setRecordTitle(app.value?.appname)
// Load versions // Load versions
try { try {

View File

@@ -6,7 +6,7 @@ export default [
path: 'reports/pc-relationships', path: 'reports/pc-relationships',
name: 'report-pc-relationships', name: 'report-pc-relationships',
component: () => import('./views/PCRelationshipsReport.vue'), component: () => import('./views/PCRelationshipsReport.vue'),
meta: { plugin: 'computers' } meta: { plugin: 'computers', title: 'PC Relationships Report' }
}, },
{ {
path: 'settings/pctypemapping', path: 'settings/pctypemapping',

View File

@@ -263,6 +263,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -280,6 +281,7 @@ onMounted(async () => {
try { try {
const response = await computersApi.get(route.params.id) const response = await computersApi.get(route.params.id)
computer.value = response.data.data computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications // Load installed applications
try { try {

View File

@@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api' import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins' import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -223,6 +224,7 @@ async function loadEmployee() {
try { try {
const response = await employeesApi.lookup(route.params.sso) const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data employee.value = response.data.data
setRecordTitle(employee.value?.name || employee.value?.sso)
} catch (err) { } catch (err) {
console.error('Error loading employee:', err) console.error('Error loading employee:', err)
error.value = 'Employee not found' error.value = 'Employee not found'

View File

@@ -59,6 +59,7 @@ import { useRoute } from 'vue-router'
import api from '@/api' import api from '@/api'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import GuideBlocks from '../components/GuideBlocks.vue' import GuideBlocks from '../components/GuideBlocks.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const guide = ref(null) const guide = ref(null)
@@ -146,6 +147,7 @@ async function load () {
try { try {
const response = await api.get('/guides/' + route.params.slug) const response = await api.get('/guides/' + route.params.slug)
guide.value = response.data?.data || response.data guide.value = response.data?.data || response.data
setRecordTitle(guide.value?.title)
loadProgress() loadProgress()
} catch (err) { } catch (err) {
error.value = apiError(err, 'Could not load this guide') error.value = apiError(err, 'Could not load this guide')

View File

@@ -33,8 +33,7 @@ without an author thinking about it.
import json import json
from datetime import datetime from datetime import datetime
from shopdb.api import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
def _utcnow(): def _utcnow():

View File

@@ -1,212 +1,214 @@
<template> <template>
<div class="detail-page"> <div class="detail-page">
<div class="page-header"> <div class="page-header">
<h2>Knowledge Base Article</h2> <h2>Knowledge Base Article</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link> <router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link> <router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div> </div>
</div> </div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article"> <template v-else-if="article">
<div class="card article-card"> <div class="card article-card">
<table class="info-table"> <table class="info-table">
<tbody> <tbody>
<tr> <tr>
<th>Description:</th> <th>Description:</th>
<td>{{ article.shortdescription }}</td> <td>{{ article.shortdescription }}</td>
</tr> </tr>
<tr v-if="article.application"> <tr v-if="article.application">
<th>Topic:</th> <th>Topic:</th>
<td> <td>
<router-link :to="`/applications/${article.application.appid}`"> <router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }} {{ article.application.appname }}
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="article.linkurl"> <tr v-if="article.linkurl">
<th>URL:</th> <th>URL:</th>
<td> <td>
<a href="#" @click.prevent="openArticle"> <a href="#" @click.prevent="openArticle">
{{ article.linkurl }} {{ article.linkurl }}
</a> </a>
</td> </td>
</tr> </tr>
<tr v-if="article.keywords"> <tr v-if="article.keywords">
<th>Keywords:</th> <th>Keywords:</th>
<td>{{ article.keywords }}</td> <td>{{ article.keywords }}</td>
</tr> </tr>
<tr> <tr>
<th>Clicks:</th> <th>Clicks:</th>
<td>{{ article.clicks }}</td> <td>{{ article.clicks }}</td>
</tr> </tr>
<tr> <tr>
<th>Last Updated:</th> <th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td> <td>{{ formatDate(article.lastupdated) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<hr /> <hr />
<div class="actions-center"> <div class="actions-center">
<a <a
v-if="article.linkurl" v-if="article.linkurl"
href="#" href="#"
class="btn btn-primary btn-lg" class="btn btn-primary btn-lg"
@click.prevent="openArticle" @click.prevent="openArticle"
> >
<span class="btn-icon">&#x2197;</span> Open Article <span class="btn-icon">&#x2197;</span> Open Article
</a> </a>
<div v-else class="alert alert-warning"> <div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one. This article does not have a URL link defined. Please edit the article to add one.
</div> </div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg"> <router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit <span class="btn-icon">&#x270E;</span> Edit
</router-link> </router-link>
</div> </div>
</div> </div>
</template> </template>
<div v-else class="card"> <div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p> <p style="text-align: center; color: var(--text-light);">Article not found</p>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api' import { knowledgebaseApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const route = useRoute()
const loading = ref(true)
const article = ref(null) const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try { onMounted(async () => {
const response = await knowledgebaseApi.get(route.params.id) try {
article.value = response.data.data const response = await knowledgebaseApi.get(route.params.id)
} catch (error) { article.value = response.data.data
console.error('Error loading article:', error) setRecordTitle(article.value?.shortdescription)
} finally { } catch (error) {
loading.value = false console.error('Error loading article:', error)
} } finally {
}) loading.value = false
}
function formatDate(dateStr) { })
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString() function formatDate(dateStr) {
} if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
async function openArticle() { }
try {
await knowledgebaseApi.trackClick(article.value.linkid) async function openArticle() {
article.value.clicks = (article.value.clicks || 0) + 1 try {
// Open the URL after tracking await knowledgebaseApi.trackClick(article.value.linkid)
if (article.value.linkurl) { article.value.clicks = (article.value.clicks || 0) + 1
window.open(article.value.linkurl, '_blank') // Open the URL after tracking
} if (article.value.linkurl) {
} catch (error) { window.open(article.value.linkurl, '_blank')
console.error('Error tracking click:', error) }
// Still open even if tracking fails } catch (error) {
if (article.value.linkurl) { console.error('Error tracking click:', error)
window.open(article.value.linkurl, '_blank') // Still open even if tracking fails
} if (article.value.linkurl) {
} window.open(article.value.linkurl, '_blank')
} }
</script> }
}
<style scoped> </script>
.detail-page {
max-width: 800px; <style scoped>
margin: 0 auto; .detail-page {
} max-width: 800px;
margin: 0 auto;
.header-actions { }
display: flex;
gap: 0.5rem; .header-actions {
} display: flex;
gap: 0.5rem;
.article-card { }
padding: 1.5rem;
} .article-card {
padding: 1.5rem;
.info-table { }
width: 100%;
border-collapse: collapse; .info-table {
} width: 100%;
border-collapse: collapse;
.info-table th, }
.info-table td {
padding: 0.75rem; .info-table th,
text-align: left; .info-table td {
border: none; padding: 0.75rem;
} text-align: left;
border: none;
.info-table th { }
width: 150px;
font-weight: 600; .info-table th {
color: var(--text-light, #666); width: 150px;
vertical-align: top; font-weight: 600;
} color: var(--text-light, #666);
vertical-align: top;
.info-table td a { }
color: var(--primary, #1976d2);
text-decoration: none; .info-table td a {
word-break: break-all; color: var(--primary, #1976d2);
overflow-wrap: break-word; text-decoration: none;
} word-break: break-all;
overflow-wrap: break-word;
.info-table td a:hover { }
text-decoration: underline;
} .info-table td a:hover {
text-decoration: underline;
.info-table td { }
word-break: break-word;
overflow-wrap: break-word; .info-table td {
max-width: 500px; word-break: break-word;
} overflow-wrap: break-word;
max-width: 500px;
hr { }
margin: 1.5rem 0;
border: none; hr {
border-top: 1px solid var(--border-color, #e5e5e5); margin: 1.5rem 0;
} border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
.actions-center { }
display: flex;
justify-content: center; .actions-center {
gap: 1rem; display: flex;
margin-top: 1rem; justify-content: center;
} gap: 1rem;
margin-top: 1rem;
.btn-lg { }
padding: 0.75rem 1.5rem;
font-size: 1rem; .btn-lg {
} padding: 0.75rem 1.5rem;
font-size: 1rem;
.btn-icon { }
margin-right: 0.5rem;
} .btn-icon {
margin-right: 0.5rem;
.alert { }
padding: 1rem;
border-radius: 4px; .alert {
} padding: 1rem;
border-radius: 4px;
.alert-warning { }
background: #fff3cd;
color: #856404; .alert-warning {
} background: #fff3cd;
color: #856404;
@media (prefers-color-scheme: dark) { }
.alert-warning {
background: #3d3200; @media (prefers-color-scheme: dark) {
color: #ffc107; .alert-warning {
} background: #3d3200;
} color: #ffc107;
</style> }
}
</style>

View File

@@ -257,6 +257,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -271,6 +272,7 @@ onMounted(async () => {
try { try {
const response = await machinesApi.get(route.params.id) const response = await machinesApi.get(route.params.id)
machine.value = response.data.data machine.value = response.data.data
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
} catch (error) { } catch (error) {
console.error('Error loading machine:', error) console.error('Error loading machine:', error)
} finally { } finally {

View File

@@ -43,7 +43,7 @@ export default [
path: 'reports/calibration', path: 'reports/calibration',
name: 'calibration-report', name: 'calibration-report',
component: () => import('./views/CalibrationReport.vue'), component: () => import('./views/CalibrationReport.vue'),
meta: { plugin: 'measuringtools' } meta: { plugin: 'measuringtools', title: 'Calibration Report' }
}, },
{ {
path: 'settings/measuringtooltypes', path: 'settings/measuringtooltypes',

View File

@@ -172,6 +172,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
@@ -193,6 +194,7 @@ onMounted(async () => {
? await measuringtoolsApi.getByAsset(route.params.assetid) ? await measuringtoolsApi.getByAsset(route.params.assetid)
: await measuringtoolsApi.get(route.params.id) : await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data tool.value = response.data.data
setRecordTitle(tool.value?.assetnumber || tool.value?.name)
} catch (err) { } catch (err) {
console.error('Error loading measuring tool:', err) console.error('Error loading measuring tool:', err)
} finally { } finally {

View File

@@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -250,6 +251,7 @@ async function loadDevice() {
try { try {
const response = await networkApi.get(deviceId) const response = await networkApi.get(deviceId)
device.value = response.data.data device.value = response.data.data
setRecordTitle(device.value?.assetnumber || device.value?.name)
} catch (error) { } catch (error) {
console.error('Error loading device:', error) console.error('Error loading device:', error)
device.value = null device.value = null

View File

@@ -59,6 +59,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { networkApi } from '@/api' import { networkApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const subnet = ref(null) const subnet = ref(null)
@@ -79,6 +80,7 @@ async function load() {
try { try {
const response = await networkApi.subnets.get(route.params.id) const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data subnet.value = response.data?.data || response.data
setRecordTitle(subnet.value?.name || subnet.value?.cidr)
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -187,6 +187,7 @@ import { useRoute } from 'vue-router'
import { printedpartsApi } from '@/api' import { printedpartsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const item = ref(null) const item = ref(null)
@@ -196,6 +197,7 @@ onMounted(async () => {
try { try {
const response = await printedpartsApi.get(route.params.id) const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data item.value = response.data.data
setRecordTitle(item.value?.itemname || item.value?.itemcode)
await loadFiles() await loadFiles()
} catch (loadError) { } catch (loadError) {
console.error('Error loading printed item:', loadError) console.error('Error loading printed item:', loadError)

View File

@@ -6,13 +6,13 @@ export default [
path: 'reports/toner', path: 'reports/toner',
name: 'toner-report', name: 'toner-report',
component: () => import('./views/TonerReport.vue'), component: () => import('./views/TonerReport.vue'),
meta: { plugin: 'printers' } meta: { plugin: 'printers', title: 'Toner Report' }
}, },
{ {
path: 'reports/toner-forecast', path: 'reports/toner-forecast',
name: 'toner-forecast', name: 'toner-forecast',
component: () => import('./views/TonerForecast.vue'), component: () => import('./views/TonerForecast.vue'),
meta: { plugin: 'printers' } meta: { plugin: 'printers', title: 'Toner Forecast' }
}, },
{ {
path: 'settings/printertypes', path: 'settings/printertypes',

View File

@@ -305,6 +305,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -344,6 +345,9 @@ onMounted(async () => {
]) ])
printer.value = printerRes.data.data 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 endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || [] supplies.value = suppliesRes.data.data?.supplies || []
// drivers are attached to the printer detail, matched by model // drivers are attached to the printer detail, matched by model

View File

@@ -160,6 +160,7 @@ import { usbApi } from '@/api'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -181,6 +182,7 @@ async function loadDevice() {
try { try {
const response = await usbApi.get(route.params.id) const response = await usbApi.get(route.params.id)
device.value = response.data.data device.value = response.data.data
setRecordTitle(device.value?.label || device.value?.serialnumber)
} catch (error) { } catch (error) {
console.error('Error loading USB device:', error) console.error('Error loading USB device:', error)
} finally { } finally {

View File

@@ -18,7 +18,7 @@ export default [
path: 'reports/warranty', path: 'reports/warranty',
name: 'warranty-report', name: 'warranty-report',
component: () => import('./views/WarrantyReport.vue'), component: () => import('./views/WarrantyReport.vue'),
meta: { plugin: 'warranty' } meta: { plugin: 'warranty', title: 'Warranty Report' }
} }
] ]