fix(usage): scope the participation notice, highlight it, and call ignoring what it is

It appears on the dashboard and settings only. It is an invitation, not
an alert, so it belongs on pages an admin opens deliberately rather than
on top of whatever task they are in the middle of.

The activity ticker deliberately did NOT move with it. That ticker is
what triggers the daily rollup — the backend has no scheduler — so
tying it to the banner would have stopped reporting for an admin who
works on Events and never opens the dashboard, and stopped it entirely
for a participating install, where the banner never renders at all. The
effect stays mounted on every admin page and only the visible aside is
scoped. Two tests pin exactly that, because it is the kind of thing a
later refactor would helpfully "clean up".

Highlighted like the migration banner it sits under: tinted surface,
border, icon, a title line above the body. It was previously the same
neutral surface as the page behind it and read as filler.

"Not now" is now "Ignore". The button calls dismiss(), which persists
notice_dismissed on the server — the invitation never comes back. "Not
now" promised otherwise. The label says what happens and a hint says
where to join later.

Only shown while participation is off. activation_pending,
deletion_pending and identity_conflict are in-flight states the settings
page explains properly; inviting someone to join in the middle of their
own withdrawal would be worse than saying nothing.

The first version of these tests was worthless: the negative cases
asserted absence after waiting only for the status call, so the
component was still rendering null for want of data and every one passed
with the gates removed. They now wait for the query cache to fill.
Removing the route gate fails 4; removing the status gate fails 6.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 21:42:21 +02:00
parent 83fbb63e13
commit 4944b9b3b6
4 changed files with 194 additions and 27 deletions
@@ -1,21 +1,34 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { usePermissions } from '../../contexts/PermissionsContext';
import { productUsageService } from '../../services/productUsage.service';
// Where the invitation is allowed to appear. It is an invitation, not an
// alert, so it belongs on the pages an admin visits deliberately rather than
// on top of whatever task they are in the middle of.
const NOTICE_PATHS = ['/admin/dashboard', '/admin/settings'];
// Loaded only inside the authenticated admin tree. Gallery routes never import
// this chunk, make usage requests, or record product usage markers.
export default function ProductUsageNotice() {
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const queryClient = useQueryClient();
const { pathname } = useLocation();
const { data } = useQuery({
queryKey: ['productUsage'],
queryFn: productUsageService.status,
enabled: hasPermission('settings.edit')
});
// Deliberately above every visibility test, and deliberately NOT limited to
// the pages the banner is shown on. This ticker is what triggers the daily
// rollup — the backend has no scheduler — so tying it to the banner would
// mean an admin who works on Events and never opens the dashboard stops
// reporting altogether, and a participating install (where the banner never
// renders at all) would never report again.
useEffect(() => {
let running = false;
const tick = async () => {
@@ -37,19 +50,36 @@ export default function ProductUsageNotice() {
document.removeEventListener('visibilitychange', tick);
};
}, []);
if (!hasPermission('settings.edit') || !data || data.status !== 'disabled' || data.notice_dismissed) return null;
if (!hasPermission('settings.edit') || !data) return null;
// Only while participation is off. `activation_pending`, `deletion_pending`
// and `identity_conflict` are all in-flight states the settings page
// explains properly; inviting someone to join in the middle of their own
// withdrawal would be worse than saying nothing.
if (data.status !== 'disabled' || data.notice_dismissed) return null;
if (!NOTICE_PATHS.some((path) => pathname.startsWith(path))) return null;
return (
<aside
className="mx-6 mt-4 rounded-lg border border-theme p-4 text-theme bg-theme-surface"
className="mx-6 mt-4 rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-4"
aria-label={t('productUsage.title')}
>
<p>{t('productUsage.notice')}</p>
<div className="mt-2 flex flex-wrap gap-4">
<Link className="underline" to="/admin/settings?tab=usage">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 flex-shrink-0 mt-0.5 text-primary-600 dark:text-primary-300" />
<div className="min-w-0 text-sm text-primary-900 dark:text-primary-100">
<p className="font-medium">{t('productUsage.noticeTitle')}</p>
<p className="mt-0.5 text-primary-800 dark:text-primary-200">
{t('productUsage.notice')}
</p>
<div className="mt-2 flex flex-wrap items-center gap-4">
<Link
className="font-medium underline hover:no-underline"
to="/admin/settings?tab=usage"
>
{t('productUsage.review')}
</Link>
<button
className="underline"
className="underline hover:no-underline"
onClick={async () => {
try {
queryClient.setQueryData(
@@ -61,8 +91,17 @@ export default function ProductUsageNotice() {
}
}}
>
{t('productUsage.later')}
{t('productUsage.ignore')}
</button>
{/* Dismissing is permanent — it sets notice_dismissed on the
server, not a session flag — so the label says "Ignore" and
this line says where to find it again. "Not now" implied the
invitation would come back, and it never does. */}
<span className="text-primary-700 dark:text-primary-300">
{t('productUsage.ignoreHint')}
</span>
</div>
</div>
</div>
</aside>
);
@@ -0,0 +1,124 @@
/**
* The participation invitation (#1110).
*
* Two properties matter here and are easy to break by accident:
*
* - it is an INVITATION, so it appears only where an admin goes
* deliberately, and only while participation is actually off;
* - the activity ticker inside it is what triggers the daily rollup — the
* backend has no scheduler — so it must keep running on every admin page,
* including the ones where the banner is not rendered and the case where
* the install is already participating and the banner never renders at all.
*/
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import ProductUsageNotice from '../ProductUsageNotice';
import { productUsageService as service } from '../../../services/productUsage.service';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: '3rdParty', init: () => {} }
}));
vi.mock('../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({ hasPermission: () => true })
}));
vi.mock('../../../services/productUsage.service', () => ({
productUsageService: { status: vi.fn(), activity: vi.fn(), dismiss: vi.fn() }
}));
const status = (over = {}) => ({
status: 'disabled',
notice_dismissed: false,
installation_id: null,
collector_url: 'https://collector.example',
schema_version: 'usage.v1',
last_report_date: null,
last_error: null,
pending_action: null,
last_packet: null,
feedback_preferences: { name: '' },
...over
});
function renderAt(path: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[path]}>
<ProductUsageNotice />
</MemoryRouter>
</QueryClientProvider>
);
// Absence only means something once the status has actually landed.
// Waiting on the service being *called* proved nothing: the component
// returns null while `data` is undefined, so every negative assertion
// passed even with the gate removed.
return {
settled: () =>
waitFor(() => expect(client.getQueryData(['productUsage'])).toBeDefined())
};
}
beforeEach(() => {
vi.mocked(service.status).mockResolvedValue(status() as never);
vi.mocked(service.activity).mockResolvedValue(undefined as never);
});
afterEach(() => { cleanup(); vi.clearAllMocks(); });
describe('product usage notice', () => {
it.each(['/admin/dashboard', '/admin/settings', '/admin/settings?tab=usage'])(
'invites participation on %s',
async (path) => {
renderAt(path);
expect(await screen.findByText('productUsage.noticeTitle')).toBeInTheDocument();
// The dismissal is permanent, so the label must not promise a return.
expect(screen.getByText('productUsage.ignore')).toBeInTheDocument();
expect(screen.getByText('productUsage.ignoreHint')).toBeInTheDocument();
}
);
it.each(['/admin/events', '/admin/archives', '/admin/users'])(
'stays out of the way on %s',
async (path) => {
const { settled } = renderAt(path);
await settled();
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
}
);
it.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
'does not invite while participation is %s',
async (state) => {
vi.mocked(service.status).mockResolvedValue(status({ status: state }) as never);
const { settled } = renderAt('/admin/dashboard');
await settled();
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
}
);
it('does not invite again once ignored', async () => {
vi.mocked(service.status).mockResolvedValue(status({ notice_dismissed: true }) as never);
const { settled } = renderAt('/admin/dashboard');
await settled();
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
});
it('still reports activity on a page where the banner is hidden', async () => {
// The rollup must not depend on which page the admin happens to be on.
const { settled } = renderAt('/admin/events');
await waitFor(() => expect(service.activity).toHaveBeenCalled());
await settled();
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
});
it('still reports activity for an install that is already participating', async () => {
// The banner never renders in this state; reporting must continue anyway.
vi.mocked(service.status).mockResolvedValue(status({ status: 'active' }) as never);
const { settled } = renderAt('/admin/dashboard');
await waitFor(() => expect(service.activity).toHaveBeenCalled());
await settled();
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
});
});
+4 -2
View File
@@ -1,9 +1,11 @@
{
"productUsage": {
"title": "Produktnutzung & Feedback",
"notice": "Hilf mit, PicPeak weiterzuentwickeln. Freiwillige Nutzungsberichte zeigen, welche Funktionen der Community wichtig sind. Berichte bleiben aus, bis du ausdrücklich teilnimmst.",
"noticeTitle": "Gestalten Sie PicPeak mit",
"notice": "Optionale Nutzungsberichte zeigen, welche Funktionen für die Community wichtig sind. Die Übermittlung ist aus, bis Sie sich aktiv dafür entscheiden.",
"ignore": "Ignorieren",
"ignoreHint": "Dieser Hinweis erscheint nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
"review": "Teilnahme prüfen",
"later": "Nicht jetzt",
"cancel": "Abbrechen",
"loading": "Teilnahmeeinstellungen werden geladen…",
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
+4 -2
View File
@@ -1,9 +1,11 @@
{
"productUsage": {
"title": "Product usage & feedback",
"notice": "Help shape PicPeak. Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
"noticeTitle": "Help shape PicPeak",
"notice": "Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
"ignore": "Ignore",
"ignoreHint": "This notice won't appear again — you can still join from Settings → Product usage.",
"review": "Review participation",
"later": "Not now",
"cancel": "Cancel",
"loading": "Loading participation settings…",
"failed": "The operation could not be completed. Check the status and try again.",