309 lines
13 KiB
TypeScript
309 lines
13 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}
|
||
|
|
// 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,
|
||
|
|
);
|
||
|
|
};
|