rn-backstage 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,559 +0,0 @@
1
- import React, { useCallback, useMemo, useState } from 'react'
2
- import {
3
- ActivityIndicator,
4
- Alert,
5
- Modal,
6
- Platform,
7
- SafeAreaView,
8
- ScrollView,
9
- Share,
10
- StyleSheet,
11
- Text,
12
- TextInput,
13
- TouchableOpacity,
14
- View,
15
- } from 'react-native'
16
- import { MonospaceFont, TestIDs } from '../constants'
17
- import { useBackstageTheme } from '../ThemeContext'
18
- import {
19
- buildDeviceInfo,
20
- composeBugReport,
21
- submitToWebhook,
22
- } from '../bug-report'
23
- import type {
24
- AppInfoItem,
25
- BackstageTheme,
26
- BugReport,
27
- BugReportConfig,
28
- BugReportSeverity,
29
- LogEntry,
30
- NetworkEntry,
31
- } from '../types'
32
-
33
- // ─── Types ───────────────────────────────────────────────────────────────────
34
-
35
- interface BugReportComposerProps {
36
- visible: boolean
37
- onClose: () => void
38
- config: BugReportConfig
39
-
40
- // Context data
41
- logs: LogEntry[]
42
- networkEntries: NetworkEntry[]
43
- state?: Record<string, unknown>
44
- appVersion?: string
45
- buildNumber?: string
46
- bundleId?: string
47
- deviceInfo?: AppInfoItem[]
48
- }
49
-
50
- // ─── Severity Config ─────────────────────────────────────────────────────────
51
-
52
- const SEVERITIES: { key: BugReportSeverity; label: string; emoji: string }[] = [
53
- { key: 'low', label: 'Low', emoji: '🟢' },
54
- { key: 'medium', label: 'Medium', emoji: '🟡' },
55
- { key: 'high', label: 'High', emoji: '🟠' },
56
- { key: 'critical', label: 'Critical', emoji: '🔴' },
57
- ]
58
-
59
- // ─── Component ───────────────────────────────────────────────────────────────
60
-
61
- export const BugReportComposer: React.FC<BugReportComposerProps> = ({
62
- visible,
63
- onClose,
64
- config,
65
- logs,
66
- networkEntries,
67
- state,
68
- appVersion,
69
- buildNumber,
70
- bundleId,
71
- deviceInfo: extraDeviceInfo,
72
- }) => {
73
- const theme = useBackstageTheme()
74
- const s = useMemo(() => createStyles(theme), [theme])
75
-
76
- // ── Form state ────────────────────────────────────────
77
- const [title, setTitle] = useState('')
78
- const [description, setDescription] = useState('')
79
- const [severity, setSeverity] = useState<BugReportSeverity>('medium')
80
- const [includeDeviceInfo, setIncludeDeviceInfo] = useState(true)
81
- const [includeLogs, setIncludeLogs] = useState(true)
82
- const [includeNetwork, setIncludeNetwork] = useState(true)
83
- const [includeState, setIncludeState] = useState(config.includeState !== false)
84
- const [includeScreenshot, setIncludeScreenshot] = useState(!!config.captureScreenshot)
85
- const [submitting, setSubmitting] = useState(false)
86
-
87
- const maxLogs = config.maxLogsInReport ?? 50
88
- const maxNetwork = config.maxNetworkEntriesInReport ?? 20
89
-
90
- const deviceInfoItems = useMemo(
91
- () => buildDeviceInfo(appVersion, buildNumber, bundleId, extraDeviceInfo),
92
- [appVersion, buildNumber, bundleId, extraDeviceInfo],
93
- )
94
-
95
- // ── Reset form ────────────────────────────────────────
96
- const resetForm = useCallback(() => {
97
- setTitle('')
98
- setDescription('')
99
- setSeverity('medium')
100
- setIncludeDeviceInfo(true)
101
- setIncludeLogs(true)
102
- setIncludeNetwork(true)
103
- setIncludeState(config.includeState !== false)
104
- setIncludeScreenshot(!!config.captureScreenshot)
105
- setSubmitting(false)
106
- }, [config])
107
-
108
- const handleClose = useCallback(() => {
109
- resetForm()
110
- onClose()
111
- }, [resetForm, onClose])
112
-
113
- // ── Build report ──────────────────────────────────────
114
- const buildReport = useCallback(async (): Promise<BugReport> => {
115
- let screenshotUri: string | undefined
116
-
117
- if (includeScreenshot && config.captureScreenshot) {
118
- try {
119
- screenshotUri = await config.captureScreenshot()
120
- } catch {
121
- // Screenshot capture failed — continue without it
122
- }
123
- }
124
-
125
- return {
126
- title: title.trim() || 'Untitled Bug Report',
127
- description: description.trim(),
128
- severity,
129
- deviceInfo: includeDeviceInfo ? deviceInfoItems : [],
130
- logs: includeLogs ? logs.slice(-maxLogs) : [],
131
- networkEntries: includeNetwork ? networkEntries.slice(-maxNetwork) : [],
132
- state: includeState ? state : undefined,
133
- screenshotUri,
134
- timestamp: Date.now(),
135
- }
136
- }, [
137
- title,
138
- description,
139
- severity,
140
- includeDeviceInfo,
141
- includeLogs,
142
- includeNetwork,
143
- includeState,
144
- includeScreenshot,
145
- deviceInfoItems,
146
- logs,
147
- networkEntries,
148
- state,
149
- config,
150
- maxLogs,
151
- maxNetwork,
152
- ])
153
-
154
- // ── Share via system share sheet ──────────────────────
155
- const handleShare = useCallback(async () => {
156
- if (!title.trim()) {
157
- Alert.alert('Title required', 'Please enter a title for the bug report.')
158
- return
159
- }
160
-
161
- setSubmitting(true)
162
-
163
- try {
164
- const report = await buildReport()
165
- const markdown = composeBugReport(report)
166
-
167
- // Fire the onSubmit callback first
168
- if (config.onSubmit) {
169
- config.onSubmit(report)
170
- }
171
-
172
- // Submit to webhook if configured
173
- if (config.webhookUrl) {
174
- const result = await submitToWebhook(config.webhookUrl, report)
175
- if (!result.success) {
176
- Alert.alert('Webhook Error', `Failed to submit: ${result.error}`)
177
- setSubmitting(false)
178
- return
179
- }
180
- }
181
-
182
- // Open system share sheet
183
- await Share.share({
184
- title: `Bug Report: ${report.title}`,
185
- message: markdown,
186
- })
187
-
188
- handleClose()
189
- } catch {
190
- Alert.alert('Error', 'Failed to share the bug report.')
191
- } finally {
192
- setSubmitting(false)
193
- }
194
- }, [title, buildReport, config, handleClose])
195
-
196
- // ── Counts ────────────────────────────────────────────
197
- const logCount = Math.min(logs.length, maxLogs)
198
- const networkCount = Math.min(networkEntries.length, maxNetwork)
199
- const hasState = state && Object.keys(state).length > 0
200
- const hasScreenshot = !!config.captureScreenshot
201
-
202
- return (
203
- <Modal
204
- testID={TestIDs.bugReport.modal}
205
- animationType="slide"
206
- transparent={false}
207
- visible={visible}
208
- onRequestClose={handleClose}
209
- statusBarTranslucent
210
- >
211
- <SafeAreaView style={s.container}>
212
- {/* ── Header ──────────────────────────────────── */}
213
- <View style={s.header}>
214
- <TouchableOpacity
215
- testID={TestIDs.bugReport.cancelButton}
216
- onPress={handleClose}
217
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
218
- >
219
- <Text style={s.cancelText}>Cancel</Text>
220
- </TouchableOpacity>
221
- <Text style={s.headerTitle}>🐛 Bug Report</Text>
222
- <TouchableOpacity
223
- testID={TestIDs.bugReport.shareButton}
224
- style={[s.shareButton, !title.trim() && s.shareButtonDisabled]}
225
- onPress={handleShare}
226
- disabled={submitting || !title.trim()}
227
- activeOpacity={0.7}
228
- >
229
- {submitting ? (
230
- <ActivityIndicator size="small" color="#FFF" />
231
- ) : (
232
- <Text style={s.shareButtonText}>
233
- {config.webhookUrl ? 'Submit' : 'Share'}
234
- </Text>
235
- )}
236
- </TouchableOpacity>
237
- </View>
238
-
239
- <ScrollView
240
- style={s.body}
241
- contentContainerStyle={s.bodyContent}
242
- showsVerticalScrollIndicator={false}
243
- keyboardShouldPersistTaps="handled"
244
- >
245
- {/* ── Title ─────────────────────────────────── */}
246
- <View style={s.inputGroup}>
247
- <Text style={s.label}>Title *</Text>
248
- <TextInput
249
- testID={TestIDs.bugReport.titleInput}
250
- style={s.titleInput}
251
- placeholder="What went wrong?"
252
- placeholderTextColor={theme.textMuted}
253
- value={title}
254
- onChangeText={setTitle}
255
- autoFocus
256
- returnKeyType="next"
257
- />
258
- </View>
259
-
260
- {/* ── Description ───────────────────────────── */}
261
- <View style={s.inputGroup}>
262
- <Text style={s.label}>Description</Text>
263
- <TextInput
264
- testID={TestIDs.bugReport.descriptionInput}
265
- style={s.descriptionInput}
266
- placeholder="Steps to reproduce, expected vs actual behavior..."
267
- placeholderTextColor={theme.textMuted}
268
- value={description}
269
- onChangeText={setDescription}
270
- multiline
271
- textAlignVertical="top"
272
- returnKeyType="default"
273
- />
274
- </View>
275
-
276
- {/* ── Severity ──────────────────────────────── */}
277
- <View style={s.inputGroup}>
278
- <Text style={s.label}>Severity</Text>
279
- <View testID={TestIDs.bugReport.severityPicker} style={s.severityRow}>
280
- {SEVERITIES.map(sev => (
281
- <TouchableOpacity
282
- key={sev.key}
283
- testID={TestIDs.bugReport.severityOption(sev.key)}
284
- style={[
285
- s.severityChip,
286
- severity === sev.key && s.severityChipActive,
287
- severity === sev.key && {
288
- borderColor: getSeverityColor(sev.key, theme),
289
- backgroundColor: getSeverityColor(sev.key, theme) + '20',
290
- },
291
- ]}
292
- onPress={() => setSeverity(sev.key)}
293
- activeOpacity={0.7}
294
- >
295
- <Text style={s.severityEmoji}>{sev.emoji}</Text>
296
- <Text
297
- style={[
298
- s.severityLabel,
299
- severity === sev.key && {
300
- color: getSeverityColor(sev.key, theme),
301
- },
302
- ]}
303
- >
304
- {sev.label}
305
- </Text>
306
- </TouchableOpacity>
307
- ))}
308
- </View>
309
- </View>
310
-
311
- {/* ── Attachments ───────────────────────────── */}
312
- <View style={s.inputGroup}>
313
- <Text style={s.label}>Attachments</Text>
314
- <View style={s.attachmentsList}>
315
- <ToggleRow
316
- testID={TestIDs.bugReport.toggleDeviceInfo}
317
- label="📱 Device Info"
318
- detail={`${deviceInfoItems.length} items`}
319
- value={includeDeviceInfo}
320
- onToggle={setIncludeDeviceInfo}
321
- theme={theme}
322
- />
323
- <ToggleRow
324
- testID={TestIDs.bugReport.toggleLogs}
325
- label="📋 Console Logs"
326
- detail={`${logCount} entries`}
327
- value={includeLogs}
328
- onToggle={setIncludeLogs}
329
- theme={theme}
330
- />
331
- <ToggleRow
332
- testID={TestIDs.bugReport.toggleNetwork}
333
- label="🌐 Network Activity"
334
- detail={`${networkCount} requests`}
335
- value={includeNetwork}
336
- onToggle={setIncludeNetwork}
337
- theme={theme}
338
- />
339
- {hasState && (
340
- <ToggleRow
341
- testID={TestIDs.bugReport.toggleState}
342
- label="🌳 State Snapshot"
343
- detail={`${Object.keys(state!).length} keys`}
344
- value={includeState}
345
- onToggle={setIncludeState}
346
- theme={theme}
347
- />
348
- )}
349
- {hasScreenshot && (
350
- <ToggleRow
351
- testID={TestIDs.bugReport.toggleScreenshot}
352
- label="📸 Screenshot"
353
- detail="Capture on submit"
354
- value={includeScreenshot}
355
- onToggle={setIncludeScreenshot}
356
- theme={theme}
357
- />
358
- )}
359
- </View>
360
- </View>
361
- </ScrollView>
362
- </SafeAreaView>
363
- </Modal>
364
- )
365
- }
366
-
367
- // ─── Toggle Row Sub-Component ────────────────────────────────────────────────
368
-
369
- interface ToggleRowProps {
370
- testID: string
371
- label: string
372
- detail: string
373
- value: boolean
374
- onToggle: (value: boolean) => void
375
- theme: BackstageTheme
376
- }
377
-
378
- const ToggleRow: React.FC<ToggleRowProps> = ({
379
- testID,
380
- label,
381
- detail,
382
- value,
383
- onToggle,
384
- theme,
385
- }) => (
386
- <TouchableOpacity
387
- testID={testID}
388
- style={[
389
- toggleStyles.row,
390
- { borderBottomColor: theme.border },
391
- ]}
392
- onPress={() => onToggle(!value)}
393
- activeOpacity={0.7}
394
- >
395
- <View style={toggleStyles.rowLeft}>
396
- <Text style={[toggleStyles.checkbox, { color: value ? theme.accent : theme.textMuted }]}>
397
- {value ? '☑' : '☐'}
398
- </Text>
399
- <View>
400
- <Text style={[toggleStyles.label, { color: theme.text }]}>{label}</Text>
401
- <Text style={[toggleStyles.detail, { color: theme.textMuted }]}>{detail}</Text>
402
- </View>
403
- </View>
404
- </TouchableOpacity>
405
- )
406
-
407
- const toggleStyles = StyleSheet.create({
408
- row: {
409
- flexDirection: 'row',
410
- alignItems: 'center',
411
- justifyContent: 'space-between',
412
- paddingVertical: 12,
413
- paddingHorizontal: 4,
414
- borderBottomWidth: StyleSheet.hairlineWidth,
415
- },
416
- rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 },
417
- checkbox: { fontSize: 20 },
418
- label: { fontFamily: MonospaceFont, fontSize: 14, fontWeight: '600' },
419
- detail: { fontFamily: MonospaceFont, fontSize: 11, marginTop: 2 },
420
- })
421
-
422
- // ─── Helpers ─────────────────────────────────────────────────────────────────
423
-
424
- function getSeverityColor(severity: BugReportSeverity, theme: BackstageTheme): string {
425
- switch (severity) {
426
- case 'low':
427
- return theme.success
428
- case 'medium':
429
- return theme.warning
430
- case 'high':
431
- return '#F97316' // orange
432
- case 'critical':
433
- return theme.error
434
- }
435
- }
436
-
437
- // ─── Styles ──────────────────────────────────────────────────────────────────
438
-
439
- const createStyles = (t: BackstageTheme) =>
440
- StyleSheet.create({
441
- container: {
442
- flex: 1,
443
- backgroundColor: t.background,
444
- },
445
- header: {
446
- flexDirection: 'row',
447
- alignItems: 'center',
448
- justifyContent: 'space-between',
449
- paddingHorizontal: 16,
450
- paddingVertical: 12,
451
- borderBottomWidth: StyleSheet.hairlineWidth,
452
- borderBottomColor: t.border,
453
- },
454
- headerTitle: {
455
- fontFamily: MonospaceFont,
456
- fontSize: 16,
457
- fontWeight: '700',
458
- color: t.text,
459
- },
460
- cancelText: {
461
- fontFamily: MonospaceFont,
462
- fontSize: 14,
463
- color: t.accent,
464
- },
465
- shareButton: {
466
- backgroundColor: t.accent,
467
- paddingHorizontal: 16,
468
- paddingVertical: 8,
469
- borderRadius: 8,
470
- minWidth: 70,
471
- alignItems: 'center',
472
- },
473
- shareButtonDisabled: {
474
- opacity: 0.4,
475
- },
476
- shareButtonText: {
477
- fontFamily: MonospaceFont,
478
- fontSize: 14,
479
- fontWeight: '700',
480
- color: '#FFFFFF',
481
- },
482
- body: {
483
- flex: 1,
484
- },
485
- bodyContent: {
486
- padding: 16,
487
- paddingBottom: 40,
488
- },
489
- inputGroup: {
490
- marginBottom: 24,
491
- },
492
- label: {
493
- fontFamily: MonospaceFont,
494
- fontSize: 12,
495
- fontWeight: '600',
496
- color: t.textSecondary,
497
- textTransform: 'uppercase',
498
- letterSpacing: 0.5,
499
- marginBottom: 8,
500
- },
501
- titleInput: {
502
- fontFamily: MonospaceFont,
503
- fontSize: 16,
504
- color: t.text,
505
- backgroundColor: t.surface,
506
- borderRadius: 10,
507
- borderWidth: 1,
508
- borderColor: t.border,
509
- paddingHorizontal: 14,
510
- paddingVertical: Platform.OS === 'ios' ? 14 : 10,
511
- },
512
- descriptionInput: {
513
- fontFamily: MonospaceFont,
514
- fontSize: 14,
515
- color: t.text,
516
- backgroundColor: t.surface,
517
- borderRadius: 10,
518
- borderWidth: 1,
519
- borderColor: t.border,
520
- paddingHorizontal: 14,
521
- paddingVertical: Platform.OS === 'ios' ? 14 : 10,
522
- minHeight: 100,
523
- },
524
- severityRow: {
525
- flexDirection: 'row',
526
- gap: 8,
527
- },
528
- severityChip: {
529
- flex: 1,
530
- flexDirection: 'row',
531
- alignItems: 'center',
532
- justifyContent: 'center',
533
- gap: 4,
534
- paddingVertical: 10,
535
- borderRadius: 10,
536
- borderWidth: 1.5,
537
- borderColor: t.border,
538
- backgroundColor: t.surface,
539
- },
540
- severityChipActive: {
541
- borderWidth: 1.5,
542
- },
543
- severityEmoji: {
544
- fontSize: 12,
545
- },
546
- severityLabel: {
547
- fontFamily: MonospaceFont,
548
- fontSize: 11,
549
- fontWeight: '600',
550
- color: t.textMuted,
551
- },
552
- attachmentsList: {
553
- backgroundColor: t.surface,
554
- borderRadius: 10,
555
- borderWidth: 1,
556
- borderColor: t.border,
557
- paddingHorizontal: 12,
558
- },
559
- })