feat: add What's new feature
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 39s

Dashboard dialog shows unseen news entries. Users mark them seen with "Got it"
or revisit via the /whats-new page. Admins publish, edit, and delete entries
on /admin/news. Full i18n support and test coverage included.
This commit is contained in:
lobo committed 2026-10-01 10:03:39 +02:00
1 parent 79b086c525
commit b811255c16
17 files changed
+690 -1

No files matched your search

+7 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -144,6 +144,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
{seesAdminPage && (
<>
<DropdownMenuItem asChild>
@@ -155,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem>
</>
)}
{isAdmin && (
+3
View File
@@ -54,6 +54,7 @@ describe('AppHeader', () => {
openMenu();
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
@@ -92,7 +93,9 @@ describe('AppHeader', () => {
openMenu();
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
});
});
+17
View File
@@ -0,0 +1,17 @@
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {formatDate} from '../utils/format.js';
import {newsText, newsTitle} from '../utils/news.js';
export default function NewsEntry({entry, className}) {
const {i18n} = useTranslation();
return (
<article className={cn('space-y-1', className)}>
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
<h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3>
<p className="whitespace-pre-line break-words text-sm">{newsText(entry, i18n.language)}</p>
</article>
);
}
+56
View File
@@ -0,0 +1,56 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import NewsEntry from './NewsEntry.jsx';
import {fetchNews, markNewsSeen} from '../utils/news.js';
/**
* The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other
* way shows them again on the next dashboard visit.
*/
export default function WhatsNewDialog() {
const {t} = useTranslation();
const [unseen, setUnseen] = useState([]);
const [open, setOpen] = useState(false);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchNews()
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const entries = (data?.results ?? []).filter((entry) => !entry.seen);
setUnseen(entries);
setOpen(entries.length > 0);
})
.catch(() => undefined);
}, []);
const gotIt = async () => {
setOpen(false);
try {
const response = await markNewsSeen();
if (!response.ok) toast.error(t('NEWS.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{unseen.map((entry) => <NewsEntry key={entry.id} entry={entry}/>)}
</div>
<DialogFooter>
<Button onClick={gotIt}>{t('NEWS.GOT_IT')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {toast} from 'sonner';
import WhatsNewDialog from './WhatsNewDialog.jsx';
import {newsText, newsTitle} from '../utils/news.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const NEW = {id: 2, titleEn: 'New feature', titleDe: 'Neue Funktion', textEn: 'English text', textDe: 'Deutscher Text',
publishedAt: '2026-10-01T10:00:00Z', seen: false};
const ENGLISH_ONLY = {...NEW, id: 3, titleEn: 'English only', titleDe: null, textEn: 'Only English', textDe: null};
const OLD = {...NEW, id: 1, titleEn: 'Old', titleDe: 'Alt', seen: true};
const newsRoutes = (list, seen = json(204)) => fetchRoutes({'GET /api/news': list, 'PUT /api/news/seen': seen});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
const showNews = async (...entries) => {
newsRoutes(json(200, {results: entries}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
};
describe('WhatsNewDialog', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('shows only the unseen entries, German when set and English otherwise', async () => {
await showNews(NEW, ENGLISH_ONLY, OLD);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByText('Neue Funktion')).toBeTruthy();
expect(screen.getByText('Deutscher Text')).toBeTruthy();
expect(screen.getByText('English only')).toBeTruthy();
expect(screen.getByText('Only English')).toBeTruthy();
expect(screen.queryByText('Alt')).toBeNull();
});
it('marks the entries seen and closes on "Got it"', async () => {
await showNews(NEW);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/news/seen')]);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('closes with X without marking anything seen', async () => {
await showNews(NEW);
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
expect(screen.queryByRole('dialog')).toBeNull();
expect(putCalls()).toEqual([]);
});
it.each([
['all entries are seen', json(200, {results: [OLD]})],
['there are no entries', json(200, {results: []})],
['the load fails', json(500, {})],
])('shows nothing when %s', async (_, answer) => {
newsRoutes(answer);
render(<WhatsNewDialog/>);
await act(async () => undefined);
expect(screen.queryByRole('dialog')).toBeNull();
expect(toast.error).not.toHaveBeenCalled();
});
it('shows an error when the seen mark is not saved', async () => {
newsRoutes(json(200, {results: [NEW]}), json(500, {}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(toast.error).toHaveBeenCalledWith('NEWS.SAVE_FAILED');
});
});
describe('newsTitle / newsText', () => {
it('pick German only for a German UI and a set German field', () => {
expect(newsTitle(NEW, 'de-DE')).toBe('Neue Funktion');
expect(newsText(NEW, 'de')).toBe('Deutscher Text');
expect(newsTitle(NEW, 'en')).toBe('New feature');
expect(newsText(ENGLISH_ONLY, 'de')).toBe('Only English');
expect(newsTitle(ENGLISH_ONLY, undefined)).toBe('English only');
});
});