Tab titles name the record; Guides reach a site and the contract surface
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user