Public Access
feat(hint): targets, automatic hints and formatting
Target fields on the admin hints page. The dashboard shows the four automatic hints after admin hints when their condition is true, using data Core already serves. HintText renders **bold** and safe links (app path or https) as React elements, never HTML, in hints and news. Auto-close without tick hides for this session, X for 7 days, tick + X for good.
This commit is contained in:
1 parent
ae904d946a
commit
8598024bce
14 files changed
+578
-53
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import HintCard from './HintCard.jsx';
|
||||
import {fetchHints, markHintDone} from '../utils/hints.js';
|
||||
import {AUTO_HINTS, fetchHints, hideAutoHint, markHintDone, pileGrowth, snoozeAutoHint} from '../utils/hints.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {fetchLinkQueue} from '../utils/links.js';
|
||||
|
||||
export const AUTO_CLOSE_MS = 30_000;
|
||||
const CLOSED_KEY = 'tbp_closed_hints';
|
||||
@@ -26,34 +28,75 @@ const rememberClosed = (hintId) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The admin hints on the dashboard, one at a time, newest first. A DISMISS hint closed without "Do not show again"
|
||||
* comes back in the next browser session.
|
||||
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
|
||||
* closed by the 30 s timer without "Do not show again" comes back in the next browser session. The X on an automatic
|
||||
* hint hides it for 7 days, "Do not show again" for good.
|
||||
*
|
||||
* @param user the current user
|
||||
* @param summary Core's library summary; undefined while loading, null when it failed
|
||||
* @param trend the TREND group of the stats; undefined while loading, null when it failed
|
||||
*/
|
||||
export default function DashboardHint() {
|
||||
export default function DashboardHint({user, summary, trend}) {
|
||||
const {t} = useTranslation();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [data, setData] = useState({hints: [], automatic: []});
|
||||
const [closed, setClosed] = useState(closedIds);
|
||||
const [hideForever, setHideForever] = useState(false);
|
||||
const hint = hints[0];
|
||||
const [linkCount, setLinkCount] = useState();
|
||||
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
|
||||
|
||||
useEffect(() => {
|
||||
// A failed load shows nothing, not an error.
|
||||
fetchHints()
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
const closed = closedIds();
|
||||
setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id)));
|
||||
})
|
||||
.then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsLinkQueue) return;
|
||||
// The queue is an AUDITOR page: a failure or 403 counts as an empty queue.
|
||||
fetchLinkQueue({page: 0, size: 20})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((body) => setLinkCount(body?.totalElements ?? 0))
|
||||
.catch(() => setLinkCount(0));
|
||||
}, [wantsLinkQueue]);
|
||||
|
||||
const hint = useMemo(() => {
|
||||
const {hints, automatic} = data;
|
||||
const links = wantsLinkQueue ? linkCount : 0;
|
||||
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
|
||||
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
|
||||
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|
||||
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const libraryEmpty = summary?.total === 0;
|
||||
const growth = pileGrowth(trend);
|
||||
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
|
||||
const count = {LINK_QUEUE: links, PILE_GREW: growth};
|
||||
return [
|
||||
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
|
||||
...AUTO_HINTS.filter((key) => automatic.includes(key) && condition[key]).map((key) => ({
|
||||
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
|
||||
})),
|
||||
].find((h) => !closed.includes(h.id));
|
||||
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]);
|
||||
|
||||
// A failed save only shows an error: the hint comes back in the next browser session.
|
||||
const close = useCallback(async (done) => {
|
||||
const close = useCallback(async (forever, byUser) => {
|
||||
rememberClosed(hint.id);
|
||||
setHints((current) => current.slice(1));
|
||||
setClosed((current) => [...current, hint.id]);
|
||||
setHideForever(false);
|
||||
if (!done) return;
|
||||
let save = null;
|
||||
if (hint.auto) {
|
||||
if (forever) save = () => hideAutoHint(hint.auto);
|
||||
else if (byUser) save = () => snoozeAutoHint(hint.auto);
|
||||
} else if (forever) {
|
||||
save = () => markHintDone(hint.id);
|
||||
}
|
||||
if (!save) return;
|
||||
try {
|
||||
const response = await markHintDone(hint.id);
|
||||
const response = await save();
|
||||
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
|
||||
} catch {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
@@ -62,7 +105,7 @@ export default function DashboardHint() {
|
||||
|
||||
useEffect(() => {
|
||||
if (hint?.kind !== 'DISMISS') return undefined;
|
||||
const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS);
|
||||
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hint, hideForever, close]);
|
||||
|
||||
@@ -71,8 +114,8 @@ export default function DashboardHint() {
|
||||
// The next card shows under the same pointer: the second click of a double click must not close it unseen.
|
||||
return (
|
||||
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
|
||||
onClose={(event) => event.detail < 2 && close(hideForever)}
|
||||
onAccept={(event) => event.detail < 2 && close(true)}
|
||||
onClose={(event) => event.detail < 2 && close(hideForever, true)}
|
||||
onAccept={(event) => event.detail < 2 && close(true, true)}
|
||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
import {hintText, pileGrowth} from '../utils/hints.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -12,14 +13,29 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
||||
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
|
||||
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
|
||||
|
||||
const hintRoutes = (list, done = json(204)) => fetchRoutes({
|
||||
const USER = {steamId: '1', activeRole: 'USER'};
|
||||
const AUDITOR = {steamId: '1', activeRole: 'AUDITOR'};
|
||||
const NO_TREND = {days: []};
|
||||
const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02', backlogGames: 12}]};
|
||||
|
||||
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
|
||||
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
|
||||
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done, ...more,
|
||||
});
|
||||
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
||||
|
||||
const mount = (props = {}) => render(
|
||||
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
|
||||
|
||||
const showHints = async (...hints) => {
|
||||
hintRoutes(json(200, {results: hints}));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
};
|
||||
|
||||
const showAuto = async (automatic, props = {}, results = [], more = {}) => {
|
||||
hintRoutes(json(200, {results, automatic}), json(204), more);
|
||||
mount(props);
|
||||
await act(async () => undefined);
|
||||
};
|
||||
|
||||
@@ -50,7 +66,7 @@ describe('DashboardHint', () => {
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
cleanup();
|
||||
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
|
||||
@@ -92,7 +108,7 @@ describe('DashboardHint', () => {
|
||||
|
||||
it('shows an error when the answer is not stored', async () => {
|
||||
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
|
||||
@@ -113,7 +129,7 @@ describe('DashboardHint', () => {
|
||||
|
||||
it('shows nothing when the hints do not load', async () => {
|
||||
hintRoutes(json(500));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
@@ -121,6 +137,162 @@ describe('DashboardHint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardHint, automatic hints', () => {
|
||||
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the admin hints before the automatic ones', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows NO_STEAM only without a Steam ID', async () => {
|
||||
await showAuto(['NO_STEAM']);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides an automatic hint for 7 days with X', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/snooze')]);
|
||||
});
|
||||
|
||||
it('hides an automatic hint for good with the tick', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
|
||||
});
|
||||
|
||||
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([]);
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the auto closed hint for good when the tick is set', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
|
||||
});
|
||||
|
||||
it('does not show an automatic hint that Auth did not send', async () => {
|
||||
await showAuto([], {user: {...USER, steamId: null}, summary: {total: 0}});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows EMPTY_LIBRARY only with an empty library', async () => {
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 3}});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 0}});
|
||||
expect(screen.getByText('HINT.AUTO_EMPTY_LIBRARY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an admin hint with emptyLibraryOnly only with an empty library', async () => {
|
||||
const only = {...DISMISS, emptyLibraryOnly: true};
|
||||
await showAuto([], {summary: {total: 3}}, [only]);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto([], {summary: {total: 0}}, [only]);
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows nothing while a fact it needs is loading', async () => {
|
||||
await showAuto(['PILE_GREW'], {trend: undefined});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: undefined});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows PILE_GREW with the growth since the first day of the month', async () => {
|
||||
await showAuto(['PILE_GREW'], {trend: GREW});
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not ask the link queue below AUDITOR', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: USER});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(fetch.mock.calls.some(([url]) => url.includes('link-queue'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows LINK_QUEUE to an auditor with the number of pairs', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
|
||||
{'GET /api/library/link-queue': json(200, {content: [], totalElements: 3})});
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_LINK_QUEUE:3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no LINK_QUEUE hint when the queue is empty or refused', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
|
||||
{'GET /api/library/link-queue': json(403, {})});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows only the admin hints when Auth sends no automatic list', async () => {
|
||||
hintRoutes(json(200, {results: [DISMISS]}));
|
||||
mount({user: {...USER, steamId: null}});
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pileGrowth', () => {
|
||||
it('counts from the first day of the last month and is 0 without a second day', () => {
|
||||
expect(pileGrowth(GREW)).toBe(2);
|
||||
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-02', backlogGames: 4}]})).toBe(-1);
|
||||
expect(pileGrowth({days: [{day: '2026-10-02', backlogGames: 4}]})).toBe(0);
|
||||
expect(pileGrowth({days: [{day: '2026-10-01', backlogGames: 9}, {day: '2026-10-02', backlogGames: 7}]})).toBe(-2);
|
||||
expect(pileGrowth(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts games added on the first day of the month', () => {
|
||||
// Pile 5 at the end of September, 3 games added on October 1, nothing after.
|
||||
const trend = {days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8},
|
||||
{day: '2026-10-02', backlogGames: 8}]};
|
||||
expect(pileGrowth(trend)).toBe(3);
|
||||
// On October 1 itself: last month's snapshot and today's live value.
|
||||
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8}]})).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hintText', () => {
|
||||
it('takes the German text for a German UI only when there is one', () => {
|
||||
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Megaphone, X} from 'lucide-react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import HintText from './HintText.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
|
||||
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
|
||||
@@ -15,7 +16,9 @@ export default function HintCard({hint, hideForever, onHideForeverChange, onClos
|
||||
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
|
||||
<p className="flex-1 whitespace-pre-line break-words text-sm">{hintText(hint, i18n.language)}</p>
|
||||
<p className="flex-1 whitespace-pre-line break-words text-sm">
|
||||
<HintText text={hint.text ?? hintText(hint, i18n.language)}/>
|
||||
</p>
|
||||
{hint.kind === 'DISMISS' && (
|
||||
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
|
||||
aria-label={t('HINT.CLOSE')} onClick={onClose}>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
// **bold** and [text](url) only. No nesting: the link text is plain.
|
||||
const TOKEN = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
|
||||
// An app path (not //host, and no backslash: browsers read /\host as //host), or https. Case-sensitive on purpose.
|
||||
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/;
|
||||
const HTTPS = /^https:\/\/[^\s\\]+$/;
|
||||
|
||||
/**
|
||||
* @returns {{to: string} | {href: string} | null} null: the url is not safe, so the link stays plain text
|
||||
*/
|
||||
export const safeLink = (url) => {
|
||||
if (APP_PATH.test(url)) return {to: url};
|
||||
if (HTTPS.test(url)) return {href: url};
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hint and news text with **bold** and [text](url). Only React elements, never HTML: React escapes every string.
|
||||
*/
|
||||
export default function HintText({text}) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(TOKEN)) {
|
||||
const [whole, bold, label, url] = match;
|
||||
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||
last = match.index + whole.length;
|
||||
const link = url && safeLink(url);
|
||||
if (bold !== undefined) {
|
||||
parts.push(<strong key={match.index}>{bold}</strong>);
|
||||
} else if (link?.to) {
|
||||
parts.push(<Link key={match.index} to={link.to} className="underline">{label}</Link>);
|
||||
} else if (link) {
|
||||
parts.push(<a key={match.index} href={link.href} target="_blank" rel="noopener noreferrer"
|
||||
className="underline">{label}</a>);
|
||||
} else {
|
||||
parts.push(whole);
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(last));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {cleanup, render} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import HintText, {safeLink} from './HintText.jsx';
|
||||
|
||||
const renderText = (text) => render(<MemoryRouter><p>{<HintText text={text}/>}</p></MemoryRouter>).container;
|
||||
|
||||
const UNSAFE = [
|
||||
'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,<b>x</b>', 'http://example.com', '//evil.example',
|
||||
'/\\evil.example', 'HTTPS://x', 'vbscript:x',
|
||||
];
|
||||
|
||||
describe('HintText', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('renders bold and links', () => {
|
||||
const container = renderText('A **bold** word, [Import](/import) and [site](https://example.com).');
|
||||
|
||||
expect(container.querySelector('strong').textContent).toBe('bold');
|
||||
expect(container.querySelector('a[href="/import"]').textContent).toBe('Import');
|
||||
const external = container.querySelector('a[href="https://example.com"]');
|
||||
expect(external.getAttribute('target')).toBe('_blank');
|
||||
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
expect(container.textContent).toBe('A bold word, Import and site.');
|
||||
});
|
||||
|
||||
it.each(UNSAFE)('shows a link to %s as plain text', (url) => {
|
||||
const text = `[click](${url})`;
|
||||
const container = renderText(text);
|
||||
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
expect(container.textContent).toBe(text);
|
||||
});
|
||||
|
||||
it('shows HTML as text', () => {
|
||||
const container = renderText('<img src=x onerror=alert(1)> <script>alert(1)</script>');
|
||||
|
||||
expect(container.querySelector('img, script')).toBeNull();
|
||||
expect(container.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('keeps unclosed markup and line breaks as they are', () => {
|
||||
const container = renderText('**bold\n[text](');
|
||||
|
||||
expect(container.querySelector('strong, a')).toBeNull();
|
||||
expect(container.textContent).toBe('**bold\n[text](');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeLink', () => {
|
||||
it('allows app paths and https only', () => {
|
||||
expect(safeLink('/import')).toEqual({to: '/import'});
|
||||
expect(safeLink('https://example.com/a')).toEqual({href: 'https://example.com/a'});
|
||||
UNSAFE.forEach((url) => expect(safeLink(url)).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import HintText from './HintText.jsx';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {newsText, newsTitle} from '../utils/news.js';
|
||||
|
||||
@@ -11,7 +12,7 @@ export default function NewsEntry({entry, className}) {
|
||||
<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>
|
||||
<p className="whitespace-pre-line break-words text-sm"><HintText text={newsText(entry, i18n.language)}/></p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import HintCard from '../components/HintCard.jsx';
|
||||
import {formatDateTime} from '../utils/format.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||
import {createHint, endHint, fetchAdminHints, setAutoHintEnabled} from '../utils/hints.js';
|
||||
import {ROLES} from '../utils/admin.js';
|
||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -25,7 +26,18 @@ const toLocalInput = (date) => {
|
||||
|
||||
const emptyForm = () => {
|
||||
const now = new Date();
|
||||
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS))};
|
||||
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS)),
|
||||
targetNoSteam: false, targetRole: '', targetNewDays: '', targetEmptyLibrary: false};
|
||||
};
|
||||
|
||||
const targetSummary = (hint, t) => {
|
||||
const groups = [
|
||||
hint.targetNoSteam && t('ADMIN_HINTS.TARGET_SUMMARY_NO_STEAM'),
|
||||
hint.targetRole && t('ADMIN_HINTS.TARGET_SUMMARY_ROLE', {role: t(`ROLES.${hint.targetRole}`)}),
|
||||
hint.targetNewDays && t('ADMIN_HINTS.TARGET_SUMMARY_NEW', {count: hint.targetNewDays}),
|
||||
hint.targetEmptyLibrary && t('ADMIN_HINTS.TARGET_SUMMARY_EMPTY_LIBRARY'),
|
||||
].filter(Boolean);
|
||||
return groups.length ? groups.join(' + ') : t('ADMIN_HINTS.TARGET_ALL');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,6 +46,7 @@ const emptyForm = () => {
|
||||
export default function AdminHintsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [automatic, setAutomatic] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -50,7 +63,9 @@ export default function AdminHintsPage() {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setHints((await response.json()).results);
|
||||
const data = await response.json();
|
||||
setHints(data.results);
|
||||
setAutomatic(data.automatic ?? []);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
console.error('Admin hints error:', err);
|
||||
@@ -91,10 +106,15 @@ export default function AdminHintsPage() {
|
||||
kind: form.kind,
|
||||
startsAt: new Date(form.startsAt).toISOString(),
|
||||
endsAt: new Date(form.endsAt).toISOString(),
|
||||
targetNoSteam: form.targetNoSteam,
|
||||
targetRole: form.targetRole || null,
|
||||
targetNewDays: form.targetNewDays ? Number(form.targetNewDays) : null,
|
||||
targetEmptyLibrary: form.targetEmptyLibrary,
|
||||
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
|
||||
};
|
||||
|
||||
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
|
||||
const tick = (field) => (event) => setForm({...form, [field]: event.target.checked});
|
||||
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
|
||||
|
||||
if (status === 'forbidden') {
|
||||
@@ -132,6 +152,35 @@ export default function AdminHintsPage() {
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">{t('ADMIN_HINTS.TARGET')}</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={form.targetNoSteam}
|
||||
onChange={tick('targetNoSteam')}/>
|
||||
{t('ADMIN_HINTS.TARGET_NO_STEAM')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={form.targetEmptyLibrary}
|
||||
onChange={tick('targetEmptyLibrary')}/>
|
||||
{t('ADMIN_HINTS.TARGET_EMPTY_LIBRARY')}
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTargetRole">{t('ADMIN_HINTS.TARGET_ROLE')}</Label>
|
||||
<select id="hintTargetRole" value={form.targetRole} onChange={change('targetRole')}
|
||||
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm">
|
||||
<option value="">{t('ADMIN_HINTS.TARGET_ROLE_ALL')}</option>
|
||||
{ROLES.map((role) => <option key={role} value={role}>{t(`ROLES.${role}`)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTargetNewDays">{t('ADMIN_HINTS.TARGET_NEW_DAYS')}</Label>
|
||||
<Input id="hintTargetNewDays" type="number" min={1} max={365} value={form.targetNewDays}
|
||||
onChange={change('targetNewDays')}/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
|
||||
</fieldset>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
|
||||
@@ -157,6 +206,22 @@ export default function AdminHintsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.AUTO_TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{automatic.map(({key, enabled}) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={enabled} disabled={busy}
|
||||
onChange={(event) => run(() => setAutoHintEnabled(key, event.target.checked),
|
||||
'ADMIN_HINTS.AUTO_SAVED')}/>
|
||||
{t(`ADMIN_HINTS.AUTO_${key}`)}
|
||||
</label>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle>
|
||||
@@ -176,6 +241,7 @@ export default function AdminHintsPage() {
|
||||
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
|
||||
t(`ADMIN_HINTS.KIND_${hint.kind}`),
|
||||
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
|
||||
targetSummary(hint, t),
|
||||
].join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,11 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
|
||||
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
|
||||
|
||||
const withRoutes = (routes) => fetchRoutes({
|
||||
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}),
|
||||
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED],
|
||||
automatic: [{key: 'NO_STEAM', enabled: true}, {key: 'PILE_GREW', enabled: true}]}),
|
||||
'POST /api/admin/hints': json(201, {id: 3}),
|
||||
'PUT /api/admin/hints/2/end': json(204),
|
||||
'PUT /api/admin/hints/auto/PILE_GREW': json(204),
|
||||
...routes,
|
||||
});
|
||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
||||
@@ -69,6 +71,53 @@ describe('AdminHintsPage', () => {
|
||||
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
|
||||
});
|
||||
|
||||
it('sends the target groups, empty ones as null', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
|
||||
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.TARGET_NO_STEAM'));
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_NEW_DAYS'), {target: {value: '7'}});
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_ROLE'), {target: {value: 'AUDITOR'}});
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
await waitFor(() => expect(calls('POST')).toHaveLength(1));
|
||||
expect(JSON.parse(calls('POST')[0][1].body)).toMatchObject({
|
||||
targetNoSteam: true, targetRole: 'AUDITOR', targetNewDays: 7, targetEmptyLibrary: false});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'More news'}});
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
await waitFor(() => expect(calls('POST')).toHaveLength(2));
|
||||
expect(JSON.parse(calls('POST')[1][1].body)).toMatchObject({
|
||||
targetNoSteam: false, targetRole: null, targetNewDays: null, targetEmptyLibrary: false});
|
||||
});
|
||||
|
||||
it('shows the role labels for the role target', async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByRole('option', {name: 'ROLES.AUDITOR'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns an automatic hint off', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.AUTO_PILE_GREW'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.AUTO_SAVED'));
|
||||
const [url, init] = calls('PUT')[0];
|
||||
expect(url).toContain('/api/admin/hints/auto/PILE_GREW');
|
||||
expect(JSON.parse(init.body)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('previews bold text', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Read **this**'}});
|
||||
|
||||
expect(document.querySelector('[role="status"] strong').textContent).toBe('this');
|
||||
});
|
||||
|
||||
it('explains an invalid time range', async () => {
|
||||
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
|
||||
renderPage();
|
||||
|
||||
+7
-10
@@ -41,14 +41,10 @@ const Dashboard = () => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
|
||||
const statGroups = [...new Set([
|
||||
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
|
||||
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
|
||||
// saved layout in /me.
|
||||
const statGroups = [...new Set([...layout.map((tile) => statGroup(tile.id)).filter(Boolean), 'TREND'])].sort();
|
||||
const stats = useUserStats(statGroups, userData != null);
|
||||
|
||||
const shuffleSuggestion = async () => {
|
||||
setShuffling(true);
|
||||
@@ -70,7 +66,7 @@ const Dashboard = () => {
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: () => {
|
||||
reloadLibrary();
|
||||
if (hasStatGroups) stats.reload();
|
||||
stats.reload();
|
||||
},
|
||||
});
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
@@ -135,7 +131,8 @@ const Dashboard = () => {
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<WhatsNewDialog/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,8 +92,8 @@ describe('Dashboard', () => {
|
||||
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
|
||||
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 3')).toBeNull();
|
||||
// No pile tile, so no TREND.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
|
||||
// TREND is always asked: the "pile grew" hint needs it.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
|
||||
+41
-2
@@ -3,8 +3,9 @@ import {apiFetch} from './api.js';
|
||||
const AUTH = () => import.meta.env.VITE_API_URL;
|
||||
|
||||
/**
|
||||
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt}]}, the running hints the user has not
|
||||
* closed for good, newest first.
|
||||
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt, emptyLibraryOnly}], automatic: [keys]}.
|
||||
* results: the running hints for this user that are not closed for good, newest first (emptyLibraryOnly: show only
|
||||
* with an empty library, checked here). automatic: the automatic hints that are on and not hidden for this user.
|
||||
*/
|
||||
export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`);
|
||||
|
||||
@@ -31,3 +32,41 @@ export const createHint = (hint) =>
|
||||
export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'});
|
||||
|
||||
export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn);
|
||||
|
||||
/**
|
||||
* The automatic hints in the order they show.
|
||||
*/
|
||||
export const AUTO_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY', 'LINK_QUEUE', 'PILE_GREW'];
|
||||
|
||||
/**
|
||||
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for 7 days.
|
||||
*/
|
||||
export const snoozeAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/snooze`, {method: 'PUT'});
|
||||
|
||||
/**
|
||||
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for good.
|
||||
*/
|
||||
export const hideAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/done`, {method: 'PUT'});
|
||||
|
||||
/**
|
||||
* PLATFORM_ADMIN only: 204, or 404 HINT_NOT_FOUND.
|
||||
*/
|
||||
export const setAutoHintEnabled = (key, enabled) =>
|
||||
apiFetch(`${AUTH()}/api/admin/hints/auto/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({enabled}),
|
||||
});
|
||||
|
||||
/**
|
||||
* How much the pile grew since the end of last month (else since the first day of this month), from the TREND group of the stats. Uses Core's
|
||||
* dates, not the browser clock.
|
||||
*/
|
||||
export const pileGrowth = (trend) => {
|
||||
const days = trend?.days ?? [];
|
||||
const last = days.at(-1);
|
||||
const month = last?.day.slice(0, 7);
|
||||
// The snapshot of a day is written at its end, so only last month's last row excludes the first day's additions.
|
||||
const first = days.findLast((d) => d.day.slice(0, 7) < month) ?? days.find((d) => d.day.slice(0, 7) === month);
|
||||
return last && first !== last ? last.backlogGames - first.backlogGames : 0;
|
||||
};
|
||||
Reference in new issue
Block a user