Review findings on the Report an Issue modal: 1. Revert the description textarea from onInput back to onChange. The onInput swap sidestepped a real React value-tracker dedup gotcha (a test harness's raw `el.value = x` assignment looks like a no-op change to a controlled onChange input), but this codebase already has the correct fix for exactly that gotcha: bypass the tracker's patched setter via the native prototype descriptor, as HeadCodeModal.test.tsx, SiteDesignPanel.reset.test.tsx, shared-controls.test.tsx, and both MediaStylePanel.*.test.tsx already do. Rewrote typeDescription() in ReportIssueModal.test.tsx to use that idiom instead of bending the component to fit the test. 2. The include-contents opt-out copy said unchecking it still sends "your description, the page name and your browser details" but omitted console errors, which buildReportPayload always includes regardless of the checkbox and which can incidentally echo page content. Copy now lists console errors explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
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}
|
|
onChange={(e) => setDescription(e.target.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, your browser details, and
|
|
any recent console errors -- 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,
|
|
);
|
|
};
|