feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading

This commit is contained in:
Florian Sylvain committed 2026-10-13 03:19:25 +02:00
1 parent fe254112a2
commit 0632bf53cb
12 files changed
+702 -194

No files matched your search

+3
View File
@@ -15,6 +15,7 @@ import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
import EditorStatusBar from '@/components/EditorStatusBar.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
@@ -43,6 +44,8 @@ const seenImages = useSeenImagesStore()
const variantCatalog = useVariantCatalogStore()
const canManageVariants = useCanManageVariants()
usePageTitleOverride(() => article.value?.title)
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
+10 -1
View File
@@ -1,16 +1,25 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted } from 'vue'
import { useSessionStore } from '@/stores/session'
import DashboardShell from '@/components/DashboardShell.vue'
import AuthShell from '@/components/AuthShell.vue'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
const resolving = computed(() => session.status === 'idle' || session.status === 'checking')
onMounted(() => {
if (session.status === 'idle') {
session.check()
}
})
</script>
<template>
<DashboardShell v-if="session.session">
<template v-if="resolving" />
<DashboardShell v-else-if="session.session">
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
</DashboardShell>
<AuthShell v-else :title="t('Page not found')">
+3 -4
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref, watchEffect } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
@@ -27,9 +28,7 @@ onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
})
watchEffect(() => {
document.title = t('Check your email · RenewCMS')
})
usePageTitleOverride(() => t('Check your email'))
const description = computed(() =>
pendingEmail.value
+5 -5
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
@@ -26,9 +27,9 @@ const passwordTouched = ref(false)
const submitting = ref(false)
const passwordShown = ref(false)
const registrationClosed = ref(false)
watchEffect(() => {
if (registrationClosed.value) document.title = t('This site already has an owner · RenewCMS')
})
usePageTitleOverride(() =>
registrationClosed.value ? t('This site already has an owner') : undefined,
)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
@@ -91,7 +92,6 @@ async function onSubmit() {
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
registrationClosed.value = true
document.title = t('This site already has an owner · RenewCMS')
return
}
serverErrorAlert.value = true
@@ -0,0 +1,316 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory } from 'vue-router'
import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import { createAppRouter } from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
type Handler = () => Response | Promise<Response>
const NEVER = new Promise<Response>(() => undefined)
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function article(title: string) {
return {
id: 42,
title,
body: '<p>Body</p>',
images: [],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-03-12T09:14:00Z',
}
}
let wrapper: ReturnType<typeof mount> | null = null
let calls: string[] = []
function installFetch(handlers: Record<string, Handler> = {}) {
calls = []
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${url}`
calls.push(key)
const handler = handlers[key] ?? handlers[key.split('?')[0]]
return handler ? handler() : NEVER
}) as unknown as typeof fetch
}
async function boot(path: string, signedIn: boolean, handlers: Record<string, Handler> = {}) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = signedIn ? { verified: true, username: 'marta', displayName: '' } : null
session.status = 'ready'
installFetch(handlers)
const router = createAppRouter(createMemoryHistory())
await router.push(path)
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
return { router, session }
}
beforeEach(() => {
setLocale('en')
document.title = ''
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
setLocale('en')
})
describe('Back-office pages show their name', () => {
it.each([
['/home', 'Home · RenewCMS'],
['/article', 'Articles · RenewCMS'],
['/article/new', 'Articles · RenewCMS'],
['/media', 'Media · RenewCMS'],
['/users', 'Users · RenewCMS'],
['/activity', 'Activity · RenewCMS'],
['/profile', 'Profile · RenewCMS'],
['/integration', 'Integration · RenewCMS'],
['/integration/api-keys', 'Integration · RenewCMS'],
['/settings', 'Settings · RenewCMS'],
['/settings/media/variants', 'Settings · RenewCMS'],
['/settings/media/cache', 'Settings · RenewCMS'],
])('%s', async (path, title) => {
await boot(path, true)
expect(document.title).toBe(title)
})
})
describe('Auth pages show the card heading they display', () => {
it.each([
['/login', 'Log in · RenewCMS'],
['/register', 'Create the owner account · RenewCMS'],
['/register/reset/request', 'Reset your password · RenewCMS'],
['/register/reset/validate?c=CODE&email=EMAIL', 'Choose a new password · RenewCMS'],
])('%s', async (path, title) => {
await boot(path, false)
expect(document.title).toBe(title)
})
it('/invite/accept?c=CODE', async () => {
await boot('/invite/accept?c=CODE', false, {
'GET /v1/auth/invite': () => mockResponse(200, { email: 'a@b.example' }),
})
expect(document.title).toBe('Accept your invite · RenewCMS')
})
it('/register/pending', async () => {
await boot('/register/pending', true)
expect(document.title).toBe('Check your email · RenewCMS')
})
it('/register/validate?c=CODE without a session', async () => {
await boot('/register/validate?c=CODE', false, {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect(document.title).toBe('Log in to finish verifying · RenewCMS')
})
})
describe('Auth card title edge cases', () => {
it('An auth card with no heading uses the product name', async () => {
await boot('/register/validate?c=CODE', false)
expect(document.title).toBe('RenewCMS')
})
it('Auth card heading changes inside one route', async () => {
let reject: (response: Response) => void = () => undefined
await boot('/register/validate?c=CODE', false, {
'POST /v1/auth/verify': () => new Promise<Response>((resolve) => (reject = resolve)),
})
expect(document.title).toBe('RenewCMS')
reject(mockResponse(401))
await flushPromises()
expect(document.title).toBe('Log in to finish verifying · RenewCMS')
})
})
describe('Title follows navigation', () => {
it('Title updates when the page changes', async () => {
const { router } = await boot('/home', true)
expect(document.title).toBe('Home · RenewCMS')
await router.push('/media')
await flushPromises()
expect(document.title).toBe('Media · RenewCMS')
await router.push('/home')
await flushPromises()
expect(document.title).toBe('Home · RenewCMS')
})
it('Leaving the login screen does not leave "Log in" stuck', async () => {
const { router, session } = await boot('/login', false)
expect(document.title).toBe('Log in · RenewCMS')
session.session = { verified: true, username: 'marta', displayName: '' }
await router.push('/home')
await flushPromises()
expect(document.title).toBe('Home · RenewCMS')
})
it('Logging out does not keep the back-office title', async () => {
const { router, session } = await boot('/settings', true)
expect(document.title).toBe('Settings · RenewCMS')
session.session = null
await router.push('/login')
await flushPromises()
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('Editor tab', () => {
it("Editor tab shows the article's own title once loaded", async () => {
let resolve: (response: Response) => void = () => undefined
await boot('/article/42/edit', true, {
'GET /v1/article/42': () => new Promise<Response>((done) => (resolve = done)),
})
expect(document.title).toBe('Editor · RenewCMS')
resolve(mockResponse(200, article('Hello world')))
await flushPromises()
expect(document.title).toBe('Hello world · RenewCMS')
})
it('Editor tab follows the saved article title', async () => {
let current = article('Hello world')
const { router } = await boot('/article/42/edit', true, {
'GET /v1/article/42': () => mockResponse(200, current),
})
expect(document.title).toBe('Hello world · RenewCMS')
current = article('Hello again')
const editor = wrapper!.findComponent({ name: 'EditorView' })
const exposed = editor.vm as unknown as {
$: { setupState: { article: { title: string } } }
}
exposed.$.setupState.article = { ...exposed.$.setupState.article, title: 'Hello again' }
await flushPromises()
expect(document.title).toBe('Hello again · RenewCMS')
expect(router.currentRoute.value.path).toBe('/article/42/edit')
})
it.each([
['the article fails to load', () => mockResponse(500, { error: 'boom' })],
['the article has an empty title', () => mockResponse(200, article(''))],
])('Editor tab falls back to "Editor" when %s', async (_situation, handler) => {
await boot('/article/42/edit', true, { 'GET /v1/article/42': handler })
expect(document.title).toBe('Editor · RenewCMS')
})
it('Leaving the editor drops the article title', async () => {
const { router } = await boot('/article/42/edit', true, {
'GET /v1/article/42': () => mockResponse(200, article('Hello world')),
})
expect(document.title).toBe('Hello world · RenewCMS')
await router.push('/article')
await flushPromises()
expect(document.title).toBe('Articles · RenewCMS')
})
})
describe('Not-found page', () => {
it('signed in', async () => {
await boot('/nowhere/at/all', true)
expect(document.title).toBe('404 · RenewCMS')
})
it('signed out', async () => {
await boot('/nowhere/at/all', false)
expect(document.title).toBe('404 · RenewCMS')
})
})
describe('Title follows the interface language', () => {
it('re-translates without a navigation', async () => {
setLocale('fr')
const { router } = await boot('/media', true)
expect(document.title).toMatch(/ · RenewCMS$/)
expect(document.title).not.toBe('Media · RenewCMS')
const path = router.currentRoute.value.fullPath
setLocale('en')
await flushPromises()
expect(document.title).toBe('Media · RenewCMS')
expect(router.currentRoute.value.fullPath).toBe(path)
})
})
describe('Not-found layout', () => {
it('renders neither shell while the session check is pending', async () => {
setActivePinia(createPinia())
installFetch()
const router = createAppRouter(createMemoryHistory())
await router.push('/nowhere/at/all')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
expect(useSessionStore().status).toBe('checking')
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Page not found')
})
it('shows the dashboard 404 for a signed-in user once the check resolves', async () => {
setActivePinia(createPinia())
installFetch({
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, username: 'marta', display_name: 'Marta' }),
})
const router = createAppRouter(createMemoryHistory())
await router.push('/nowhere/at/all')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Not found')
expect(document.title).toBe('404 · RenewCMS')
})
})
describe('Closed registration', () => {
it('shows the owner-exists title after a 403 on submit', async () => {
await boot('/register', false, {
'POST /v1/auth/register': () => mockResponse(403),
})
const inputs = wrapper!.findAll('input')
await inputs[0].setValue('marta')
await inputs[1].setValue('marta@renew.example')
await inputs[2].setValue('limewash2026')
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect(wrapper!.find('form').exists()).toBe(false)
expect(document.title).toBe('This site already has an owner · RenewCMS')
})
})
describe('No call is made only to set the title', () => {
it('fetches only the registration status on /login', async () => {
await boot('/login', false)
expect(calls).toEqual(['GET /v1/auth/registration'])
})
})