Public Access
feat(dashboard): show admin hints and add the hints admin page
This commit is contained in:
1 parent
6601277961
commit
01ae372b2d
12 files changed
+700
-6
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -112,9 +112,14 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !viewingAs && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuSub>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import HintCard from './HintCard.jsx';
|
||||
import {fetchHints, markHintDone} from '../utils/hints.js';
|
||||
|
||||
export const AUTO_CLOSE_MS = 30_000;
|
||||
const CLOSED_KEY = 'tbp_closed_hints';
|
||||
|
||||
// Hints closed in this tab, so going back to the dashboard does not show them again.
|
||||
const closedIds = () => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(CLOSED_KEY)) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const rememberClosed = (hintId) => {
|
||||
try {
|
||||
sessionStorage.setItem(CLOSED_KEY, JSON.stringify([...closedIds(), hintId]));
|
||||
} catch {
|
||||
// Storage blocked: the hint shows again on the next dashboard visit.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function DashboardHint() {
|
||||
const {t} = useTranslation();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [hideForever, setHideForever] = useState(false);
|
||||
const hint = hints[0];
|
||||
|
||||
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)));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// A failed save only shows an error: the hint comes back in the next browser session.
|
||||
const close = useCallback(async (done) => {
|
||||
rememberClosed(hint.id);
|
||||
setHints((current) => current.slice(1));
|
||||
setHideForever(false);
|
||||
if (!done) return;
|
||||
try {
|
||||
const response = await markHintDone(hint.id);
|
||||
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
|
||||
} catch {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
}, [hint, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hint?.kind !== 'DISMISS') return undefined;
|
||||
const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hint, hideForever, close]);
|
||||
|
||||
if (!hint) return null;
|
||||
|
||||
// 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)}
|
||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
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 answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
|
||||
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
||||
|
||||
const showHints = async (...hints) => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, {results: hints})));
|
||||
render(<DashboardHint/>);
|
||||
await act(async () => undefined);
|
||||
};
|
||||
|
||||
describe('DashboardHint', () => {
|
||||
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows one hint at a time and the next after closing', async () => {
|
||||
await showHints(DISMISS, ACCEPT);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
expect(screen.queryByText(ACCEPT.textEn)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
|
||||
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
|
||||
expect(doneCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not show a hint closed in this tab again when the dashboard mounts again', async () => {
|
||||
await showHints(DISMISS, ACCEPT);
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
cleanup();
|
||||
|
||||
render(<DashboardHint/>);
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
|
||||
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
|
||||
expect(doneCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores a dismiss hint only when "Do not show again" is ticked', async () => {
|
||||
await showHints(DISMISS);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/2/done')]);
|
||||
});
|
||||
|
||||
it('closes a dismiss hint by itself after 30 s without storing it', async () => {
|
||||
await showHints(DISMISS);
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an accept hint until OK and stores it', async () => {
|
||||
await showHints(ACCEPT);
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2));
|
||||
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
|
||||
expect(screen.queryByLabelText('HINT.CLOSE')).toBeNull();
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]);
|
||||
});
|
||||
|
||||
it('shows an error when the answer is not stored', async () => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(500) : answer(200, {results: [ACCEPT]})));
|
||||
render(<DashboardHint/>);
|
||||
await act(async () => undefined);
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('HINT.SAVE_FAILED');
|
||||
});
|
||||
|
||||
it('does not let a double click on OK accept the next hint unseen', async () => {
|
||||
await showHints(ACCEPT, {...ACCEPT, id: 3, textEn: 'Rate your games'});
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 1}));
|
||||
// The second click of the same double click lands on the next card's OK button.
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 2}));
|
||||
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]);
|
||||
expect(screen.getByText('Rate your games')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows nothing when the hints do not load', async () => {
|
||||
globalThis.fetch = vi.fn(async () => answer(500));
|
||||
render(<DashboardHint/>);
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hintText', () => {
|
||||
it('takes the German text for a German UI only when there is one', () => {
|
||||
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
|
||||
expect(hintText(DISMISS, 'en')).toBe(DISMISS.textEn);
|
||||
expect(hintText(ACCEPT, 'de')).toBe(ACCEPT.textEn);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useId} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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 '../utils/hints.js';
|
||||
|
||||
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const checkboxId = useId();
|
||||
|
||||
return (
|
||||
<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>
|
||||
{hint.kind === 'DISMISS' && (
|
||||
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
|
||||
aria-label={t('HINT.CLOSE')} onClick={onClose}>
|
||||
<X/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{hint.kind === 'DISMISS' ? (
|
||||
<label htmlFor={checkboxId} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input id={checkboxId} type="checkbox" className="accent-primary" checked={hideForever}
|
||||
onChange={(event) => onHideForeverChange(event.target.checked)}/>
|
||||
{t('HINT.DO_NOT_SHOW_AGAIN')}
|
||||
</label>
|
||||
) : (
|
||||
<Button type="button" size="sm" onClick={onAccept}>{t('HINT.OK')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user