feat: add opt-in product usage and feedback integration (#1110)

This commit is contained in:
Paul Nothaft
2026-09-05 12:59:06 +02:00
parent c71ffae912
commit b53e5d97b4
24 changed files with 2360 additions and 18 deletions
@@ -0,0 +1,153 @@
import {
render,
screen,
fireEvent,
waitFor,
cleanup
} from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import ProductUsageTab from '../tabs/ProductUsageTab';
import {
productUsageService as service,
type UsageStatus
} from '../../../services/productUsage.service';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}));
vi.mock('../../../components/common/ConfirmDialog', () => ({
useConfirm: () => async () => true
}));
vi.mock('../../../services/productUsage.service', () => ({
productUsageService: {
status: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
retry: vi.fn(),
preview: vi.fn(),
export: vi.fn(),
preferences: vi.fn(),
feedback: vi.fn(),
portalSession: vi.fn()
}
}));
const status: UsageStatus = {
status: 'disabled',
notice_dismissed: false,
installation_id: null,
collector_url: 'https://usage.picpeak.app',
schema_version: 'usage.v1',
last_report_date: null,
last_error: null,
pending_action: null,
last_packet: null,
feedback_preferences: { name: 'Remembered private name' }
};
const mount = () =>
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ProductUsageTab />
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(service.status).mockResolvedValue({ ...status });
HTMLDialogElement.prototype.showModal = function () {
this.setAttribute('open', '');
};
});
afterEach(cleanup);
describe('product usage controls', () => {
it('requires the disclosure and an unchecked-by-default consent before enabling', async () => {
mount();
fireEvent.click(await screen.findByText('productUsage.review'));
const enable = screen.getByRole('button', { name: 'productUsage.enable' });
expect(enable).toBeDisabled();
for (const key of [
'fields',
'excluded',
'transport',
'visibility',
'deletion',
'feedbackDisclosure'
])
expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument();
expect(service.enable).not.toHaveBeenCalled();
expect(service.preview).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText('productUsage.consentCheck'));
fireEvent.click(enable);
await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1));
});
it('sends anonymous private feedback even when a remembered name exists', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active',
installation_id: 'a'.repeat(64)
});
vi.mocked(service.feedback).mockResolvedValue({
delivered: true,
state: { ...status, status: 'active' }
});
mount();
await screen.findByText('productUsage.feedbackTitle');
expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('productUsage.subject'), {
target: { value: 'Feedback title' }
});
fireEvent.change(screen.getByLabelText('productUsage.message'), {
target: { value: 'Useful details' }
});
fireEvent.click(
screen.getByRole('button', { name: 'productUsage.sendFeedback' })
);
await waitFor(() =>
expect(service.feedback).toHaveBeenCalledWith({
kind: 'feedback',
title: 'Feedback title',
body: 'Useful details',
name: '',
allow_public: false,
allow_marketing: false
})
);
});
it('requires a separate marketing choice and clears it when publication permission is removed', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active'
});
mount();
fireEvent.change(await screen.findByLabelText('productUsage.kind'), {
target: { value: 'testimonial' }
});
expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked();
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled();
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
fireEvent.click(screen.getByLabelText('productUsage.allowMarketing'));
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked();
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
expect(
screen.getByLabelText('productUsage.allowMarketing')
).not.toBeChecked();
});
it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'deletion_pending',
last_error: 'DELIVERY_FAILED'
});
mount();
await screen.findByText('productUsage.states.deletion_pending');
expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument();
expect(
screen.queryByText('productUsage.feedbackTitle')
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' }));
await waitFor(() => expect(service.retry).toHaveBeenCalled());
});
});
@@ -0,0 +1,428 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
productUsageService as service,
type ProductFeedback
} from '../../../services/productUsage.service';
import { useConfirm } from '../../../components/common/ConfirmDialog';
import { Button } from '../../../components/common/Button';
function ConsentDialog({
close,
enable,
busy,
collector
}: {
close: () => void;
enable: () => void;
busy: boolean;
collector: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDialogElement>(null);
const [checked, setChecked] = useState(false);
useEffect(() => {
ref.current?.showModal();
}, []);
return (
<dialog
ref={ref}
onCancel={close}
aria-labelledby="usage-consent-title"
className="w-full max-w-2xl rounded-xl p-6 text-theme bg-theme-surface backdrop:bg-black/50"
>
<h2 id="usage-consent-title" className="text-xl font-semibold">
{t('productUsage.consentTitle')}
</h2>
<div className="my-4 max-h-[55vh] overflow-y-auto space-y-3">
{[
'purpose',
'fields',
'excluded',
'transport',
'visibility',
'deletion',
'feedbackDisclosure'
].map((key) => (
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
))}
</div>
<label className="flex items-start gap-2 mb-4">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{t('productUsage.consentCheck')}
</label>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={close} disabled={busy}>
{t('productUsage.cancel')}
</Button>
<Button onClick={enable} disabled={!checked || busy}>
{t('productUsage.enable')}
</Button>
</div>
</dialog>
);
}
export default function ProductUsageTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const confirm = useConfirm();
const { data, isPending, isError } = useQuery({
queryKey: ['productUsage'],
queryFn: service.status,
refetchInterval: 30000
});
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<unknown>(null);
const [portalUrl, setPortalUrl] = useState<string | null>(null);
const [named, setNamed] = useState(false);
const [form, setForm] = useState<ProductFeedback>({
kind: 'feedback',
title: '',
body: '',
name: '',
allow_public: false,
allow_marketing: false
});
const run = async (fn: () => Promise<void>) => {
setBusy(true);
setMessage('');
try {
await fn();
} catch {
setMessage(t('productUsage.failed'));
} finally {
setBusy(false);
await queryClient.invalidateQueries({ queryKey: ['productUsage'] });
}
};
const download = (value: unknown) => {
const url = URL.createObjectURL(
new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })
);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'picpeak-usage-packets.json';
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
if (isPending) return <p>{t('productUsage.loading')}</p>;
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
const active = data.status === 'active';
return (
<div className="space-y-6 text-theme">
<p>{t('productUsage.purpose')}</p>
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
<h3 className="text-lg font-semibold">
{t(`productUsage.states.${data.status}`)}
</h3>
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
{data.installation_id && (
<label className="block">
{t('productUsage.hash')}
<input
className="mt-1 w-full rounded border border-theme bg-theme-surface p-2 font-mono text-sm"
readOnly
value={data.installation_id}
/>
</label>
)}
{data.last_report_date && (
<p>{t('productUsage.lastReport', { date: data.last_report_date })}</p>
)}
{data.last_error && (
<p role="status">{t('productUsage.deliveryProblem')}</p>
)}
<div className="flex flex-wrap gap-3">
{data.status === 'disabled' ? (
<Button disabled={busy} onClick={() => setConsent(true)}>
{t('productUsage.review')}
</Button>
) : (
<>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => {
await service.retry();
})
}
>
{t('productUsage.retry')}
</Button>
<Button
variant="outline"
disabled={busy || data.status === 'deletion_pending'}
onClick={async () => {
if (
await confirm({
title: t('productUsage.disable'),
message: t('productUsage.deletion'),
confirmLabel: t('productUsage.disable'),
variant: 'danger'
})
) {
await run(async () => {
await service.disable();
setPreview(null);
setPortalUrl(null);
});
}
}}
>
{t('productUsage.disable')}
</Button>
</>
)}
<a
className="underline self-center"
href={`${data.collector_url}/transparency`}
target="_blank"
rel="noreferrer"
>
{t('productUsage.transparency')}
</a>
</div>
</section>
{active && (
<>
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
<h3 className="text-lg font-semibold">
{t('productUsage.inspect')}
</h3>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => setPreview(await service.preview()))
}
>
{t('productUsage.preview')}
</Button>
<Button
variant="outline"
disabled={busy || !data.last_packet}
onClick={() => setPreview(data.last_packet)}
>
{t('productUsage.lastPacket')}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => download(await service.export()))
}
>
{t('productUsage.export')}
</Button>
<Button
variant="outline"
disabled={busy || Boolean(data.pending_action)}
onClick={() =>
run(async () => {
const result = await service.portalSession();
setPortalUrl(result.url);
if (!result.delivered) setMessage(t('productUsage.queued'));
})
}
>
{t('productUsage.connect')}
</Button>
</div>
{portalUrl && (
<a
href={portalUrl}
target="_blank"
rel="noreferrer"
className="underline"
>
{t('productUsage.openPortal')}
</a>
)}
{preview !== null && (
<pre
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
aria-label={t('productUsage.preview')}
>
{JSON.stringify(preview, null, 2)}
</pre>
)}
</section>
<form
className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const result = await service.feedback({
...form,
name: named ? form.name : ''
});
setMessage(
t(
result.delivered
? 'productUsage.feedbackSent'
: result.queued
? 'productUsage.queued'
: 'productUsage.failed'
)
);
setForm({
...form,
title: '',
body: '',
allow_public: false,
allow_marketing: false
});
});
}}
>
<h3 className="text-lg font-semibold">
{t('productUsage.feedbackTitle')}
</h3>
<p>{t('productUsage.feedbackDisclosure')}</p>
<label className="block">
{t('productUsage.kind')}
<select
aria-label={t('productUsage.kind')}
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
value={form.kind}
onChange={(e) =>
setForm({
...form,
kind: e.target.value as ProductFeedback['kind'],
allow_public: false,
allow_marketing: false
})
}
>
{['feedback', 'feature_request', 'testimonial'].map((kind) => (
<option key={kind} value={kind}>
{t(`productUsage.kinds.${kind}`)}
</option>
))}
</select>
</label>
<label className="block">
{t('productUsage.subject')}
<input
required
maxLength={120}
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
</label>
<label className="block">
{t('productUsage.message')}
<textarea
required
maxLength={4000}
rows={5}
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={named}
onChange={(e) => {
setNamed(e.target.checked);
if (e.target.checked && !form.name)
setForm({ ...form, name: data.feedback_preferences.name });
}}
/>
{t('productUsage.includeName')}
</label>
{named && (
<div className="space-y-2">
<label className="block">
{t('productUsage.name')}
<input
required
maxLength={80}
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() =>
run(async () => {
await service.preferences(form.name);
setMessage(t('productUsage.saved'));
})
}
>
{t('productUsage.saveName')}
</Button>
</div>
)}
{form.kind !== 'feedback' && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.allow_public}
onChange={(e) =>
setForm({
...form,
allow_public: e.target.checked,
allow_marketing: false
})
}
/>
{t('productUsage.allowPublic')}
</label>
)}
{form.kind === 'testimonial' && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.allow_marketing}
disabled={!form.allow_public}
onChange={(e) =>
setForm({ ...form, allow_marketing: e.target.checked })
}
/>
{t('productUsage.allowMarketing')}
</label>
)}
<Button
type="submit"
disabled={busy || Boolean(data.pending_action)}
>
{t('productUsage.sendFeedback')}
</Button>
</form>
</>
)}
{message && <p role="status">{message}</p>}
{consent && (
<ConsentDialog
collector={data.collector_url}
busy={busy}
close={() => setConsent(false)}
enable={() =>
run(async () => {
await service.enable();
setConsent(false);
})
}
/>
)}
</div>
);
}