Public Access
feat: add What's new feature
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:
1 parent
79b086c525
commit
b811255c16
17 files changed
+690
-1
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user