diff --git a/.changeset/feat_in_app_bug_report.md b/.changeset/feat_in_app_bug_report.md new file mode 100644 index 000000000..d24850fb4 --- /dev/null +++ b/.changeset/feat_in_app_bug_report.md @@ -0,0 +1,5 @@ +--- +'sable': minor +--- + +feat: in-app bug report and feature request modal diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx new file mode 100644 index 000000000..1ee788f05 --- /dev/null +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -0,0 +1,382 @@ +import { useState, useEffect } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Chip, + config, + Header, + Icon, + IconButton, + Icons, + Input, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, + TextArea, +} from 'folds'; +import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal'; +import { stopPropagation } from '$utils/keyboard'; + +type ReportType = 'bug' | 'feature'; + +type SimilarIssue = { + number: number; + title: string; + html_url: string; +}; + +const GITHUB_REPO = 'SableClient/Sable'; + +async function searchSimilarIssues(query: string, signal: AbortSignal): Promise { + // Split into individual words, drop very short ones, and join with OR so that + // partial / stemmed titles (e.g. "reporting" still matches "report") surface results. + const words = query + .split(/[\s\-_/]+/) + .map((w) => w.replace(/[^\w]/g, '')) + .filter((w) => w.length >= 3); + + if (words.length === 0) return []; + + const q = `${words.join(' OR ')} repo:${GITHUB_REPO} is:issue is:open`; + const params = new URLSearchParams({ q, per_page: '5' }); + const res = await fetch(`https://api.github.com/search/issues?${params}`, { signal }); + if (!res.ok) return []; + const data = (await res.json()) as { items?: SimilarIssue[] }; + return data.items ?? []; +} + +// Field IDs match the ids defined in .github/ISSUE_TEMPLATE/bug_report.yml +// and feature_request.yml so GitHub pre-fills each form field directly. +function buildGitHubUrl(type: ReportType, title: string, fields: Record): string { + const devLabel = IS_RELEASE_TAG ? '' : '-dev'; + const buildLabel = BUILD_HASH ? ` (${BUILD_HASH})` : ''; + const version = `v${APP_VERSION}${devLabel}${buildLabel}`; + + const params: Record = { title }; + + if (type === 'bug') { + params.template = 'bug_report.yml'; + if (fields.description) params.description = fields.description; + if (fields.reproduction) params.reproduction = fields.reproduction; + if (fields['expected-behavior']) params['expected-behavior'] = fields['expected-behavior']; + // Auto-populate the platform/versions field + params.info = `- OS: ${navigator.platform || 'unknown'}\n- Browser: ${navigator.userAgent}\n- Sable: ${version}`; + if (fields.context) params.context = fields.context; + } else { + params.template = 'feature_request.yml'; + if (fields.problem) params.problem = fields.problem; + if (fields.solution) params.solution = fields.solution; + if (fields.alternatives) params.alternatives = fields.alternatives; + if (fields.context) params.context = fields.context; + } + + return `https://github.com/${GITHUB_REPO}/issues/new?${new URLSearchParams(params)}`; +} + +function BugReportModal() { + const close = useCloseBugReportModal(); + const [type, setType] = useState('bug'); + const [title, setTitle] = useState(''); + + // Bug fields (match bug_report.yml ids) + const [description, setDescription] = useState(''); + const [reproduction, setReproduction] = useState(''); + const [expectedBehavior, setExpectedBehavior] = useState(''); + + // Feature fields (match feature_request.yml ids) + const [problem, setProblem] = useState(''); + const [solution, setSolution] = useState(''); + const [alternatives, setAlternatives] = useState(''); + + // Shared optional field + const [context, setContext] = useState(''); + + const [similarIssues, setSimilarIssues] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + const trimmed = title.trim(); + const controller = new AbortController(); + let cancelled = false; + let timer: ReturnType | undefined; + + if (trimmed.length >= 3) { + timer = setTimeout(async () => { + setSearching(true); + try { + const issues = await searchSimilarIssues(trimmed, controller.signal); + if (!cancelled) setSimilarIssues(issues); + } catch { + // silently ignore network errors / rate limits + } finally { + if (!cancelled) setSearching(false); + } + }, 600); + } else { + setSimilarIssues([]); + setSearching(false); + } + + return () => { + cancelled = true; + if (timer !== undefined) clearTimeout(timer); + controller.abort(); + }; + }, [title]); + + const canSubmit = + title.trim().length > 0 && + (type === 'bug' + ? description.trim().length > 0 + : problem.trim().length > 0 && solution.trim().length > 0); + + const handleSubmit = () => { + if (!canSubmit) return; + const fields: Record = + type === 'bug' + ? { description, reproduction, 'expected-behavior': expectedBehavior, context } + : { problem, solution, alternatives, context }; + const url = buildGitHubUrl(type, title.trim(), fields); + window.open(url, '_blank', 'noopener,noreferrer'); + close(); + }; + + return ( + }> + + + + +
+ + Report an Issue + + + + +
+ + + {/* Type */} + + Type + + setType('bug')} + > + Bug Report + + setType('feature')} + > + Feature Request + + + + + {/* Title + duplicate check */} + + Title * + setTitle((e.target as HTMLInputElement).value)} + /> + {searching && ( + + + Searching for similar issues… + + )} + {!searching && similarIssues.length > 0 && ( + + + Similar open issues — please check before submitting: + + {similarIssues.map((issue) => ( + + {'→ '} + + #{issue.number}: {issue.title} + + + ))} + + )} + + + {/* Description */} + + + {type === 'bug' ? 'Describe the bug *' : 'Describe the problem *'} + +