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