@picked/svelte 0.0.1-dev.1793923852884 → 0.0.1-dev.1793933802654
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/plugins/devtools/DevToolsExperience.svelte +32 -2
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.d.ts +1 -1
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
- package/dist/plugins/devtools/derive/index.d.ts +3 -1
- package/dist/plugins/devtools/derive/index.js +1 -1
- package/dist/plugins/devtools/derive/issue-report/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/issue-report/index.js +1 -0
- package/dist/plugins/devtools/derive/issue-report/issue-report.d.ts +255 -0
- package/dist/plugins/devtools/derive/issue-report/issue-report.js +2 -0
- package/dist/plugins/devtools/derive/plain-language/index.d.ts +1 -1
- package/dist/plugins/devtools/derive/plain-language/index.js +1 -1
- package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +6 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
- package/dist/plugins/devtools/index.d.ts +4 -1
- package/dist/plugins/devtools/index.js +1 -1
- package/dist/plugins/devtools/options.d.ts +30 -1
- package/dist/plugins/devtools/options.js +1 -1
- package/dist/plugins/devtools/review/index.d.ts +3 -2
- package/dist/plugins/devtools/review/index.js +1 -1
- package/dist/plugins/devtools/review/issues/ReportIssuePanel.svelte +805 -0
- package/dist/plugins/devtools/review/issues/capture-screenshot.d.ts +38 -0
- package/dist/plugins/devtools/review/issues/capture-screenshot.js +1 -0
- package/dist/plugins/devtools/review/issues/read-attachment.d.ts +27 -0
- package/dist/plugins/devtools/review/issues/read-attachment.js +1 -0
- package/dist/plugins/devtools/review/issues/site-endpoint.d.ts +17 -0
- package/dist/plugins/devtools/review/issues/site-endpoint.js +1 -0
- package/dist/plugins/devtools/sidebar/nav.d.ts +6 -3
- package/dist/plugins/devtools/sidebar/nav.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @file The Report an issue tab: a title, a description, a kind and a priority, a screenshot or
|
|
4
|
+
* files, and the context the report carries, which the reviewer can trim before sending. Without
|
|
5
|
+
* a sink it says how to set reports up.
|
|
6
|
+
*/
|
|
7
|
+
import type { Picked } from '@picked/client'
|
|
8
|
+
import { Banner, Button, Card, CopyButton, EmptyState, Select, Stack, TextField } from '../../ui'
|
|
9
|
+
import {
|
|
10
|
+
ATTACHMENT_TYPES,
|
|
11
|
+
ISSUE_KINDS,
|
|
12
|
+
ISSUE_PRIORITIES,
|
|
13
|
+
attachmentProblem,
|
|
14
|
+
buildIssueContext,
|
|
15
|
+
buildIssueReport,
|
|
16
|
+
contextRows,
|
|
17
|
+
draftProblem,
|
|
18
|
+
toggled,
|
|
19
|
+
withoutRows,
|
|
20
|
+
type ContextRowId,
|
|
21
|
+
type IssueAttachment,
|
|
22
|
+
type IssueContext,
|
|
23
|
+
type IssueKind,
|
|
24
|
+
type IssuePriority,
|
|
25
|
+
type IssueReceipt,
|
|
26
|
+
type RouteEntry,
|
|
27
|
+
} from '../../derive'
|
|
28
|
+
import type { ResolvedIssueOptions } from '../../options'
|
|
29
|
+
import type { JourneyView } from '../../session'
|
|
30
|
+
import { readStored, writeStored } from '../../sidebar/nav'
|
|
31
|
+
import { captureScreenshot } from './capture-screenshot'
|
|
32
|
+
import { previewUrl, readAttachment } from './read-attachment'
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
client: Picked
|
|
36
|
+
/** `null` while the site has not set reports up. */
|
|
37
|
+
issues: ResolvedIssueOptions | null
|
|
38
|
+
journey: JourneyView | null
|
|
39
|
+
route: readonly RouteEntry[]
|
|
40
|
+
/** Keys that code commits, which a report leaves out. */
|
|
41
|
+
codeOwned: ReadonlySet<string>
|
|
42
|
+
/** The persona the reviewer walks through as. */
|
|
43
|
+
persona: { id: string; name: string } | null
|
|
44
|
+
/** Re-reads the journey before the context is taken. */
|
|
45
|
+
refreshJourney: () => void
|
|
46
|
+
/** Hides the panel while a screenshot is taken, and shows it again. */
|
|
47
|
+
onCapturing: (capturing: boolean) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { client, issues, journey, route, codeOwned, persona, refreshJourney, onCapturing }: Props =
|
|
51
|
+
$props()
|
|
52
|
+
|
|
53
|
+
const docsUrl = $derived(
|
|
54
|
+
`${client.documentationSiteUrl}/how-to/send-issue-reports-to-your-tracker`,
|
|
55
|
+
)
|
|
56
|
+
const workspace = $derived(client.searchParams['preview-token']?.[0] ? 'draft' : 'published')
|
|
57
|
+
|
|
58
|
+
/** `sessionStorage` key holding the unsent title, description, kind and priority. */
|
|
59
|
+
const DRAFT_KEY = 'picked-devtools-issue-draft'
|
|
60
|
+
const sessionStorageOf = (): Storage => window.sessionStorage
|
|
61
|
+
|
|
62
|
+
/** What the form keeps for this tab while it is unsent. */
|
|
63
|
+
interface StoredDraft {
|
|
64
|
+
title: string
|
|
65
|
+
description: string
|
|
66
|
+
kind: IssueKind
|
|
67
|
+
priority: IssuePriority
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const EMPTY_DRAFT: StoredDraft = { title: '', description: '', kind: 'bug', priority: 'none' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The draft this tab left unsent, read back from `sessionStorage`.
|
|
74
|
+
*
|
|
75
|
+
* @returns {StoredDraft} The draft, or an empty one.
|
|
76
|
+
* @example readDraft().title
|
|
77
|
+
*/
|
|
78
|
+
function readDraft(): StoredDraft {
|
|
79
|
+
const stored = readStored(sessionStorageOf, DRAFT_KEY)
|
|
80
|
+
if (stored === null) return EMPTY_DRAFT
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(stored)
|
|
83
|
+
if (typeof parsed !== 'object' || parsed === null) return EMPTY_DRAFT
|
|
84
|
+
const entries = Object.fromEntries(Object.entries(parsed))
|
|
85
|
+
return {
|
|
86
|
+
title: typeof entries.title === 'string' ? entries.title : '',
|
|
87
|
+
description: typeof entries.description === 'string' ? entries.description : '',
|
|
88
|
+
kind: ISSUE_KINDS.find((option) => option.value === entries.kind)?.value ?? 'bug',
|
|
89
|
+
priority:
|
|
90
|
+
ISSUE_PRIORITIES.find((option) => option.value === entries.priority)?.value ?? 'none',
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
return EMPTY_DRAFT
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const draft = readDraft()
|
|
98
|
+
let title = $state(draft.title)
|
|
99
|
+
let description = $state(draft.description)
|
|
100
|
+
let kind = $state<IssueKind>(draft.kind)
|
|
101
|
+
let priority = $state<IssuePriority>(draft.priority)
|
|
102
|
+
let attachments = $state.raw<IssueAttachment[]>([])
|
|
103
|
+
let removed = $state.raw<ReadonlySet<ContextRowId>>(new Set())
|
|
104
|
+
let droppedLabels = $state.raw<ReadonlySet<string>>(new Set())
|
|
105
|
+
let titleError = $state<string | undefined>(undefined)
|
|
106
|
+
let fileError = $state<string | null>(null)
|
|
107
|
+
let capturing = $state(false)
|
|
108
|
+
let status = $state<
|
|
109
|
+
| { kind: 'editing' }
|
|
110
|
+
| { kind: 'sending' }
|
|
111
|
+
| { kind: 'sent'; receipt: IssueReceipt }
|
|
112
|
+
| { kind: 'failed'; message: string }
|
|
113
|
+
>({ kind: 'editing' })
|
|
114
|
+
let fileInput = $state<HTMLInputElement | undefined>(undefined)
|
|
115
|
+
|
|
116
|
+
$effect(() => {
|
|
117
|
+
writeStored(sessionStorageOf, DRAFT_KEY, JSON.stringify({ title, description, kind, priority }))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Takes the context now: where the visitor is, how they got there, and their responses.
|
|
122
|
+
*
|
|
123
|
+
* @returns {IssueContext} The context.
|
|
124
|
+
* @example takeContext().step
|
|
125
|
+
*/
|
|
126
|
+
function takeContext(): IssueContext {
|
|
127
|
+
refreshJourney()
|
|
128
|
+
return buildIssueContext({
|
|
129
|
+
capturedAt: new Date(),
|
|
130
|
+
pagePath: window.location.pathname,
|
|
131
|
+
workspace,
|
|
132
|
+
journey,
|
|
133
|
+
route,
|
|
134
|
+
responses: client.responses.getAllStore.getState(),
|
|
135
|
+
codeOwned,
|
|
136
|
+
persona,
|
|
137
|
+
site: issues?.context(client) ?? {},
|
|
138
|
+
redact: issues?.redact ?? null,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let context = $state.raw<IssueContext>(takeContext())
|
|
143
|
+
const rows = $derived(contextRows(context))
|
|
144
|
+
const suggestedLabels = $derived(issues?.labels(context) ?? [])
|
|
145
|
+
const labels = $derived(suggestedLabels.filter((label) => !droppedLabels.has(label)))
|
|
146
|
+
const sending = $derived(status.kind === 'sending')
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Takes the context again, keeping the parts the reviewer removed out.
|
|
150
|
+
*
|
|
151
|
+
* @example refresh()
|
|
152
|
+
*/
|
|
153
|
+
function refresh(): void {
|
|
154
|
+
context = takeContext()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Removes a part of the context from the report, or puts it back.
|
|
159
|
+
*
|
|
160
|
+
* @param {ContextRowId} id - The part.
|
|
161
|
+
* @example toggleRow('responses')
|
|
162
|
+
*/
|
|
163
|
+
function toggleRow(id: ContextRowId): void {
|
|
164
|
+
removed = toggled(removed, id)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Drops a suggested label from the report, or puts it back.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} label - The label.
|
|
171
|
+
* @example toggleLabel('exp/funnel')
|
|
172
|
+
*/
|
|
173
|
+
function toggleLabel(label: string): void {
|
|
174
|
+
droppedLabels = toggled(droppedLabels, label)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Attaches files, refusing any a report cannot carry.
|
|
179
|
+
*
|
|
180
|
+
* @param {Iterable<File>} files - The files picked or dropped.
|
|
181
|
+
* @example await attachFiles(input.files)
|
|
182
|
+
*/
|
|
183
|
+
async function attachFiles(files: Iterable<File>): Promise<void> {
|
|
184
|
+
fileError = null
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const problem = attachmentProblem(file, attachments)
|
|
187
|
+
if (problem !== null) {
|
|
188
|
+
fileError = problem
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
attachments = [...attachments, await readAttachment(file, file.name)]
|
|
194
|
+
} catch {
|
|
195
|
+
fileError = `"${file.name}" could not be read.`
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Takes a screenshot of the page, with the panel hidden for it.
|
|
202
|
+
*
|
|
203
|
+
* @example await takeScreenshot()
|
|
204
|
+
*/
|
|
205
|
+
async function takeScreenshot(): Promise<void> {
|
|
206
|
+
fileError = null
|
|
207
|
+
capturing = true
|
|
208
|
+
try {
|
|
209
|
+
const shot = await captureScreenshot({
|
|
210
|
+
hide: () => onCapturing(true),
|
|
211
|
+
show: () => onCapturing(false),
|
|
212
|
+
})
|
|
213
|
+
const problem = attachmentProblem(
|
|
214
|
+
{ name: shot.filename, type: shot.contentType, size: shot.size },
|
|
215
|
+
attachments,
|
|
216
|
+
)
|
|
217
|
+
if (problem === null) attachments = [...attachments, shot]
|
|
218
|
+
else fileError = problem
|
|
219
|
+
} catch (error) {
|
|
220
|
+
fileError = error instanceof Error ? error.message : 'No screenshot was taken.'
|
|
221
|
+
} finally {
|
|
222
|
+
capturing = false
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Removes an attachment.
|
|
228
|
+
*
|
|
229
|
+
* @param {IssueAttachment} attachment - The attachment.
|
|
230
|
+
* @example detach(attachments[0])
|
|
231
|
+
*/
|
|
232
|
+
function detach(attachment: IssueAttachment): void {
|
|
233
|
+
attachments = attachments.filter((entry) => entry !== attachment)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Sends the report through the site's sink.
|
|
238
|
+
*
|
|
239
|
+
* @example await send()
|
|
240
|
+
*/
|
|
241
|
+
async function send(): Promise<void> {
|
|
242
|
+
if (issues === null || sending) return
|
|
243
|
+
const problem = draftProblem({ title, description })
|
|
244
|
+
titleError = problem ?? undefined
|
|
245
|
+
if (problem !== null) return
|
|
246
|
+
status = { kind: 'sending' }
|
|
247
|
+
try {
|
|
248
|
+
const report = buildIssueReport(
|
|
249
|
+
{ title, description, kind, priority, labels, attachments },
|
|
250
|
+
withoutRows(context, removed),
|
|
251
|
+
)
|
|
252
|
+
status = { kind: 'sent', receipt: await issues.sink(report) }
|
|
253
|
+
title = ''
|
|
254
|
+
description = ''
|
|
255
|
+
attachments = []
|
|
256
|
+
} catch (error) {
|
|
257
|
+
status = {
|
|
258
|
+
kind: 'failed',
|
|
259
|
+
message: error instanceof Error ? error.message : 'The report was not sent.',
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Starts a new report: an empty form, and the context taken again.
|
|
266
|
+
*
|
|
267
|
+
* @example startOver()
|
|
268
|
+
*/
|
|
269
|
+
function startOver(): void {
|
|
270
|
+
title = ''
|
|
271
|
+
description = ''
|
|
272
|
+
kind = 'bug'
|
|
273
|
+
priority = 'none'
|
|
274
|
+
attachments = []
|
|
275
|
+
removed = new Set()
|
|
276
|
+
droppedLabels = new Set()
|
|
277
|
+
titleError = undefined
|
|
278
|
+
fileError = null
|
|
279
|
+
status = { kind: 'editing' }
|
|
280
|
+
refresh()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Sends on Ctrl or Cmd with Enter in the description, where Enter alone starts a new line.
|
|
285
|
+
*
|
|
286
|
+
* @param {KeyboardEvent} event - The key press.
|
|
287
|
+
* @example onKeydown(event)
|
|
288
|
+
*/
|
|
289
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
290
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
291
|
+
event.preventDefault()
|
|
292
|
+
void send()
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Attaches the files dropped on the attachments area.
|
|
298
|
+
*
|
|
299
|
+
* @param {DragEvent} event - The drop.
|
|
300
|
+
* @example onDrop(event)
|
|
301
|
+
*/
|
|
302
|
+
function onDrop(event: DragEvent): void {
|
|
303
|
+
event.preventDefault()
|
|
304
|
+
const files = event.dataTransfer?.files
|
|
305
|
+
if (files) void attachFiles(files)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A file size, in kB or MB.
|
|
310
|
+
*
|
|
311
|
+
* @param {number} bytes - The size.
|
|
312
|
+
* @returns {string} The size as the reviewer reads it.
|
|
313
|
+
* @example sizeText(1_572_864) // '1.5 MB'
|
|
314
|
+
*/
|
|
315
|
+
function sizeText(bytes: number): string {
|
|
316
|
+
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} kB`
|
|
317
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
318
|
+
}
|
|
319
|
+
</script>
|
|
320
|
+
|
|
321
|
+
{#if issues === null}
|
|
322
|
+
<Stack direction="vertical" gap="medium" padding="medium" justify="center">
|
|
323
|
+
<EmptyState
|
|
324
|
+
message="Reports are not set up on this site yet"
|
|
325
|
+
hint="A developer connects the devtools to the team's issue tracker. Each report then goes straight to it, with where you are in the experience."
|
|
326
|
+
/>
|
|
327
|
+
<Button variant="secondary" size="small" href={docsUrl} target="_blank">
|
|
328
|
+
How to set up reports
|
|
329
|
+
</Button>
|
|
330
|
+
</Stack>
|
|
331
|
+
{:else if status.kind === 'sent'}
|
|
332
|
+
<div class="sent" data-testid="devtools-issue-sent">
|
|
333
|
+
<Banner status="success" title="Report sent">
|
|
334
|
+
{#if status.receipt.reference}
|
|
335
|
+
Its reference is <strong>{status.receipt.reference}</strong>.
|
|
336
|
+
{:else}
|
|
337
|
+
The team has it.
|
|
338
|
+
{/if}
|
|
339
|
+
{#if status.receipt.url}
|
|
340
|
+
<a class="issue-link" href={status.receipt.url} target="_blank" rel="noopener noreferrer"
|
|
341
|
+
>Open the issue</a
|
|
342
|
+
>
|
|
343
|
+
{/if}
|
|
344
|
+
</Banner>
|
|
345
|
+
<div>
|
|
346
|
+
<Button variant="secondary" size="small" onclick={startOver}>Report another issue</Button>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
{:else}
|
|
350
|
+
<form
|
|
351
|
+
class="report"
|
|
352
|
+
novalidate
|
|
353
|
+
data-testid="devtools-issue-form"
|
|
354
|
+
onsubmit={(event) => {
|
|
355
|
+
event.preventDefault()
|
|
356
|
+
void send()
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{#if status.kind === 'failed'}
|
|
360
|
+
<Banner status="error" title="The report was not sent" testId="devtools-issue-failed">
|
|
361
|
+
{status.message} Your report is still here; try again.
|
|
362
|
+
</Banner>
|
|
363
|
+
{/if}
|
|
364
|
+
|
|
365
|
+
<TextField
|
|
366
|
+
label="Title"
|
|
367
|
+
name="issue-title"
|
|
368
|
+
required
|
|
369
|
+
placeholder="What is wrong, in a few words"
|
|
370
|
+
value={title}
|
|
371
|
+
error={titleError}
|
|
372
|
+
oninput={(value) => {
|
|
373
|
+
title = value
|
|
374
|
+
titleError = undefined
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
|
|
378
|
+
<div class="field">
|
|
379
|
+
<label class="label" for="field-issue-description">Description</label>
|
|
380
|
+
<textarea
|
|
381
|
+
id="field-issue-description"
|
|
382
|
+
class="textarea"
|
|
383
|
+
name="issue-description"
|
|
384
|
+
rows="5"
|
|
385
|
+
placeholder="What you did, what you saw, and what you expected to see"
|
|
386
|
+
value={description}
|
|
387
|
+
onkeydown={onKeydown}
|
|
388
|
+
oninput={(event) => {
|
|
389
|
+
if (event.target instanceof HTMLTextAreaElement) description = event.target.value
|
|
390
|
+
}}
|
|
391
|
+
></textarea>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div class="pair">
|
|
395
|
+
<Select
|
|
396
|
+
label="Kind"
|
|
397
|
+
name="issue-kind"
|
|
398
|
+
options={ISSUE_KINDS.map((option) => ({ value: option.value, label: option.label }))}
|
|
399
|
+
value={kind}
|
|
400
|
+
onchange={(value) => {
|
|
401
|
+
kind = ISSUE_KINDS.find((option) => option.value === value)?.value ?? 'bug'
|
|
402
|
+
}}
|
|
403
|
+
/>
|
|
404
|
+
<Select
|
|
405
|
+
label="Priority"
|
|
406
|
+
name="issue-priority"
|
|
407
|
+
options={ISSUE_PRIORITIES.map((option) => ({ value: option.value, label: option.label }))}
|
|
408
|
+
value={priority}
|
|
409
|
+
onchange={(value) => {
|
|
410
|
+
priority = ISSUE_PRIORITIES.find((option) => option.value === value)?.value ?? 'none'
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
{#if suggestedLabels.length > 0}
|
|
416
|
+
<div class="field">
|
|
417
|
+
<span class="label">Labels</span>
|
|
418
|
+
<div class="chips" role="group" aria-label="Labels">
|
|
419
|
+
{#each suggestedLabels as label (label)}
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
class="chip"
|
|
423
|
+
class:dropped={droppedLabels.has(label)}
|
|
424
|
+
aria-pressed={!droppedLabels.has(label)}
|
|
425
|
+
data-testid="devtools-issue-label"
|
|
426
|
+
onclick={() => toggleLabel(label)}
|
|
427
|
+
>
|
|
428
|
+
{label}
|
|
429
|
+
</button>
|
|
430
|
+
{/each}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
{/if}
|
|
434
|
+
|
|
435
|
+
<section
|
|
436
|
+
class="attachments"
|
|
437
|
+
aria-label="Screenshots and files"
|
|
438
|
+
ondragover={(event) => event.preventDefault()}
|
|
439
|
+
ondrop={onDrop}
|
|
440
|
+
>
|
|
441
|
+
<div class="attachment-actions">
|
|
442
|
+
<Button
|
|
443
|
+
variant="secondary"
|
|
444
|
+
size="small"
|
|
445
|
+
loading={capturing}
|
|
446
|
+
testId="devtools-issue-screenshot"
|
|
447
|
+
onclick={() => void takeScreenshot()}
|
|
448
|
+
>
|
|
449
|
+
Take screenshot
|
|
450
|
+
</Button>
|
|
451
|
+
<Button variant="secondary" size="small" onclick={() => fileInput?.click()}>
|
|
452
|
+
Attach file
|
|
453
|
+
</Button>
|
|
454
|
+
<span class="hint">or drop files here</span>
|
|
455
|
+
<input
|
|
456
|
+
bind:this={fileInput}
|
|
457
|
+
class="file-input"
|
|
458
|
+
type="file"
|
|
459
|
+
multiple
|
|
460
|
+
accept={ATTACHMENT_TYPES.join(',')}
|
|
461
|
+
tabindex="-1"
|
|
462
|
+
aria-hidden="true"
|
|
463
|
+
onchange={(event) => {
|
|
464
|
+
const input = event.currentTarget
|
|
465
|
+
if (!input.files) return
|
|
466
|
+
void attachFiles([...input.files]).then(() => {
|
|
467
|
+
input.value = ''
|
|
468
|
+
})
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
{#if fileError}
|
|
473
|
+
<p class="file-error" role="alert">{fileError}</p>
|
|
474
|
+
{/if}
|
|
475
|
+
{#if attachments.length > 0}
|
|
476
|
+
<ul class="files" data-testid="devtools-issue-files">
|
|
477
|
+
{#each attachments as attachment (attachment)}
|
|
478
|
+
{@const preview = previewUrl(attachment)}
|
|
479
|
+
<li class="file">
|
|
480
|
+
{#if preview}
|
|
481
|
+
<img class="thumb" src={preview} alt="" />
|
|
482
|
+
{:else}
|
|
483
|
+
<span class="thumb doc" aria-hidden="true"
|
|
484
|
+
>{attachment.filename.split('.').pop()}</span
|
|
485
|
+
>
|
|
486
|
+
{/if}
|
|
487
|
+
<span class="file-name">{attachment.filename}</span>
|
|
488
|
+
<span class="file-size">{sizeText(attachment.size)}</span>
|
|
489
|
+
<button
|
|
490
|
+
type="button"
|
|
491
|
+
class="text-button"
|
|
492
|
+
aria-label="Remove {attachment.filename}"
|
|
493
|
+
onclick={() => detach(attachment)}>Remove</button
|
|
494
|
+
>
|
|
495
|
+
</li>
|
|
496
|
+
{/each}
|
|
497
|
+
</ul>
|
|
498
|
+
{/if}
|
|
499
|
+
</section>
|
|
500
|
+
|
|
501
|
+
<Card>
|
|
502
|
+
{#snippet header()}
|
|
503
|
+
<div class="context-head">
|
|
504
|
+
<span class="context-title">Sent with the report</span>
|
|
505
|
+
<span class="context-actions">
|
|
506
|
+
<button type="button" class="text-button" onclick={refresh}>Refresh</button>
|
|
507
|
+
<CopyButton
|
|
508
|
+
text={JSON.stringify(withoutRows(context, removed), null, 2)}
|
|
509
|
+
label="Copy context"
|
|
510
|
+
/>
|
|
511
|
+
</span>
|
|
512
|
+
</div>
|
|
513
|
+
{/snippet}
|
|
514
|
+
{#if rows.length === 0}
|
|
515
|
+
<p class="empty">Nothing about the session goes with this report.</p>
|
|
516
|
+
{:else}
|
|
517
|
+
<dl class="context" data-testid="devtools-issue-context">
|
|
518
|
+
{#each rows as row (row.id)}
|
|
519
|
+
<div class="context-row" class:removed={removed.has(row.id)}>
|
|
520
|
+
<dt>{row.label}</dt>
|
|
521
|
+
<dd>{removed.has(row.id) ? 'Left out' : row.value}</dd>
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
class="text-button"
|
|
525
|
+
aria-label="{removed.has(row.id) ? 'Put back' : 'Leave out'} {row.label}"
|
|
526
|
+
onclick={() => toggleRow(row.id)}
|
|
527
|
+
>{removed.has(row.id) ? 'Put back' : 'Leave out'}</button
|
|
528
|
+
>
|
|
529
|
+
</div>
|
|
530
|
+
{/each}
|
|
531
|
+
</dl>
|
|
532
|
+
{/if}
|
|
533
|
+
{#if context.hidden.length > 0 && !removed.has('responses')}
|
|
534
|
+
<p class="privacy">
|
|
535
|
+
Email addresses, phone numbers, dates and personal details in your responses are hidden.
|
|
536
|
+
</p>
|
|
537
|
+
{/if}
|
|
538
|
+
</Card>
|
|
539
|
+
|
|
540
|
+
<div class="send">
|
|
541
|
+
<Button variant="ghost" size="small" onclick={startOver} disabled={sending}>Clear</Button>
|
|
542
|
+
<Button
|
|
543
|
+
variant="primary"
|
|
544
|
+
size="small"
|
|
545
|
+
type="submit"
|
|
546
|
+
loading={sending}
|
|
547
|
+
testId="devtools-issue-send"
|
|
548
|
+
>
|
|
549
|
+
Send report
|
|
550
|
+
</Button>
|
|
551
|
+
</div>
|
|
552
|
+
</form>
|
|
553
|
+
{/if}
|
|
554
|
+
|
|
555
|
+
<style>
|
|
556
|
+
.report,
|
|
557
|
+
.sent {
|
|
558
|
+
display: flex;
|
|
559
|
+
flex-direction: column;
|
|
560
|
+
gap: var(--space-4);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.field {
|
|
564
|
+
display: flex;
|
|
565
|
+
flex-direction: column;
|
|
566
|
+
gap: var(--space-1);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.label {
|
|
570
|
+
font-size: var(--text-sm);
|
|
571
|
+
font-weight: var(--font-medium);
|
|
572
|
+
color: var(--color-gray-900);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* Specific enough to win over the panel's monospace rule for its JSON editors. */
|
|
576
|
+
.field .textarea {
|
|
577
|
+
box-sizing: border-box;
|
|
578
|
+
width: 100%;
|
|
579
|
+
min-height: 6rem;
|
|
580
|
+
padding: var(--space-2) var(--space-3);
|
|
581
|
+
border: 1px solid var(--color-gray-300);
|
|
582
|
+
border-radius: var(--radius-md);
|
|
583
|
+
background-color: var(--color-white);
|
|
584
|
+
color: var(--color-gray-900);
|
|
585
|
+
font-family: inherit;
|
|
586
|
+
font-size: var(--text-sm);
|
|
587
|
+
line-height: 1.5;
|
|
588
|
+
resize: vertical;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.field .textarea:focus {
|
|
592
|
+
outline: none;
|
|
593
|
+
box-shadow: 0 0 0 var(--ring-width) var(--ring-color);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.pair {
|
|
597
|
+
display: grid;
|
|
598
|
+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
599
|
+
gap: var(--space-3);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.chips {
|
|
603
|
+
display: flex;
|
|
604
|
+
flex-wrap: wrap;
|
|
605
|
+
gap: var(--space-1);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.chip {
|
|
609
|
+
padding: var(--space-1) var(--space-2-5);
|
|
610
|
+
border: 1px solid var(--color-blue-600);
|
|
611
|
+
border-radius: var(--radius-full);
|
|
612
|
+
background: var(--color-blue-600);
|
|
613
|
+
color: var(--color-white);
|
|
614
|
+
font-family: inherit;
|
|
615
|
+
font-size: var(--text-xs);
|
|
616
|
+
cursor: pointer;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.chip.dropped {
|
|
620
|
+
border-color: var(--color-gray-300);
|
|
621
|
+
background: var(--color-white);
|
|
622
|
+
color: var(--color-gray-500);
|
|
623
|
+
text-decoration: line-through;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.chip:focus-visible,
|
|
627
|
+
.text-button:focus-visible {
|
|
628
|
+
outline: var(--ring-width) solid var(--ring-color);
|
|
629
|
+
outline-offset: var(--ring-offset);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.attachments {
|
|
633
|
+
display: flex;
|
|
634
|
+
flex-direction: column;
|
|
635
|
+
gap: var(--space-2);
|
|
636
|
+
padding: var(--space-3);
|
|
637
|
+
border: 1px dashed var(--color-gray-300);
|
|
638
|
+
border-radius: var(--radius-md);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.attachment-actions {
|
|
642
|
+
display: flex;
|
|
643
|
+
flex-wrap: wrap;
|
|
644
|
+
align-items: center;
|
|
645
|
+
gap: var(--space-2);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.hint {
|
|
649
|
+
color: var(--color-gray-500);
|
|
650
|
+
font-size: var(--text-xs);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.file-input {
|
|
654
|
+
display: none;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.file-error {
|
|
658
|
+
margin: 0;
|
|
659
|
+
color: var(--color-red-600);
|
|
660
|
+
font-size: var(--text-sm);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.files {
|
|
664
|
+
display: flex;
|
|
665
|
+
flex-direction: column;
|
|
666
|
+
gap: var(--space-2);
|
|
667
|
+
margin: 0;
|
|
668
|
+
padding: 0;
|
|
669
|
+
list-style: none;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.file {
|
|
673
|
+
display: grid;
|
|
674
|
+
grid-template-columns: 3rem minmax(0, 1fr) auto auto;
|
|
675
|
+
align-items: center;
|
|
676
|
+
gap: var(--space-2);
|
|
677
|
+
font-size: var(--text-sm);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.thumb {
|
|
681
|
+
width: 3rem;
|
|
682
|
+
height: 2.25rem;
|
|
683
|
+
border: 1px solid var(--color-gray-200);
|
|
684
|
+
border-radius: var(--radius-sm);
|
|
685
|
+
object-fit: cover;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.thumb.doc {
|
|
689
|
+
display: flex;
|
|
690
|
+
align-items: center;
|
|
691
|
+
justify-content: center;
|
|
692
|
+
background: var(--color-gray-50);
|
|
693
|
+
color: var(--color-gray-500);
|
|
694
|
+
font-size: var(--text-xs);
|
|
695
|
+
text-transform: uppercase;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.file-name {
|
|
699
|
+
overflow: hidden;
|
|
700
|
+
color: var(--color-gray-900);
|
|
701
|
+
text-overflow: ellipsis;
|
|
702
|
+
white-space: nowrap;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.file-size {
|
|
706
|
+
color: var(--color-gray-500);
|
|
707
|
+
font-size: var(--text-xs);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.text-button {
|
|
711
|
+
padding: 0;
|
|
712
|
+
border: 0;
|
|
713
|
+
background: none;
|
|
714
|
+
color: var(--color-blue-600);
|
|
715
|
+
font: inherit;
|
|
716
|
+
font-size: var(--text-xs);
|
|
717
|
+
cursor: pointer;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.text-button:hover {
|
|
721
|
+
text-decoration: underline;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.context-head {
|
|
725
|
+
display: flex;
|
|
726
|
+
flex: 1;
|
|
727
|
+
align-items: center;
|
|
728
|
+
justify-content: space-between;
|
|
729
|
+
gap: var(--space-2);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.context-title {
|
|
733
|
+
color: var(--color-gray-900);
|
|
734
|
+
font-size: var(--text-sm);
|
|
735
|
+
font-weight: var(--font-semibold);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.context-actions {
|
|
739
|
+
display: flex;
|
|
740
|
+
align-items: center;
|
|
741
|
+
gap: var(--space-2);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.context {
|
|
745
|
+
display: flex;
|
|
746
|
+
flex-direction: column;
|
|
747
|
+
margin: 0;
|
|
748
|
+
padding: var(--space-2) var(--space-4);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.context-row {
|
|
752
|
+
display: grid;
|
|
753
|
+
grid-template-columns: 6.5rem minmax(0, 1fr) auto;
|
|
754
|
+
align-items: baseline;
|
|
755
|
+
gap: var(--space-3);
|
|
756
|
+
padding: var(--space-1-5) 0;
|
|
757
|
+
border-bottom: 1px solid var(--color-gray-100);
|
|
758
|
+
font-size: var(--text-sm);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.context-row:last-child {
|
|
762
|
+
border-bottom: 0;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.context-row dt {
|
|
766
|
+
color: var(--color-gray-500);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.context-row dd {
|
|
770
|
+
margin: 0;
|
|
771
|
+
overflow-wrap: anywhere;
|
|
772
|
+
color: var(--color-gray-900);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.context-row.removed dd {
|
|
776
|
+
color: var(--color-gray-400);
|
|
777
|
+
font-style: italic;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.empty {
|
|
781
|
+
padding: var(--space-3) var(--space-4);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.empty,
|
|
785
|
+
.privacy {
|
|
786
|
+
margin: 0;
|
|
787
|
+
color: var(--color-gray-500);
|
|
788
|
+
font-size: var(--text-xs);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.privacy {
|
|
792
|
+
padding: 0 var(--space-4) var(--space-3);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
.issue-link {
|
|
796
|
+
color: inherit;
|
|
797
|
+
font-weight: var(--font-medium);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.send {
|
|
801
|
+
display: flex;
|
|
802
|
+
justify-content: flex-end;
|
|
803
|
+
gap: var(--space-2);
|
|
804
|
+
}
|
|
805
|
+
</style>
|