diff --git a/features/document_title.feature b/features/document_title.feature new file mode 100644 index 0000000..248237f --- /dev/null +++ b/features/document_title.feature @@ -0,0 +1,109 @@ +Feature: Browser tab title names the current page + + Background: + Given the SPA is mounted with its router + And the tab title format is " · RenewCMS" for a page that has a name + And the tab title is just "RenewCMS" when the current page has no name at all + And page names are translated to the interface language + And the title is set in one place for the whole SPA, not by each screen + And no /v1 call is made only to set the title + + Scenario Outline: Back-office pages show their name + Given a signed-in verified user + When the user navigates to + Then the tab title is "" + + Examples: + | path | title | + | /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 | + + Scenario Outline: Auth pages show the card heading they display + Given a signed-out visitor + When the visitor navigates to <path> + Then the tab title is "<title>" + + Examples: + | path | title | + | /login | Log in · RenewCMS | + | /register | Create the owner account · RenewCMS | + | /register/pending | Check your email · RenewCMS | + | /register/reset/request | Reset your password · RenewCMS | + | /register/reset/validate?c=CODE&email=EMAIL | Choose a new password · RenewCMS | + | /invite/accept?c=CODE | Accept your invite · RenewCMS | + | /register/validate?c=CODE without a session | Log in to finish verifying · RenewCMS | + + Scenario: An auth card with no heading uses the product name + Given the verify screen is verifying a code and its card shows no heading + Then the tab title is "RenewCMS" + + Scenario: Auth card heading changes inside one route + Given an auth screen whose card heading changes between states + When the card heading changes + Then the tab title follows the new card heading + + Scenario: Title updates when the page changes + Given a signed-in verified user on /home with the tab title "Home · RenewCMS" + When the user navigates to /media + Then the tab title is "Media · RenewCMS" + When the user navigates back to /home + Then the tab title is "Home · RenewCMS" + + Scenario: Leaving the login screen does not leave "Log in" stuck + Given a signed-out visitor on /login with the tab title "Log in · RenewCMS" + When the visitor logs in successfully and is redirected to /home + Then the tab title is "Home · RenewCMS" + + Scenario: Logging out does not keep the back-office title + Given a signed-in verified user on /settings + When the user logs out and is sent to /login + Then the tab title is "Log in · RenewCMS" + + Scenario: Editor tab shows the article's own title once loaded + Given a signed-in verified user + When the user opens /article/42/edit and the article is still loading + Then the tab title is "Editor · RenewCMS" + When the article "Hello world" has loaded + Then the tab title is "Hello world · RenewCMS" + + Scenario: Editor tab follows the saved article title + Given the user is editing the article "Hello world" at /article/42/edit + When the article title is saved as "Hello again" + Then the tab title is "Hello again · RenewCMS" + + Scenario Outline: Editor tab falls back to "Editor" + Given a signed-in verified user + When the user opens /article/42/edit and <situation> + Then the tab title is "Editor · RenewCMS" + + Examples: + | situation | + | the article fails to load | + | the article has an empty title | + + Scenario: Leaving the editor drops the article title + Given the user is on /article/42/edit with the tab title "Hello world · RenewCMS" + When the user navigates to /article + Then the tab title is "Articles · RenewCMS" + + Scenario: Not-found page + Given any user + When the user navigates to an unknown path + Then the tab title is "404 · RenewCMS" + + Scenario: Title follows the interface language + Given the interface language is Français + When the user navigates to /media + Then the tab title is the French "Media" name followed by " · RenewCMS" + When the user switches the interface language to English + Then the tab title is "Media · RenewCMS" without a navigation diff --git a/features/document_title.qa.md b/features/document_title.qa.md new file mode 100644 index 0000000..4a73211 --- /dev/null +++ b/features/document_title.qa.md @@ -0,0 +1,23 @@ +# QA: document title (SPA, Vitest only) + +## Automated (Vitest + Vue Test Utils, real route table, memory history, mocked /v1) +Each test resets `document.title` to "" in `beforeEach`. + +1. Back-office rows: with `/v1` user mocked as signed in and verified, push each path from the first Examples table, await `router.isReady()`, assert `document.title`. +2. Auth rows: with the session mocked as signed out (401), push each path of the auth Examples table and assert the title equals the card heading plus " · RenewCMS". For the verify noSession row, mock a verify attempt with no session. +3. Verify with a code and no heading: assert `document.title === 'RenewCMS'`. +4. Auth heading change: drive an auth screen through two states with different headings (e.g. reset request default to the incomplete/expired variant) and assert the title follows. +5. Navigation sequence: /home, /media, /home, asserting after each push. +6. Login redirect: from /login simulate a successful login so the guard redirects to /home; assert "Home · RenewCMS". +7. Logout: from /settings trigger logout; assert "Log in · RenewCMS". +8. Editor: mock GET article 42 with a pending promise, assert "Editor · RenewCMS"; resolve with title "Hello world", assert "Hello world · RenewCMS". Save with title "Hello again" and assert the update. Repeat with a failing GET and with an empty title, expecting "Editor · RenewCMS". Navigate to /article and assert "Articles · RenewCMS". +9. Not found: push an unknown path, assert "404 · RenewCMS". +10. Locale: set `fr`, push /media, assert the French name plus " · RenewCMS"; switch to `en` and assert "Media · RenewCMS" without a navigation. +11. Update `web/src/components/__tests__/AuthShell.spec.ts` if its title expectations no longer hold once the title is set in one place. + +## Visual +None. The tab title is not drawn on any board (skip: not-visual). + +## Manual +- In a real browser, log in from /login and confirm the tab reads "Home · RenewCMS", then use the sidebar and the browser Back and Forward buttons and confirm the tab follows. +- Open an article in the editor, edit its title, and confirm the tab and the history entry follow. diff --git a/web/src/components/AuthShell.vue b/web/src/components/AuthShell.vue index 4be2bf3..00e1b8b 100644 --- a/web/src/components/AuthShell.vue +++ b/web/src/components/AuthShell.vue @@ -1,9 +1,8 @@ <script setup lang="ts"> -import { t } from '@/i18n' - import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue' import AppLogo from '@/components/AppLogo.vue' import LanguageChip from '@/components/LanguageChip.vue' +import { useCardHeading } from '@/composables/documentTitle' import { isInAppNavigation } from '@/composables/useAuthShellNavigation' const props = defineProps<{ @@ -70,16 +69,7 @@ const colorModeSelectUi = computed(() => ({ content: 'w-auto min-w-36', })) -watch( - () => props.title, - (title) => { - if (!title) { - return - } - document.title = t('{0} · RenewCMS', [title]) - }, - { immediate: true }, -) +useCardHeading(() => props.title) watch( () => props.title, diff --git a/web/src/components/__tests__/AuthShell.spec.ts b/web/src/components/__tests__/AuthShell.spec.ts index a88204c..e437418 100644 --- a/web/src/components/__tests__/AuthShell.spec.ts +++ b/web/src/components/__tests__/AuthShell.spec.ts @@ -8,10 +8,7 @@ beforeEach(() => { resetAuthShellNavigation() }) -function mountShell( - props: Record<string, unknown>, - slots: Record<string, unknown> = {}, -) { +function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) { return mount(AuthShell, { props, slots, @@ -21,36 +18,29 @@ function mountShell( }) } -describe('AuthShell renders its title as the page heading and sets the document title', () => { - it('renders an h1 containing the title and sets document.title', () => { +describe('AuthShell renders its title as the page heading', () => { + it('renders an h1 containing the title', () => { const wrapper = mountShell({ title: 'Log in' }) const heading = wrapper.find('h1') expect(heading.text()).toBe('Log in') expect(heading.attributes('tabindex')).toBe('-1') - expect(document.title).toBe('Log in · RenewCMS') }) }) -describe('AuthShell with an empty title renders no heading and leaves document.title untouched', () => { - it('mounts directly with an empty title without rendering an h1 or touching document.title', () => { - document.title = 'Existing title · RenewCMS' - +describe('AuthShell with an empty title renders no heading', () => { + it('mounts with an empty title without rendering an h1', () => { const wrapper = mountShell({ title: '' }) expect(wrapper.find('h1').exists()).toBe(false) - expect(document.title).toBe('Existing title · RenewCMS') }) - it('leaves document.title untouched and does not crash when the title changes to empty', async () => { + it('removes the heading when the title changes to empty', async () => { const wrapper = mountShell({ title: 'Create the owner account' }) - expect(document.title).toBe('Create the owner account · RenewCMS') - await wrapper.setProps({ title: '' }) expect(wrapper.find('h1').exists()).toBe(false) - expect(document.title).toBe('Create the owner account · RenewCMS') }) }) @@ -66,17 +56,13 @@ describe('AuthShell renders its notice and footer slots', () => { const notice = wrapper.find('span') expect(notice.text()).toBe('Your session expired.') - expect( - wrapper.findAll('[data-slot="root"][data-orientation]').length, - ).toBe(1) + expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1) const card = wrapper.find('[data-testid="auth-card"]') expect(card.element.contains(notice.element)).toBe(true) const html = wrapper.html() - expect(html.indexOf('<h1')).toBeLessThan( - html.indexOf('Your session expired.'), - ) + expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.')) const footerLink = wrapper.find('a[href="/register/reset/request"]') expect(footerLink.exists()).toBe(true) diff --git a/web/src/composables/documentTitle.ts b/web/src/composables/documentTitle.ts new file mode 100644 index 0000000..e70ccd5 --- /dev/null +++ b/web/src/composables/documentTitle.ts @@ -0,0 +1,66 @@ +import { getCurrentScope, onScopeDispose, shallowRef, watchEffect } from 'vue' +import type { Router } from 'vue-router' +import { t, translate } from '@/i18n' + +const PRODUCT_NAME = 'RenewCMS' + +type TitleSource = () => string | undefined + +const overrides = shallowRef<TitleSource[]>([]) +const headings = shallowRef<TitleSource[]>([]) + +function register(list: typeof overrides, source: TitleSource) { + list.value = [...list.value, source] + if (getCurrentScope()) { + onScopeDispose(() => { + list.value = list.value.filter((entry) => entry !== source) + }) + } +} + +function firstName(sources: TitleSource[]) { + for (const source of [...sources].reverse()) { + const name = source() + if (name) { + return name + } + } + return undefined +} + +export function usePageTitleOverride(source: TitleSource) { + register(overrides, source) +} + +export function useCardHeading(source: TitleSource) { + register(headings, source) +} + +function formatTitle(name: string | undefined) { + return name ? t('{0} · RenewCMS', [name]) : PRODUCT_NAME +} + +function routeName(router: Router) { + const meta = router.currentRoute.value.meta + if (typeof meta.documentTitle === 'string') { + return meta.documentTitle + } + const override = firstName(overrides.value) + if (override) { + return override + } + const heading = firstName(headings.value) + if (heading) { + return heading + } + return typeof meta.title === 'string' ? translate(meta.title) : undefined +} + +let stopTitleWatcher: (() => void) | null = null + +export function installDocumentTitle(router: Router) { + stopTitleWatcher?.() + stopTitleWatcher = watchEffect(() => { + document.title = formatTitle(routeName(router)) + }) +} diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 0382084..9b7f5a3 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -797,8 +797,6 @@ export const messages = { '{0} {1} attention': '{0} {1} votre attention', '{0} {1} rejected': '{0} {1} refusées', '{0} {1} the same content.': '{0} {1} le même contenu.', - 'Check your email · RenewCMS': 'Consultez vos e-mails · RenewCMS', - 'This site already has an owner · RenewCMS': 'Ce site a déjà un propriétaire · RenewCMS', '{0} · RenewCMS': '{0} · RenewCMS', Enter: 'Entrée', Escape: 'Échap', diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c34bf55..23ddafc 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,128 +1,128 @@ import { createRouter, createWebHistory, + type RouterHistory, type RouteLocationNormalized, type RouteLocationRaw, + type RouteRecordRaw, } from 'vue-router' import { useSessionStore } from '@/stores/session' +import { installDocumentTitle } from '@/composables/documentTitle' export type AuthRequirement = 'public' | 'session' | 'verified' | 'any' -const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - component: () => import('@/views/RootView.vue'), - meta: { auth: 'any' }, - }, - { - path: '/login', - component: () => import('@/views/LoginView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/register', - component: () => import('@/views/RegisterView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/register/pending', - component: () => import('@/views/PendingView.vue'), - meta: { auth: 'session' }, - }, - { - path: '/register/validate', - component: () => import('@/views/VerifyView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/register/reset/request', - component: () => import('@/views/ResetRequestView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/register/reset/validate', - component: () => import('@/views/ResetConfirmView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/invite/accept', - component: () => import('@/views/AcceptInviteView.vue'), - meta: { auth: 'public' }, - }, - { - path: '/home', - component: () => import('@/views/HomeView.vue'), - meta: { auth: 'verified', title: 'Home' }, - }, - { - path: '/article', - component: () => import('@/views/ArticleView.vue'), - meta: { auth: 'verified', title: 'Articles' }, - }, - { - path: '/article/new', - component: () => import('@/views/ArticleView.vue'), - meta: { auth: 'verified', title: 'Articles' }, - }, - { - path: '/article/:id/edit', - component: () => import('@/views/EditorView.vue'), - meta: { auth: 'verified', title: 'Editor' }, - }, - { - path: '/media', - component: () => import('@/views/MediaView.vue'), - meta: { auth: 'verified', title: 'Media' }, - }, - { - path: '/users', - component: () => import('@/views/UsersView.vue'), - meta: { auth: 'verified', title: 'Users' }, - }, - { - path: '/activity', - component: () => import('@/views/ActivityView.vue'), - meta: { auth: 'verified', title: 'Activity' }, - }, - { - path: '/profile', - component: () => import('@/views/ProfileView.vue'), - meta: { auth: 'verified', title: 'Profile' }, - }, - { - path: '/integration', - component: () => import('@/views/IntegrationView.vue'), - meta: { auth: 'verified', title: 'Integration' }, - }, - { - path: '/integration/api-keys', - component: () => import('@/views/ApiKeysView.vue'), - meta: { auth: 'verified', title: 'Integration' }, - }, - { - path: '/settings', - component: () => import('@/views/SettingsView.vue'), - meta: { auth: 'verified', title: 'Settings' }, - }, - { - path: '/settings/media/variants', - component: () => import('@/views/VariantsPage.vue'), - meta: { auth: 'verified', title: 'Settings' }, - }, - { - path: '/settings/media/cache', - component: () => import('@/views/CachePage.vue'), - meta: { auth: 'verified', title: 'Settings' }, - }, - { - path: '/:pathMatch(.*)*', - component: () => import('@/views/NotFoundView.vue'), - meta: { auth: 'any' }, - }, - ], -}) +const routes: RouteRecordRaw[] = [ + { + path: '/', + component: () => import('@/views/RootView.vue'), + meta: { auth: 'any' }, + }, + { + path: '/login', + component: () => import('@/views/LoginView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/register', + component: () => import('@/views/RegisterView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/register/pending', + component: () => import('@/views/PendingView.vue'), + meta: { auth: 'session' }, + }, + { + path: '/register/validate', + component: () => import('@/views/VerifyView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/register/reset/request', + component: () => import('@/views/ResetRequestView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/register/reset/validate', + component: () => import('@/views/ResetConfirmView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/invite/accept', + component: () => import('@/views/AcceptInviteView.vue'), + meta: { auth: 'public' }, + }, + { + path: '/home', + component: () => import('@/views/HomeView.vue'), + meta: { auth: 'verified', title: 'Home' }, + }, + { + path: '/article', + component: () => import('@/views/ArticleView.vue'), + meta: { auth: 'verified', title: 'Articles' }, + }, + { + path: '/article/new', + component: () => import('@/views/ArticleView.vue'), + meta: { auth: 'verified', title: 'Articles' }, + }, + { + path: '/article/:id/edit', + component: () => import('@/views/EditorView.vue'), + meta: { auth: 'verified', title: 'Editor' }, + }, + { + path: '/media', + component: () => import('@/views/MediaView.vue'), + meta: { auth: 'verified', title: 'Media' }, + }, + { + path: '/users', + component: () => import('@/views/UsersView.vue'), + meta: { auth: 'verified', title: 'Users' }, + }, + { + path: '/activity', + component: () => import('@/views/ActivityView.vue'), + meta: { auth: 'verified', title: 'Activity' }, + }, + { + path: '/profile', + component: () => import('@/views/ProfileView.vue'), + meta: { auth: 'verified', title: 'Profile' }, + }, + { + path: '/integration', + component: () => import('@/views/IntegrationView.vue'), + meta: { auth: 'verified', title: 'Integration' }, + }, + { + path: '/integration/api-keys', + component: () => import('@/views/ApiKeysView.vue'), + meta: { auth: 'verified', title: 'Integration' }, + }, + { + path: '/settings', + component: () => import('@/views/SettingsView.vue'), + meta: { auth: 'verified', title: 'Settings' }, + }, + { + path: '/settings/media/variants', + component: () => import('@/views/VariantsPage.vue'), + meta: { auth: 'verified', title: 'Settings' }, + }, + { + path: '/settings/media/cache', + component: () => import('@/views/CachePage.vue'), + meta: { auth: 'verified', title: 'Settings' }, + }, + { + path: '/:pathMatch(.*)*', + component: () => import('@/views/NotFoundView.vue'), + meta: { auth: 'public', documentTitle: '404' }, + }, +] export function resolveAuthRedirect( to: RouteLocationNormalized, @@ -163,41 +163,50 @@ export function resolveAuthRedirect( return true } -router.beforeEach((to) => { - if (to.meta.auth === 'public') { - return true - } +export function createAppRouter(history: RouterHistory) { + const router = createRouter({ history, routes }) + installDocumentTitle(router) - const session = useSessionStore() - - if (session.status === 'idle') { - session.check() - return true - } - if (session.status === 'checking') { - return true - } - - return resolveAuthRedirect(to, session) -}) - -router.afterEach((to) => { - if (to.meta.auth === 'public') { - return - } - - const session = useSessionStore() - const settled = - session.status === 'idle' || session.status === 'checking' - ? session.check() - : Promise.resolve() - - settled.then(() => { - const destination = resolveAuthRedirect(router.currentRoute.value, session) - if (destination !== true) { - router.replace(destination) + router.beforeEach((to) => { + if (to.meta.auth === 'public') { + return true } + + const session = useSessionStore() + + if (session.status === 'idle') { + session.check() + return true + } + if (session.status === 'checking') { + return true + } + + return resolveAuthRedirect(to, session) }) -}) + + router.afterEach((to) => { + if (to.meta.auth === 'public') { + return + } + + const session = useSessionStore() + const settled = + session.status === 'idle' || session.status === 'checking' + ? session.check() + : Promise.resolve() + + settled.then(() => { + const destination = resolveAuthRedirect(router.currentRoute.value, session) + if (destination !== true) { + router.replace(destination) + } + }) + }) + + return router +} + +const router = createAppRouter(createWebHistory(import.meta.env.BASE_URL)) export default router diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue index 1dcb227..6bc9485 100644 --- a/web/src/views/EditorView.vue +++ b/web/src/views/EditorView.vue @@ -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) diff --git a/web/src/views/NotFoundView.vue b/web/src/views/NotFoundView.vue index 14d5f4d..e65e1a2 100644 --- a/web/src/views/NotFoundView.vue +++ b/web/src/views/NotFoundView.vue @@ -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')"> diff --git a/web/src/views/PendingView.vue b/web/src/views/PendingView.vue index 94bc9cc..a46d8a7 100644 --- a/web/src/views/PendingView.vue +++ b/web/src/views/PendingView.vue @@ -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 diff --git a/web/src/views/RegisterView.vue b/web/src/views/RegisterView.vue index f15c413..ad382f7 100644 --- a/web/src/views/RegisterView.vue +++ b/web/src/views/RegisterView.vue @@ -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 diff --git a/web/src/views/__tests__/DocumentTitle.spec.ts b/web/src/views/__tests__/DocumentTitle.spec.ts new file mode 100644 index 0000000..1df0e9a --- /dev/null +++ b/web/src/views/__tests__/DocumentTitle.spec.ts @@ -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']) + }) +})