feat(site-builder): add in-builder Report an Issue modal

Wires the Task 18 payload builder and Task 16/17 diagnostics into a
user-facing modal, reachable from the topbar bug icon (desktop) and
overflow menu (mobile). Two gaps in the task brief's draft, not called
out there, are handled explicitly: buildReportPayload() can throw when
the payload is still oversized after canvas_state is dropped, so
submission is wrapped in try/catch with an actionable "too large" error
that preserves the user's typed text; and the textarea maxLength is
sourced from MAX_DESCRIPTION_CHARS (with a proximity character count)
instead of a hardcoded number, so the UI limit can't drift from the
payload limit.

Also portals the modal to document.body for the same stacking-context
reasons TemplateModal/HeadCodeModal already do, and guards the
useEditor() selection collector with optional chaining so it degrades
gracefully under TopBar's existing test harness (a minimal @craftjs/core
stub with no events/nodes on its collector state).

The textarea uses onInput rather than onChange: React's onChange dedup
(via its DOM value-tracker) treats a test harness's raw `el.value = x`
assignment as a no-op change, matching this repo's existing pattern
(HeadCodeModal.test.tsx, shared-controls.test.tsx, etc. all work around
the same gotcha) -- onInput is a plain passthrough with no such check,
and is behaviorally identical for real typing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:29:26 -07:00
co-authored by Claude Opus 5
parent f43a1ef872
commit d7eeff3a68
4 changed files with 504 additions and 0 deletions
@@ -0,0 +1,308 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useEditor } from '@craftjs/core';
import { Modal } from '../../ui/Modal';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { usePages } from '../../state/PageContext';
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
import { getRecentConsoleErrors } from '../../utils/console-buffer';
import { editorBuild } from '../../utils/build-stamp';
export interface ReportIssueModalProps {
open: boolean;
onClose: () => void;
device: string;
}
const CATEGORIES: { value: ReportCategory; label: string }[] = [
{ value: 'bug', label: 'Something is broken' },
{ value: 'confusing', label: 'Something is confusing' },
{ value: 'feature', label: 'I wish it could…' },
];
/** Only start showing the running character count once it's actually useful
* -- i.e. once the user is close enough to MAX_DESCRIPTION_CHARS that
* losing text is a real possibility, not on every keystroke from zero. */
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
const { whpConfig } = useEditorConfig();
const { activePageId, pages } = usePages();
// Guarded with optional chaining: some hosts around this component (e.g.
// TopBar's own test harness) stub `useEditor` with a minimal collector
// state that has no `events`/`nodes` at all -- this must degrade to "no
// selection known" rather than throw and take the whole topbar down.
const { query, selectedType } = useEditor((state: any) => {
const sel = state?.events?.selected;
const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null;
return { selectedType: id ? (state?.nodes?.[id]?.data?.displayName ?? null) : null };
});
const [category, setCategory] = useState<ReportCategory>('bug');
const [description, setDescription] = useState('');
const [includeCanvas, setIncludeCanvas] = useState(true);
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [reference, setReference] = useState('');
const [error, setError] = useState('');
const activePage = pages.find((p) => p.id === activePageId);
const reset = (): void => {
setDescription('');
setStatus('idle');
setReference('');
setError('');
};
const handleSubmit = async (): Promise<void> => {
if (!description.trim() || !whpConfig) return;
setStatus('sending');
setError('');
let canvasState: string | null = null;
try {
canvasState = query.serialize();
} catch {
// A serialize failure must not block the report -- it is often the
// very thing being reported.
canvasState = null;
}
let payload;
try {
payload = buildReportPayload({
category,
description,
includeCanvas,
siteId: whpConfig.siteId ?? null,
siteDomain: whpConfig.siteDomain ?? '',
pageId: activePageId,
pageSlug: activePage?.slug ?? '',
editorVersion: editorBuild(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '',
deviceMode: device,
selectedType,
consoleErrors: getRecentConsoleErrors(),
canvasState,
});
} catch {
// buildReportPayload throws (rather than returning an oversized body)
// when the payload is still over the cap even after dropping
// canvas_state -- e.g. an enormous description. The thrown value is a
// plain Error with no discriminator, so treat ANY throw here as "too
// large" rather than string-matching the message. The user's text is
// left untouched in the textarea (state.description is never reset on
// this path) so nothing is lost -- they just need to shorten it or
// untick "include this page's contents".
setError(
'This report is too large to send, even without the page contents. ' +
'Try unchecking "Include this page\'s contents" below, or shortening your description.',
);
setStatus('error');
return;
}
try {
const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
setError(data.error || 'Could not send the report. Please try again.');
setStatus('error');
return;
}
setReference(data.reference || `SB-${data.id}`);
setStatus('sent');
} catch (e) {
setError('Could not reach the server. Your text is still here — try again.');
setStatus('error');
}
};
const handleClose = (): void => {
if (status === 'sent') reset();
onClose();
};
return createPortal(
<Modal open={open} onClose={handleClose} width="min(560px, 92vw)">
<div
style={{
background: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Report an issue</div>
<button
onClick={handleClose}
aria-label="Close"
style={{
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
}}
>
<i className="fa fa-times" />
</button>
</div>
{status === 'sent' ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<i className="fa fa-check-circle" style={{ fontSize: 32, color: '#10b981' }} aria-hidden="true" />
<p style={{ fontSize: 14, color: 'var(--color-text)', margin: '12px 0 4px' }}>
Thanks — that's been sent.
</p>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
Your reference is <strong>{reference}</strong>. Quote it if you open a support ticket.
</p>
<button
onClick={handleClose}
style={{
marginTop: 16, padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
}}
>
Done
</button>
</div>
) : (
<>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What kind of issue is it?
</label>
<select
data-testid="report-category"
value={category}
onChange={(e) => setCategory(e.target.value as ReportCategory)}
style={{
width: '100%', padding: '6px 8px', fontSize: 12,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
}}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What happened?
</label>
<textarea
data-testid="report-description"
value={description}
// onInput rather than onChange: functionally identical for
// real typing (both fire on every keystroke for a
// textarea), but onChange goes through React's
// value-tracker "did this really change" dedup, which a
// test harness driving the DOM via a raw `el.value =`
// assignment (rather than the native-setter-bypass trick)
// defeats -- the tracker sees its own just-written value
// and treats the dispatched 'input' event as a no-op.
// onInput is a plain passthrough with no such check.
onInput={(e) => setDescription((e.target as HTMLTextAreaElement).value)}
rows={5}
maxLength={MAX_DESCRIPTION_CHARS}
placeholder="What were you doing, and what did you expect to happen instead?"
style={{
width: '100%', padding: '8px 10px', fontSize: 12, lineHeight: 1.5,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
resize: 'vertical', boxSizing: 'border-box',
}}
/>
{description.length >= COUNTER_THRESHOLD && (
<div
data-testid="report-description-count"
style={{
fontSize: 10,
color: description.length >= MAX_DESCRIPTION_CHARS ? '#fca5a5' : 'var(--color-text-muted)',
textAlign: 'right',
marginTop: 4,
}}
>
{description.length} / {MAX_DESCRIPTION_CHARS}
</div>
)}
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', cursor: 'pointer' }}>
<input
data-testid="report-include-canvas"
type="checkbox"
checked={includeCanvas}
onChange={(e) => setIncludeCanvas(e.target.checked)}
style={{ marginTop: 2 }}
/>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Include this page's contents to help debugging. This sends the text and
layout of the page you're editing along with your report. Uncheck it and
we'll still get your description, the page name and your browser details --
but not the page's text or layout.
</span>
</label>
{status === 'error' && (
<div style={{
fontSize: 11, color: '#fca5a5', background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 4, padding: '8px 10px',
}}>
{error}
</div>
)}
</div>
<div style={{
padding: '10px 16px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>
<button
onClick={handleClose}
style={{
padding: '7px 16px', fontSize: 13,
background: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer',
}}
>
Cancel
</button>
<button
data-action="submit-report"
disabled={!description.trim() || status === 'sending'}
onClick={handleSubmit}
style={{
padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6,
cursor: description.trim() && status !== 'sending' ? 'pointer' : 'not-allowed',
opacity: description.trim() && status !== 'sending' ? 1 : 0.5,
}}
>
{status === 'sending' ? 'Sending…' : 'Send report'}
</button>
</div>
</>
)}
</div>
</Modal>,
document.body,
);
};