Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
},
Expand Down Expand Up @@ -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 () {
Expand Down
18 changes: 5 additions & 13 deletions frontend/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/stores/product-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/utils/page-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { RouteLocationNormalized } from 'vue-router'

import { Maybe } from '@/types/common/types'

interface IPageTitleMetadata {
id?: Maybe<string>
name?: Maybe<string>
}

interface IPageTitleContext {
instance?: Maybe<IPageTitleMetadata>
device?: Maybe<IPageTitleMetadata>
}

const SUFFIX = ' - FlowFuse'

function hasWordPrefix (title: string, word: string): boolean {
return title === word || title.startsWith(word + ' ')
}

export function isEditorRoute (route: Maybe<RouteLocationNormalized>): boolean {
const name = route?.name?.toString() ?? ''
return name.startsWith('instance-editor') || name.startsWith('device-editor')
}

export function computePageTitle (
route: Maybe<RouteLocationNormalized>,
{ 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
}
122 changes: 122 additions & 0 deletions test/unit/frontend/utils/page-title.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading