feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading
This commit is contained in:
1 parent
fe254112a2
commit
0632bf53cb
12 files changed
+702
-194
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user