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:
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: (collect?: (state: any) => any) => {
|
||||||
|
const state = { events: { selected: new Set<string>() }, nodes: {} };
|
||||||
|
return {
|
||||||
|
query: { serialize: () => '{"ROOT":{}}' },
|
||||||
|
actions: {},
|
||||||
|
...(collect ? collect(state) : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/EditorConfigContext', () => ({
|
||||||
|
useEditorConfig: () => ({
|
||||||
|
whpConfig: {
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
},
|
||||||
|
isWHP: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/PageContext', () => ({
|
||||||
|
usePages: () => ({
|
||||||
|
activePageId: 'home',
|
||||||
|
pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
// Mocked (rather than using the real ring buffer) only so the added
|
||||||
|
// oversized-payload test below can force an over-cap consoleErrors array --
|
||||||
|
// every other test gets a plain empty array, same as a fresh page load.
|
||||||
|
vi.mock('../../utils/console-buffer', () => ({ getRecentConsoleErrors: vi.fn() }));
|
||||||
|
|
||||||
|
import { ReportIssueModal } from './ReportIssueModal';
|
||||||
|
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(open = true) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeDescription(text: string) {
|
||||||
|
const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
|
||||||
|
act(() => {
|
||||||
|
ta.value = text;
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.mocked(getRecentConsoleErrors).mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReportIssueModal', () => {
|
||||||
|
test('submit is disabled until a description is entered', () => {
|
||||||
|
render();
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
typeDescription('something is wrong');
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts the payload to the report_issue action with the CSRF header', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('colours do nothing');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toContain('action=report_issue');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.headers['X-CSRF-Token']).toBe('tok');
|
||||||
|
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.description).toBe('colours do nothing');
|
||||||
|
expect(body.category).toBe('bug');
|
||||||
|
expect(body.site_id).toBe(42);
|
||||||
|
expect(body.canvas_state).toBe('{"ROOT":{}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchecking include-contents omits the canvas state', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('no canvas please');
|
||||||
|
const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
|
||||||
|
act(() => { cb.click(); });
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.canvas_state).toBeNull();
|
||||||
|
expect(body.canvas_state_omitted).toBe('opt-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the returned reference on success', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||||
|
}));
|
||||||
|
render();
|
||||||
|
typeDescription('x');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
expect(document.body.textContent).toContain('SB-1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the text and shows an error when the request fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ success: false, error: 'Rate limited' }),
|
||||||
|
}));
|
||||||
|
render();
|
||||||
|
typeDescription('keep me');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Rate limited');
|
||||||
|
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a payload still too large after dropping canvas_state shows an error instead of hanging, and never calls fetch', async () => {
|
||||||
|
// buildReportPayload throws when the body is still over MAX_PAYLOAD_BYTES
|
||||||
|
// even with canvas_state dropped -- see report-payload.ts `finalize()`.
|
||||||
|
// A huge *description* alone can't reach that path (it's truncated to
|
||||||
|
// MAX_DESCRIPTION_CHARS before the size check ever runs -- see
|
||||||
|
// report-payload.test.ts's "description size bounding" suite), so this
|
||||||
|
// forces the same defensive scenario report-payload.test.ts's "honest
|
||||||
|
// size markers" test uses: an oversized consoleErrors array, standing in
|
||||||
|
// for whatever future bug would let that much data through in practice.
|
||||||
|
// The point under test here is purely the modal's reaction to the throw,
|
||||||
|
// not how the oversized condition arises.
|
||||||
|
vi.mocked(getRecentConsoleErrors).mockReturnValue(
|
||||||
|
Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) })),
|
||||||
|
);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('this report has a huge console-error backlog attached');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(document.body.textContent).toMatch(/too large/i);
|
||||||
|
// The user's text must survive a rejected submission just as much as a
|
||||||
|
// server-side failure does.
|
||||||
|
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe(
|
||||||
|
'this report has a huge console-error backlog attached',
|
||||||
|
);
|
||||||
|
// Not stuck on "Sending..." -- the submit button is usable again.
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { useMobileChrome } from '../../state/MobileChromeContext';
|
|||||||
import { DeviceMode } from '../../types';
|
import { DeviceMode } from '../../types';
|
||||||
import { TemplateModal } from './TemplateModal';
|
import { TemplateModal } from './TemplateModal';
|
||||||
import { HeadCodeModal } from './HeadCodeModal';
|
import { HeadCodeModal } from './HeadCodeModal';
|
||||||
|
import { ReportIssueModal } from './ReportIssueModal';
|
||||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||||
import { PublishWarnings } from './PublishWarnings';
|
import { PublishWarnings } from './PublishWarnings';
|
||||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
@@ -40,6 +41,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
||||||
// otherwise identical for both the desktop and mobile branches below.
|
// otherwise identical for both the desktop and mobile branches below.
|
||||||
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
||||||
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { open: openSitesmith } = useSitesmithModal();
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -303,12 +305,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
onToggleGuides={onToggleGuides}
|
onToggleGuides={onToggleGuides}
|
||||||
onOpenTemplates={() => setTemplateModalOpen(true)}
|
onOpenTemplates={() => setTemplateModalOpen(true)}
|
||||||
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
||||||
|
onOpenReportIssue={() => setReportOpen(true)}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||||
|
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -377,6 +381,15 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
||||||
<i className="fa fa-eye" />
|
<i className="fa fa-eye" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="topbar-btn icon-only"
|
||||||
|
aria-label="Report an issue"
|
||||||
|
data-tooltip="Report an issue"
|
||||||
|
title="Report an issue"
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
>
|
||||||
|
<i className="fa fa-bug" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Draft/Published status badge */}
|
{/* Draft/Published status badge */}
|
||||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||||
@@ -441,6 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
</div>
|
</div>
|
||||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||||
|
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface TopBarOverflowMenuProps {
|
|||||||
onToggleGuides: () => void;
|
onToggleGuides: () => void;
|
||||||
onOpenTemplates: () => void;
|
onOpenTemplates: () => void;
|
||||||
onOpenHeadCode: () => void;
|
onOpenHeadCode: () => void;
|
||||||
|
onOpenReportIssue: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
|
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
|
||||||
* re-implemented here so the mobile menu reuses the exact same
|
* re-implemented here so the mobile menu reuses the exact same
|
||||||
@@ -33,6 +34,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
|||||||
onToggleGuides,
|
onToggleGuides,
|
||||||
onOpenTemplates,
|
onOpenTemplates,
|
||||||
onOpenHeadCode,
|
onOpenHeadCode,
|
||||||
|
onOpenReportIssue,
|
||||||
onPreview,
|
onPreview,
|
||||||
sitesmithNode,
|
sitesmithNode,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -91,6 +93,9 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
|||||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
||||||
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
|
||||||
|
<i className="fa fa-bug" aria-hidden="true" /> Report an issue
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
|
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user