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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user