mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(system-notices): a notice waits rather than showing its own key names
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. The entry is per-version, so whoever closes the gibberish never sees the notes for that release at all. So a notice made only of unresolved keys is held back until the reload App.tsx is already waiting for. Nothing is spent by waiting: a notice is used up by POST /dismiss and by nothing else. A release notice is judged by the strings its own two-column layout draws, not by the generic title and body it never shows. Copy written out in words rather than keyed is left alone, and one resolved string is enough to show a notice: a notice hidden by mistake is the worse failure.
This commit is contained in:
@@ -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(<SystemNoticeHost />)
|
||||
|
||||
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(<SystemNoticeHost />)
|
||||
|
||||
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(<SystemNoticeHost />)
|
||||
|
||||
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(<SystemNoticeHost />)
|
||||
|
||||
expect(screen.getByTestId('modals')).toHaveTextContent('half')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user