diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 74fa175a3d..fe7dbfb34d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -83,6 +83,7 @@ import { useContextStore } from '@/stores/context.js' import { useProductBrokersStore } from '@/stores/product-brokers.js' import { useUxDrawersStore } from '@/stores/ux-drawers.js' import { useUxLoadingStore } from '@/stores/ux-loading.js' +import { computePageTitle, isEditorRoute } from '@/utils/page-title' export default { name: 'App', @@ -105,6 +106,10 @@ export default { ...mapState(useAccountAuthStore, ['user']), ...mapState(useUxLoadingStore, ['appLoader', 'offline']), ...mapState(useAccountSettingsStore, ['settings']), + ...mapState(useContextStore, ['instance', 'device']), + pageTitleSignal () { + return [this.instance?.id, this.instance?.name, this.device?.id, this.device?.name, this.$route.name] + }, loginRequired () { return this.$route.meta.requiresLogin !== false }, @@ -143,6 +148,14 @@ export default { handler (to) { this.updateRoute(to) } + }, + pageTitleSignal: { + immediate: true, + handler () { + if (isEditorRoute(this.$route)) return + const title = computePageTitle(this.$route, { instance: this.instance, device: this.device }) + if (title) document.title = title + } } }, mounted () { diff --git a/frontend/src/routes.js b/frontend/src/routes.js index befd9ad2c5..d82f1c9a06 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -13,6 +13,8 @@ import InstanceRoutes from './pages/instance/routes.js' import TeamRoutes from './pages/team/routes.js' import { useAccountAuthStore } from '@/stores/account-auth.js' +import { useContextStore } from '@/stores/context.js' +import { computePageTitle } from '@/utils/page-title' const routes = [ { @@ -85,22 +87,12 @@ router.beforeEach((to, from, next) => { } catch (err) { console.error('posthog error logging route change') } - // This goes through the matched routes from last to first, finding the closest route with a title. - // e.g., if we have `/some/deep/nested/route` and `/some`, `/deep`, and `/nested` have titles, - // `/nested`'s will be chosen. - const nearestWithTitle = to.matched.slice().reverse().find(r => r.meta && r.meta.title) - // Find the nearest route element with meta tags. const nearestWithMeta = to.matched.slice().reverse().find(r => r.meta && r.meta.metaTags) - const previousNearestWithMeta = from.matched.slice().reverse().find(r => r.meta && r.meta.metaTags) - - // If a route with a title was found, set the document (page) title to that value. - if (nearestWithTitle) { - document.title = nearestWithTitle.meta.title + ' - FlowFuse' - } else if (previousNearestWithMeta) { - document.title = previousNearestWithMeta.meta.title + ' - FlowFuse' - } + const contextStore = useContextStore() + const title = computePageTitle(to, contextStore) ?? computePageTitle(from, contextStore) + if (title) document.title = title // Remove any stale meta tags from the document using the key attribute we set below. Array.from(document.querySelectorAll('[data-vue-router-controlled]')).map(el => el.parentNode.removeChild(el)) diff --git a/frontend/src/stores/product-assistant.js b/frontend/src/stores/product-assistant.js index 204a4a378f..0b9377a29a 100644 --- a/frontend/src/stores/product-assistant.js +++ b/frontend/src/stores/product-assistant.js @@ -319,11 +319,14 @@ export const useProductAssistantStore = defineStore('product-assistant', { return this.removeDebugLogContext(payload.data.debugLog) case payload.data.type === 'debug-log-context-clear': return this.resetDebugLogContext() - case payload.data.type === 'nr-assistant/workspace:change': + case payload.data.type === 'nr-assistant/workspace:change': { if (payload.data.tab?.label) { - document.title = `Node-RED: ${payload.data.tab.label} - FlowFuse` + const instanceName = useContextStore().instance?.name + const suffix = instanceName ? ` - ${instanceName} - FlowFuse` : ' - FlowFuse' + document.title = `Node-RED: ${payload.data.tab.label}${suffix}` } break + } default: // do nothing } diff --git a/frontend/src/utils/page-title.ts b/frontend/src/utils/page-title.ts new file mode 100644 index 0000000000..0e27979253 --- /dev/null +++ b/frontend/src/utils/page-title.ts @@ -0,0 +1,52 @@ +import type { RouteLocationNormalized } from 'vue-router' + +import { Maybe } from '@/types/common/types' + +interface IPageTitleMetadata { + id?: Maybe + name?: Maybe +} + +interface IPageTitleContext { + instance?: Maybe + device?: Maybe +} + +const SUFFIX = ' - FlowFuse' + +function hasWordPrefix (title: string, word: string): boolean { + return title === word || title.startsWith(word + ' ') +} + +export function isEditorRoute (route: Maybe): boolean { + const name = route?.name?.toString() ?? '' + return name.startsWith('instance-editor') || name.startsWith('device-editor') +} + +export function computePageTitle ( + route: Maybe, + { instance, device }: IPageTitleContext +): string | null { + const nearestWithTitle = route?.matched?.slice().reverse().find(r => (r.meta as { title?: string })?.title) + if (!nearestWithTitle) return null + + let title = (nearestWithTitle.meta as { title: string }).title + const routeName = route?.name?.toString() ?? '' + const routeId = route?.params?.id + + if (routeName.startsWith('instance') && + instance?.id != null && + instance.id === routeId && + instance.name && + hasWordPrefix(title, 'Instance')) { + title = `${instance.name}${title.slice('Instance'.length)}` + } else if (routeName.startsWith('device') && + device?.id != null && + device.id === routeId && + device.name && + hasWordPrefix(title, 'Device')) { + title = `${device.name}${title.slice('Device'.length)}` + } + + return title + SUFFIX +} diff --git a/test/unit/frontend/utils/page-title.spec.js b/test/unit/frontend/utils/page-title.spec.js new file mode 100644 index 0000000000..ab3df97d9f --- /dev/null +++ b/test/unit/frontend/utils/page-title.spec.js @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'vitest' + +import { computePageTitle, isEditorRoute } from '../../../../frontend/src/utils/page-title.ts' + +function makeRoute ({ name, id, title }) { + return { + name, + params: id == null ? {} : { id }, + matched: title == null ? [] : [{ meta: { title } }] + } +} + +describe('isEditorRoute', () => { + test('matches instance-editor and device-editor route names', () => { + expect(isEditorRoute({ name: 'instance-editor' })).toBe(true) + expect(isEditorRoute({ name: 'instance-editor-sub-flow' })).toBe(true) + expect(isEditorRoute({ name: 'device-editor' })).toBe(true) + }) + + test('returns false for non-editor and null routes', () => { + expect(isEditorRoute({ name: 'instance-overview' })).toBe(false) + expect(isEditorRoute({ name: 'device-overview' })).toBe(false) + expect(isEditorRoute({ name: 'Home' })).toBe(false) + expect(isEditorRoute(null)).toBe(false) + expect(isEditorRoute(undefined)).toBe(false) + expect(isEditorRoute({})).toBe(false) + }) +}) + +describe('computePageTitle', () => { + test('returns null when no matched route has a meta.title', () => { + expect(computePageTitle(makeRoute({ name: 'instance-overview', id: 'abc' }), {})).toBe(null) + expect(computePageTitle(null, {})).toBe(null) + expect(computePageTitle(undefined, { instance: { id: 'abc', name: 'X' } })).toBe(null) + }) + + test('falls through to static title with FlowFuse suffix when no context match', () => { + const route = makeRoute({ name: 'Home', title: 'Home' }) + expect(computePageTitle(route, {})).toBe('Home - FlowFuse') + }) + + test('substitutes instance name on instance-* routes when id matches', () => { + const route = makeRoute({ name: 'instance-overview', id: 'abc', title: 'Instance - Overview' }) + const context = { instance: { id: 'abc', name: 'MyInstance' } } + expect(computePageTitle(route, context)).toBe('MyInstance - Overview - FlowFuse') + }) + + test('substitutes device name on device-* routes when id matches', () => { + const route = makeRoute({ name: 'device-logs', id: 'dev-1', title: 'Device - Logs' }) + const context = { device: { id: 'dev-1', name: 'RaspberryPi-Edge' } } + expect(computePageTitle(route, context)).toBe('RaspberryPi-Edge - Logs - FlowFuse') + }) + + test('falls back to static title when instance id does not match route id (stale store during nav A -> B)', () => { + const route = makeRoute({ name: 'instance-overview', id: 'B', title: 'Instance - Overview' }) + const context = { instance: { id: 'A', name: 'OldInstance' } } + expect(computePageTitle(route, context)).toBe('Instance - Overview - FlowFuse') + }) + + test('falls back to static title when context instance is null / has no name', () => { + const route = makeRoute({ name: 'instance-overview', id: 'abc', title: 'Instance - Overview' }) + expect(computePageTitle(route, {})).toBe('Instance - Overview - FlowFuse') + expect(computePageTitle(route, { instance: null })).toBe('Instance - Overview - FlowFuse') + expect(computePageTitle(route, { instance: { id: 'abc' } })).toBe('Instance - Overview - FlowFuse') + }) + + test('does not substitute on cross-family mismatch (device on instance route, etc.)', () => { + const instanceRoute = makeRoute({ name: 'instance-overview', id: 'abc', title: 'Instance - Overview' }) + const deviceRoute = makeRoute({ name: 'device-overview', id: 'abc', title: 'Device - Overview' }) + expect(computePageTitle(instanceRoute, { device: { id: 'abc', name: 'D' } })).toBe('Instance - Overview - FlowFuse') + expect(computePageTitle(deviceRoute, { instance: { id: 'abc', name: 'I' } })).toBe('Device - Overview - FlowFuse') + }) + + test('leaves titles untouched when they do not start with Instance / Device', () => { + // Defensive guard: e.g. 'Application Settings - General' on an application route should be unchanged. + const route = makeRoute({ name: 'application-settings-general', id: 'abc', title: 'Application Settings - General' }) + expect(computePageTitle(route, { instance: { id: 'abc', name: 'X' } })).toBe('Application Settings - General - FlowFuse') + }) + + test('requires a word boundary after the Instance / Device prefix', () => { + // 'Instances - Foo' must NOT be treated as 'Instance' + 's - Foo' — only whole-word prefix substitutes. + const instancesRoute = makeRoute({ name: 'instance-devices', id: 'abc', title: 'Instances - Foo' }) + expect(computePageTitle(instancesRoute, { instance: { id: 'abc', name: 'X' } })).toBe('Instances - Foo - FlowFuse') + + const devicesRoute = makeRoute({ name: 'device-overview', id: 'abc', title: 'Devices - Foo' }) + expect(computePageTitle(devicesRoute, { device: { id: 'abc', name: 'X' } })).toBe('Devices - Foo - FlowFuse') + }) + + test('substitutes when the title is exactly Instance / Device with no suffix', () => { + const instRoute = makeRoute({ name: 'instance-overview', id: 'abc', title: 'Instance' }) + expect(computePageTitle(instRoute, { instance: { id: 'abc', name: 'Prod' } })).toBe('Prod - FlowFuse') + + const devRoute = makeRoute({ name: 'device-overview', id: 'abc', title: 'Device' }) + expect(computePageTitle(devRoute, { device: { id: 'abc', name: 'Pi' } })).toBe('Pi - FlowFuse') + }) + + test('substitutes name on editor routes too (e.g. instance-editor with title "Instance - Editor")', () => { + // routes.js sets an initial title for editor pages; the Node-RED workspace:change handler overwrites it later. + const route = makeRoute({ name: 'instance-editor', id: 'abc', title: 'Instance - Editor' }) + const context = { instance: { id: 'abc', name: 'Prod' } } + expect(computePageTitle(route, context)).toBe('Prod - Editor - FlowFuse') + }) + + test('preserves whatever follows the Instance/Device prefix verbatim', () => { + const route = makeRoute({ name: 'instance-devices', id: 'abc', title: 'Instance - Remote Instances' }) + const context = { instance: { id: 'abc', name: 'Prod' } } + expect(computePageTitle(route, context)).toBe('Prod - Remote Instances - FlowFuse') + }) + + test('picks the deepest matched route with a meta.title (parent / child nesting)', () => { + const route = { + name: 'instance-overview', + params: { id: 'abc' }, + matched: [ + { meta: { title: 'Parent' } }, + { meta: { title: 'Instance - Overview' } } + ] + } + const context = { instance: { id: 'abc', name: 'MyInstance' } } + expect(computePageTitle(route, context)).toBe('MyInstance - Overview - FlowFuse') + }) +})