diff --git a/client/src/components/SystemNotices/SystemNoticeHost.test.tsx b/client/src/components/SystemNotices/SystemNoticeHost.test.tsx index 84afad95a..e6db29e82 100644 --- a/client/src/components/SystemNotices/SystemNoticeHost.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeHost.test.tsx @@ -1,4 +1,4 @@ -// FE-W4SNH-001 to FE-W4SNH-008 +// FE-W4SNH-001 to FE-W4SNH-012 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, act } from '../../../tests/helpers/render' import { useSystemNoticeStore, type SystemNoticeDTO } from '../../store/systemNoticeStore' @@ -152,4 +152,92 @@ describe('SystemNoticeHost', () => { expect(screen.getByTestId('modals')).toHaveTextContent('thanks') }) + // The deploy window: the server already serves the new release notice, the browser is + // still running the bundle the service worker cached, and the keys that notice names do + // not exist in it. See hasCopy in SystemNoticeHost. + it('FE-W4SNH-009: a notice made only of keys this build cannot resolve waits instead of showing them', () => { + useSystemNoticeStore.setState({ + loaded: true, + notices: [notice({ + id: 'release-notes', + display: 'modal', + titleKey: 'system_notice.release_notes.title', + bodyKey: 'system_notice.release_notes.body', + })], + }) + + render() + + expect(screen.getByTestId('modals')).toBeEmptyDOMElement() + }) + + it('FE-W4SNH-010: a release notice is judged by the strings its own layout draws', () => { + // The release panel never shows the generic title and body, so a resolvable pair there + // would vouch for a panel that is still all keys. The keys name a release this build + // knows nothing about, which is exactly the position an old bundle is in. + const release = { + version: '5.0.0', + eyebrowKey: 'system_notice.release_notes_5_0_0.eyebrow', + headlineKey: 'system_notice.release_notes_5_0_0.headline', + introKey: 'system_notice.release_notes_5_0_0.intro', + featuresLabelKey: 'system_notice.release_notes_5_0_0.features_label', + features: [], + note: { + eyebrowKey: 'system_notice.release_notes_5_0_0.note_eyebrow', + titleKey: 'system_notice.release_notes_5_0_0.note_title', + bodyKey: 'system_notice.release_notes_5_0_0.note_body', + promiseLabelKey: 'system_notice.release_notes_5_0_0.promise_label', + promiseLeadKey: 'system_notice.release_notes_5_0_0.promise_lead', + promiseTextKey: 'system_notice.release_notes_5_0_0.promise_text', + bodyAfterKey: 'system_notice.release_notes_5_0_0.note_body_after', + closingKey: 'system_notice.release_notes_5_0_0.note_closing', + }, + supportLeadKey: 'system_notice.release_notes_5_0_0.support_lead', + supportTextKey: 'system_notice.release_notes_5_0_0.support_text', + } + useSystemNoticeStore.setState({ + loaded: true, + notices: [notice({ id: 'release-notes', display: 'modal', titleKey: 'Update', bodyKey: 'Installed', release })], + }) + + render() + + expect(screen.getByTestId('modals')).toBeEmptyDOMElement() + }) + + it('FE-W4SNH-011: a notice written out in words is shown, keys or no keys', () => { + // An instance's own announcement carries literal copy, and t() hands back anything it + // does not know — so the shape of the string is what tells the two apart. + useSystemNoticeStore.setState({ + loaded: true, + notices: [notice({ + id: 'outage', + display: 'modal', + titleKey: 'Maintenance tonight', + bodyKey: 'The server restarts at 22:00.', + })], + }) + + render() + + expect(screen.getByTestId('modals')).toHaveTextContent('outage') + }) + + it('FE-W4SNH-012: one resolved string is enough to show the notice', () => { + // Generous on purpose: half a translation is still readable, and a notice hidden by + // mistake is the worse failure. 'common.save' is in every build. + useSystemNoticeStore.setState({ + loaded: true, + notices: [notice({ + id: 'half', + display: 'modal', + titleKey: 'common.save', + bodyKey: 'system_notice.not.in.this.build', + })], + }) + + render() + + expect(screen.getByTestId('modals')).toHaveTextContent('half') + }) }) diff --git a/client/src/components/SystemNotices/SystemNoticeHost.tsx b/client/src/components/SystemNotices/SystemNoticeHost.tsx index 8c86040a7..d622d3148 100644 --- a/client/src/components/SystemNotices/SystemNoticeHost.tsx +++ b/client/src/components/SystemNotices/SystemNoticeHost.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; -import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; +import { useSystemNoticeStore, type SystemNoticeDTO } from '../../store/systemNoticeStore.js'; +import { useTranslation } from '../../i18n/index.js'; import { ModalRenderer } from './SystemNoticeModal.js'; import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js'; @@ -18,8 +19,44 @@ function useIsMobile() { return isMobile; } +/** + * A key by its shape: dotted, no spaces, `system_notice.release_notes.headline`. + * + * A notice may carry literal copy instead of a key — an instance's own announcement is + * written out, not translated — and `t()` hands any string it does not know straight back, + * so the test below cannot tell "no translation" from "this IS the text" on its own. The + * shape can: a sentence has spaces and no dots between words. + */ +const KEY_SHAPE = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/i; + +/** + * Whether a notice has words to show, rather than the names of strings this build lacks. + * + * The keys come from the server's registry, the copy from the bundle in the browser, and a + * deploy parts the two for as long as it takes the page to reload: the service worker keeps + * serving the old app while `/api/config` already reports the new version, and the release + * notice for that very version arrives naming keys the old bundle never had. What is drawn + * is then `system_notice.release_notes.headline` where the headline belongs, on the one + * notice whose whole job is to be read once per release. Worse, a dismissal in that window + * is a real one: it is `per-version`, so whoever closes the gibberish never sees the notes. + * + * So a notice made only of unresolved keys waits. Nothing is spent by waiting — a notice is + * used up by `POST /dismiss` and by nothing else — and `App.tsx` reloads the page as soon + * as the new worker takes over, after which this returns true and the modal opens for real. + * + * Deliberately generous: one resolved key is enough. Half a translation is still readable, + * and a notice hidden by mistake is a worse failure than an ugly one. + */ +function hasCopy(notice: SystemNoticeDTO, t: (key: string) => string): boolean { + const keys = notice.release + ? [notice.release.headlineKey, notice.release.introKey, notice.release.note.titleKey] + : [notice.titleKey, notice.bodyKey]; + return keys.some(key => !KEY_SHAPE.test(key) || t(key) !== key); +} + export function SystemNoticeHost() { const { notices, loaded } = useSystemNoticeStore(); + const { t } = useTranslation(); const isMobile = useIsMobile(); // Notices are fetched by authStore after login (see App.tsx / authStore modification). @@ -33,8 +70,10 @@ export function SystemNoticeHost() { if (!loaded) return null; - // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile. - const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices; + // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile, and a + // notice whose copy this build does not have yet waits for the reload (see hasCopy). + const readable = notices.filter(n => hasCopy(n, t)); + const visible = isMobile ? readable.filter(n => !n.desktopOnly) : readable; const modals = visible.filter(n => n.display === 'modal'); const banners = visible.filter(n => n.display === 'banner');