@lightworkai.official/debug-capture 0.12.8 → 0.14.0
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/README.md +17 -0
- package/dist/debug-capture.js +25 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +36 -16
- package/dist/requests.d.ts +30 -0
- package/dist/submit.d.ts +11 -0
- package/dist/ui/strings.d.ts +26 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +1 -1
- package/src/embed.ts +7 -1
- package/src/index.ts +2 -0
- package/src/install.ts +10 -0
- package/src/requests.ts +109 -0
- package/src/submit.ts +19 -16
- package/src/ui/annotator.ts +2 -0
- package/src/ui/element.ts +242 -52
- package/src/ui/strings.ts +73 -0
- package/src/ui/styles.ts +22 -2
package/src/install.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { launch, onCrash } from "./reporter";
|
|
|
13
13
|
import { strings } from "./ui/strings";
|
|
14
14
|
import { showToast } from "./ui/toast";
|
|
15
15
|
import { defineReporterElement, REPORTER_TAG } from "./ui/element";
|
|
16
|
+
import { loadRequestTypes } from "./requests";
|
|
16
17
|
|
|
17
18
|
let armed = false;
|
|
18
19
|
|
|
@@ -59,4 +60,13 @@ export function installDebugCapture(options: InstallOptions = {}): void {
|
|
|
59
60
|
if (!document.querySelector(REPORTER_TAG)) {
|
|
60
61
|
document.body.append(document.createElement(REPORTER_TAG));
|
|
61
62
|
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* What the desk opens to its reporters, read once the page has settled —
|
|
66
|
+
* so a press of the button can offer ขอให้ทีมดำเนินการ without waiting
|
|
67
|
+
* (requests.ts). Late on purpose: it is never what the page came for.
|
|
68
|
+
*/
|
|
69
|
+
const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
|
|
70
|
+
if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
|
|
71
|
+
else setTimeout(() => void loadRequestTypes(), 2000);
|
|
62
72
|
}
|
package/src/requests.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ขอให้ทีมดำเนินการ — asking the team for something, from the app.
|
|
3
|
+
*
|
|
4
|
+
* A report is "something broke": it carries a screenshot and the page's logs,
|
|
5
|
+
* because those are what make it reproducible. A request is "please do this"
|
|
6
|
+
* — reset my password, fix this record — and the logs do not help, but the
|
|
7
|
+
* picture of the page often does (the record to fix is on it): a request
|
|
8
|
+
* keeps the picture and the page's address, and sends no bundle. The desk
|
|
9
|
+
* decides what may be asked (its request types opened to reporters, GET
|
|
10
|
+
* /ingest/request-types); where it opens none, the widget is exactly what it
|
|
11
|
+
* was.
|
|
12
|
+
*
|
|
13
|
+
* Asked IN the report form, never before it (docs/WIDGET-ASK-PLAN.md): the
|
|
14
|
+
* press takes the screen as always, and the form offers เป็นคำขอ? whenever
|
|
15
|
+
* this read has landed. Installing the widget starts the read.
|
|
16
|
+
*/
|
|
17
|
+
import { getConfig, appInfo, resolveModule } from "./config";
|
|
18
|
+
import { compressScreenshot } from "./screenshot";
|
|
19
|
+
import { failureOf, identityHeader, ReportError, type FiledTicket } from "./submit";
|
|
20
|
+
|
|
21
|
+
export interface RequestTypeOption {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
/** How long the team means to take, in hours — null when it has not said. */
|
|
26
|
+
targetHours: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let known: RequestTypeOption[] | null = null;
|
|
30
|
+
let reading: Promise<RequestTypeOption[]> | null = null;
|
|
31
|
+
|
|
32
|
+
const hostOf = () => getConfig().host.replace(/\/$/, "");
|
|
33
|
+
|
|
34
|
+
/** What the desk opens to its reporters, as far as is known right now — null before the first read lands. */
|
|
35
|
+
export function requestTypesNow(): RequestTypeOption[] | null {
|
|
36
|
+
return known;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ask the desk once. Never throws: a desk that answers nothing useful — an
|
|
41
|
+
* older one without the endpoint (404), a network blip — offers nothing, and
|
|
42
|
+
* the widget stays a report button.
|
|
43
|
+
*/
|
|
44
|
+
export function loadRequestTypes(): Promise<RequestTypeOption[]> {
|
|
45
|
+
if (reading) return reading;
|
|
46
|
+
reading = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${hostOf()}/ingest/request-types`, { headers: { "x-realm-key": getConfig().realmKey } });
|
|
49
|
+
const body = res.ok ? ((await res.json()) as { types?: unknown }) : null;
|
|
50
|
+
known = Array.isArray(body?.types) ? body.types.filter(isOption) : [];
|
|
51
|
+
} catch {
|
|
52
|
+
known = [];
|
|
53
|
+
// A failed read may be tried again on the next launch.
|
|
54
|
+
reading = null;
|
|
55
|
+
}
|
|
56
|
+
return known ?? [];
|
|
57
|
+
})();
|
|
58
|
+
return reading;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isOption(t: unknown): t is RequestTypeOption {
|
|
62
|
+
const o = t as Partial<RequestTypeOption> | null;
|
|
63
|
+
return !!o && typeof o.id === "string" && typeof o.name === "string";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forget what was read — for tests, and for a host that changes realm. */
|
|
67
|
+
export function resetRequestTypes(): void {
|
|
68
|
+
known = null;
|
|
69
|
+
reading = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Send a request. The same door as a report (POST /ingest/tickets), with the
|
|
74
|
+
* type it asks for — the desk starts its steps and never folds two people's
|
|
75
|
+
* same request into one. Who is asking comes from the identity token when the
|
|
76
|
+
* host has one, as for a report.
|
|
77
|
+
*/
|
|
78
|
+
export async function submitRequest(input: { ticketTypeId: string; title: string; description: string; screenshotDataUrl?: string | null }): Promise<FiledTicket> {
|
|
79
|
+
const config = getConfig();
|
|
80
|
+
let user: { email?: string | null; fullName?: string | null } = {};
|
|
81
|
+
try {
|
|
82
|
+
user = config.user?.() ?? {};
|
|
83
|
+
} catch {
|
|
84
|
+
user = {};
|
|
85
|
+
}
|
|
86
|
+
const here = typeof window === "undefined" ? null : window.location;
|
|
87
|
+
const body = {
|
|
88
|
+
title: input.title,
|
|
89
|
+
description: input.description.trim() || undefined,
|
|
90
|
+
ticketTypeId: input.ticketTypeId,
|
|
91
|
+
appName: appInfo().name,
|
|
92
|
+
category: here ? resolveModule(here.pathname)?.label : undefined,
|
|
93
|
+
routeUrl: here?.href,
|
|
94
|
+
priority: "MEDIUM",
|
|
95
|
+
requesterEmail: user.email ?? undefined,
|
|
96
|
+
requesterName: user.fullName ?? undefined,
|
|
97
|
+
// The picture of the page, resized as a report's is — never the logs.
|
|
98
|
+
attachments: input.screenshotDataUrl ? [{ dataUri: await compressScreenshot(input.screenshotDataUrl), filename: "screenshot.jpg" }] : undefined,
|
|
99
|
+
};
|
|
100
|
+
const res = await fetch(`${hostOf()}/ingest/tickets`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json", "x-realm-key": config.realmKey, ...(await identityHeader()) },
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
}).catch(() => {
|
|
105
|
+
throw new ReportError("request never left the browser", "offline");
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) throw await failureOf(res);
|
|
108
|
+
return (await res.json()) as FiledTicket;
|
|
109
|
+
}
|
package/src/submit.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function bundleAttachment(bundle: DebugBundle): { dataUri: string; filena
|
|
|
76
76
|
* endpoint is having a bad day — a bug report is the last thing that should
|
|
77
77
|
* fail because of a side quest.
|
|
78
78
|
*/
|
|
79
|
-
async function identityHeader(): Promise<Record<string, string>> {
|
|
79
|
+
export async function identityHeader(): Promise<Record<string, string>> {
|
|
80
80
|
const provider = getConfig().identity;
|
|
81
81
|
if (!provider) return {};
|
|
82
82
|
try {
|
|
@@ -220,20 +220,23 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
220
220
|
throw new ReportError("report never left the browser", "offline");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
if (!res.ok)
|
|
224
|
-
// The ingest endpoint answers 429 with a retry-after; anything else carries
|
|
225
|
-
// an { error } body. Surface whichever it is — "failed" alone sends someone
|
|
226
|
-
// reading a network tab they may not be able to open.
|
|
227
|
-
const retryAfter = res.headers.get("retry-after");
|
|
228
|
-
const detail = await res
|
|
229
|
-
.json()
|
|
230
|
-
.then((b: { error?: string }) => b.error)
|
|
231
|
-
.catch(() => null);
|
|
232
|
-
if (res.status === 429) throw new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
|
|
233
|
-
// 502/503/504 come from whatever is in front of the desk while it is being
|
|
234
|
-
// replaced, and the body is that thing's error page, not a sentence for us.
|
|
235
|
-
if (UNAVAILABLE.has(res.status)) throw new ReportError("desk unavailable", "unavailable");
|
|
236
|
-
throw new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
|
|
237
|
-
}
|
|
223
|
+
if (!res.ok) throw await failureOf(res);
|
|
238
224
|
return (await res.json()) as FiledTicket;
|
|
239
225
|
}
|
|
226
|
+
|
|
227
|
+
/** The send that did not go through, as one of the four failures — shared with a request's send (requests.ts). */
|
|
228
|
+
export async function failureOf(res: Response): Promise<ReportError> {
|
|
229
|
+
// The ingest endpoint answers 429 with a retry-after; anything else carries
|
|
230
|
+
// an { error } body. Surface whichever it is — "failed" alone sends someone
|
|
231
|
+
// reading a network tab they may not be able to open.
|
|
232
|
+
const retryAfter = res.headers.get("retry-after");
|
|
233
|
+
const detail = await res
|
|
234
|
+
.json()
|
|
235
|
+
.then((b: { error?: string }) => b.error)
|
|
236
|
+
.catch(() => null);
|
|
237
|
+
if (res.status === 429) return new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
|
|
238
|
+
// 502/503/504 come from whatever is in front of the desk while it is being
|
|
239
|
+
// replaced, and the body is that thing's error page, not a sentence for us.
|
|
240
|
+
if (UNAVAILABLE.has(res.status)) return new ReportError("desk unavailable", "unavailable");
|
|
241
|
+
return new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
|
|
242
|
+
}
|
package/src/ui/annotator.ts
CHANGED
|
@@ -621,6 +621,8 @@ export function createAnnotator(
|
|
|
621
621
|
event.preventDefault();
|
|
622
622
|
deleteSelected();
|
|
623
623
|
} else if (event.key === "Escape") {
|
|
624
|
+
// Handled: the dialog around the picture must not also close on it.
|
|
625
|
+
event.preventDefault();
|
|
624
626
|
selected = -1;
|
|
625
627
|
render();
|
|
626
628
|
}
|
package/src/ui/element.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildBundle } from "../bundle";
|
|
|
13
13
|
import { getConfig } from "../config";
|
|
14
14
|
import { onLaunch } from "../reporter";
|
|
15
15
|
import { captureScreen } from "../screenshot";
|
|
16
|
+
import { loadRequestTypes, requestTypesNow, submitRequest, type RequestTypeOption } from "../requests";
|
|
16
17
|
import { ReportError, submitBundle } from "../submit";
|
|
17
18
|
import type { DebugBundle, DebugReason, ErroredQuery } from "../types";
|
|
18
19
|
import { createAnnotator, type Annotator, type AnnotatorTool } from "./annotator";
|
|
@@ -23,6 +24,19 @@ import { CSS } from "./styles";
|
|
|
23
24
|
|
|
24
25
|
export const REPORTER_TAG = "lw-debug-reporter";
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What was typed and not sent, kept for the next press in this tab — Gmail's
|
|
29
|
+
* compose: closing keeps the draft (DESIGN §7). ✕, ปิด and Escape all close;
|
|
30
|
+
* none of them is a decision to throw words away. Cleared when it is sent.
|
|
31
|
+
*/
|
|
32
|
+
interface Draft {
|
|
33
|
+
title: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
asking: boolean;
|
|
36
|
+
typeId: string | null;
|
|
37
|
+
}
|
|
38
|
+
let draft: Draft | null = null;
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* Failed data-layer queries the host wants on the record — react-query, Apollo,
|
|
28
42
|
* whatever it uses. Set by the host; read at capture time. A callback rather
|
|
@@ -81,6 +95,10 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
81
95
|
private annotator: Annotator | null = null;
|
|
82
96
|
private bundle: DebugBundle | null = null;
|
|
83
97
|
private busy = false;
|
|
98
|
+
/** The open form's words, read when it closes unsent. */
|
|
99
|
+
private keep: (() => Draft | null) | null = null;
|
|
100
|
+
/** What had the focus before the press — the host's button, usually — given back on close. */
|
|
101
|
+
private returnFocus: HTMLElement | null = null;
|
|
84
102
|
|
|
85
103
|
constructor() {
|
|
86
104
|
super();
|
|
@@ -94,6 +112,14 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
94
112
|
// shortcuts must not hear it (ui/keys). Once, on the root — it outlives
|
|
95
113
|
// every open and close.
|
|
96
114
|
containKeys(this.root);
|
|
115
|
+
// Escape closes the dialog — unless something inside it took the key
|
|
116
|
+
// first (a note being typed on the picture, a shape being let go of).
|
|
117
|
+
this.root.addEventListener("keydown", (event) => {
|
|
118
|
+
const e = event as KeyboardEvent;
|
|
119
|
+
if (e.key !== "Escape" || e.defaultPrevented || !this.root.querySelector("[role=dialog]")) return;
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
this.close();
|
|
122
|
+
});
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
connectedCallback(): void {
|
|
@@ -118,8 +144,24 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
118
144
|
private async open(reason: DebugReason): Promise<void> {
|
|
119
145
|
if (this.busy) return;
|
|
120
146
|
this.busy = true;
|
|
147
|
+
const active = document.activeElement;
|
|
148
|
+
this.returnFocus = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
121
149
|
const t = strings(getConfig().locale);
|
|
150
|
+
/*
|
|
151
|
+
* One door: the press takes the screen and opens the report form,
|
|
152
|
+
* always. Asking the team is a choice made IN that form — เป็นคำขอ?
|
|
153
|
+
* เลือกเรื่องที่ขอ — never a door before it (docs/WIDGET-ASK-PLAN.md): a
|
|
154
|
+
* chooser here cost every press one more click to serve the one in
|
|
155
|
+
* twelve that asks, and a press before the offer was read had no way to
|
|
156
|
+
* ask at all. The offer is read without waiting — the share needs this
|
|
157
|
+
* click — and the form shows the door whenever it lands.
|
|
158
|
+
*/
|
|
159
|
+
if (requestTypesNow() === null) void loadRequestTypes();
|
|
160
|
+
await this.report(reason, t);
|
|
161
|
+
}
|
|
122
162
|
|
|
163
|
+
/** A report: the screen first, then the form — see open(). Called inside a click, with nothing awaited before the capture. */
|
|
164
|
+
private async report(reason: DebugReason, t: Strings): Promise<void> {
|
|
123
165
|
/*
|
|
124
166
|
* Capture BEFORE anything of ours is on screen.
|
|
125
167
|
*
|
|
@@ -144,15 +186,22 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
144
186
|
screenshotMethod: shot.method,
|
|
145
187
|
erroredQueries: safeQueries(),
|
|
146
188
|
});
|
|
147
|
-
this.renderForm(t);
|
|
189
|
+
this.renderForm(t, reason);
|
|
148
190
|
}
|
|
149
191
|
|
|
150
|
-
|
|
192
|
+
/** Closed unsent, what was typed is kept for the next press; sent, it is gone. */
|
|
193
|
+
private close(sent = false): void {
|
|
194
|
+
if (sent) draft = null;
|
|
195
|
+
else if (this.keep) draft = this.keep() ?? draft;
|
|
196
|
+
this.keep = null;
|
|
151
197
|
this.annotator?.destroy();
|
|
152
198
|
this.annotator = null;
|
|
153
199
|
this.bundle = null;
|
|
154
200
|
this.busy = false;
|
|
155
201
|
this.root.replaceChildren();
|
|
202
|
+
const back = this.returnFocus;
|
|
203
|
+
this.returnFocus = null;
|
|
204
|
+
if (back?.isConnected) back.focus();
|
|
156
205
|
}
|
|
157
206
|
|
|
158
207
|
private shell(t: Strings): HTMLElement {
|
|
@@ -179,88 +228,220 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
179
228
|
panel.append(head, el("p", "status", message));
|
|
180
229
|
}
|
|
181
230
|
|
|
182
|
-
|
|
231
|
+
/**
|
|
232
|
+
* The one dialog: a report — or, where the desk offers request types to
|
|
233
|
+
* its reporters, a request, the same dialog turned (docs/WIDGET-ASK-PLAN.md;
|
|
234
|
+
* Zendesk's and Freshdesk's widgets keep the kind inside the one form).
|
|
235
|
+
* What was typed, the marked picture and the page cross over; a request
|
|
236
|
+
* leaves the page's logs behind — they are a problem's evidence, not an
|
|
237
|
+
* ask's — and its picture can be taken off.
|
|
238
|
+
*/
|
|
239
|
+
private renderForm(t: Strings, reason: DebugReason): void {
|
|
183
240
|
const bundle = this.bundle;
|
|
184
241
|
if (!bundle) return;
|
|
185
242
|
const panel = this.shell(t);
|
|
243
|
+
let mode: "problem" | "request" = "problem";
|
|
244
|
+
let chosen: RequestTypeOption | null = null;
|
|
245
|
+
let select: HTMLSelectElement | null = null;
|
|
186
246
|
|
|
187
247
|
const head = el("div", "head");
|
|
248
|
+
const heading = el("h2", "", t.title);
|
|
188
249
|
const closeBtn = button("✕", "icon");
|
|
189
250
|
closeBtn.setAttribute("aria-label", t.close);
|
|
190
251
|
closeBtn.addEventListener("click", () => this.close());
|
|
191
|
-
head.append(
|
|
252
|
+
head.append(heading, closeBtn);
|
|
253
|
+
|
|
254
|
+
// The other door: a quiet line under the heading, only where the desk offers something to ask.
|
|
255
|
+
const door = el("p", "door");
|
|
256
|
+
door.hidden = true;
|
|
257
|
+
const doorText = el("span");
|
|
258
|
+
const doorBtn = button("", "link");
|
|
259
|
+
doorBtn.dataset.switchKind = "";
|
|
260
|
+
door.append(doorText, doorBtn);
|
|
261
|
+
|
|
262
|
+
// What is asked for — the request's own rows.
|
|
263
|
+
const ask = el("div", "ask");
|
|
264
|
+
ask.hidden = true;
|
|
265
|
+
const typeHolder = el("div");
|
|
266
|
+
const about = el("p", "status", "");
|
|
267
|
+
ask.append(labelFor(t.requestTypeLabel, "", true), typeHolder, about);
|
|
268
|
+
const describe = () => {
|
|
269
|
+
if (!chosen) return;
|
|
270
|
+
about.textContent = [chosen.description, chosen.targetHours ? t.requestTarget.replace("{time}", hoursWords(t, chosen.targetHours)) : ""].filter(Boolean).join(" · ");
|
|
271
|
+
};
|
|
192
272
|
|
|
193
273
|
const summary = document.createElement("input");
|
|
194
274
|
summary.type = "text";
|
|
195
|
-
|
|
196
|
-
const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
|
|
197
|
-
|
|
275
|
+
const summaryLabel = labelFor("");
|
|
198
276
|
const detail = document.createElement("textarea");
|
|
199
|
-
|
|
277
|
+
const detailLabel = labelFor("");
|
|
200
278
|
|
|
201
279
|
const meta = el("div", "meta");
|
|
202
|
-
meta.innerHTML = "";
|
|
203
280
|
const { context } = bundle;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// with the bundle.
|
|
211
|
-
//
|
|
212
|
-
// The replay counts as one more thing captured, listed with the rest.
|
|
213
|
-
// It is the support team's debug log, taken the way the network and
|
|
214
|
-
// console logs are — not a choice put to the reporter. There was a
|
|
215
|
-
// tick box here once; the platform owner's call was that a report
|
|
216
|
-
// should always carry what makes it reproducible.
|
|
217
|
-
[
|
|
218
|
-
t.captured,
|
|
219
|
-
`${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
|
|
220
|
-
bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
|
|
221
|
-
}`,
|
|
222
|
-
],
|
|
223
|
-
] as [string, string][]) {
|
|
281
|
+
const captured = `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
|
|
282
|
+
bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
|
|
283
|
+
}`;
|
|
284
|
+
// textContent, not innerHTML: every one of these values is attacker-
|
|
285
|
+
// influenced (a URL, a display name, a captured label).
|
|
286
|
+
const metaRow = (key: string, value: string) => {
|
|
224
287
|
const row = document.createElement("div");
|
|
225
|
-
// textContent, not innerHTML: every one of these values is attacker-
|
|
226
|
-
// influenced (a URL, a display name, a captured label).
|
|
227
288
|
const b = document.createElement("b");
|
|
228
289
|
b.textContent = `${key}: `;
|
|
229
|
-
|
|
290
|
+
const v = document.createTextNode(value);
|
|
291
|
+
row.append(b, v);
|
|
230
292
|
meta.append(row);
|
|
231
|
-
|
|
293
|
+
return { b, v };
|
|
294
|
+
};
|
|
295
|
+
metaRow(t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`);
|
|
296
|
+
metaRow(t.page, context.route.href);
|
|
297
|
+
// No version row: it is for the person reading, not the one filing — it
|
|
298
|
+
// still travels with the bundle. The replay is listed with the rest of
|
|
299
|
+
// what was captured: the support team's debug log, not a choice put to
|
|
300
|
+
// the reporter.
|
|
301
|
+
const logs = metaRow(t.captured, captured);
|
|
302
|
+
|
|
303
|
+
// A request's picture is its own to keep or take off; a report always carries what it captured.
|
|
304
|
+
const shotChoice = document.createElement("label");
|
|
305
|
+
shotChoice.className = "shot-choice";
|
|
306
|
+
shotChoice.hidden = true;
|
|
307
|
+
const withShot = document.createElement("input");
|
|
308
|
+
withShot.type = "checkbox";
|
|
309
|
+
withShot.checked = true;
|
|
310
|
+
withShot.dataset.requestShot = "";
|
|
311
|
+
shotChoice.append(withShot, document.createTextNode(` ${t.requestWithShot}`));
|
|
312
|
+
const shot = bundle.screenshotDataUrl ? this.screenshotSection(t, bundle.screenshotDataUrl) : [el("p", "status", t.noScreenshot)];
|
|
313
|
+
// What to mark on the picture: the problem, or what is asked for.
|
|
314
|
+
const markWhat = bundle.screenshotDataUrl ? shot[0]! : null;
|
|
315
|
+
if (!bundle.screenshotDataUrl) shotChoice.replaceChildren();
|
|
232
316
|
|
|
233
317
|
const status = el("p", "status", "");
|
|
234
318
|
const submitBtn = button(t.submit, "primary");
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
319
|
+
const ready = () => {
|
|
320
|
+
submitBtn.disabled = summary.value.trim().length === 0 || (mode === "request" && !chosen);
|
|
321
|
+
};
|
|
322
|
+
summary.addEventListener("input", ready);
|
|
323
|
+
|
|
324
|
+
const apply = () => {
|
|
325
|
+
const asking = mode === "request";
|
|
326
|
+
heading.textContent = asking ? t.requestTitle : t.title;
|
|
327
|
+
panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
|
|
328
|
+
doorText.textContent = `${asking ? t.problemInstead : t.askInstead} `;
|
|
329
|
+
doorBtn.textContent = asking ? t.problemInsteadAction : t.askInsteadAction;
|
|
330
|
+
ask.hidden = !asking;
|
|
331
|
+
fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
|
|
332
|
+
summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
|
|
333
|
+
fillLabel(detailLabel, asking ? t.requestDetailLabel : t.detailLabel);
|
|
334
|
+
detail.placeholder = asking ? t.requestDetailPlaceholder : t.detailPlaceholder;
|
|
335
|
+
logs.b.textContent = `${asking ? t.requestLeaves : t.captured}: `;
|
|
336
|
+
logs.v.textContent = asking ? t.requestLeavesWhat : captured;
|
|
337
|
+
shotChoice.hidden = !asking || !bundle.screenshotDataUrl;
|
|
338
|
+
if (markWhat) fillLabel(markWhat, asking ? t.requestAnnotateHint : t.annotateHint);
|
|
339
|
+
for (const node of shot) node.classList.toggle("dropped", asking && !withShot.checked);
|
|
340
|
+
submitBtn.textContent = asking ? t.requestSubmit : t.submit;
|
|
341
|
+
ready();
|
|
342
|
+
};
|
|
343
|
+
withShot.addEventListener("change", apply);
|
|
344
|
+
doorBtn.addEventListener("click", () => {
|
|
345
|
+
mode = mode === "problem" ? "request" : "problem";
|
|
346
|
+
apply();
|
|
347
|
+
(mode === "request" ? (select ?? summary) : summary).focus();
|
|
238
348
|
});
|
|
239
349
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
350
|
+
/*
|
|
351
|
+
* What the desk offers, as soon as it is known: now, or when the read
|
|
352
|
+
* lands with the form already open. Only on a press by hand — a crash's
|
|
353
|
+
* invitation is a problem by definition.
|
|
354
|
+
*/
|
|
355
|
+
const offer = (types: RequestTypeOption[]) => {
|
|
356
|
+
if (this.bundle !== bundle || reason.type !== "manual" || !types.length) return;
|
|
357
|
+
chosen = types[0]!;
|
|
358
|
+
if (types.length > 1) {
|
|
359
|
+
select = document.createElement("select");
|
|
360
|
+
select.dataset.requestType = "";
|
|
361
|
+
for (const option of types) {
|
|
362
|
+
const o = document.createElement("option");
|
|
363
|
+
o.value = option.id;
|
|
364
|
+
o.textContent = option.name;
|
|
365
|
+
select.append(o);
|
|
366
|
+
}
|
|
367
|
+
select.addEventListener("change", () => {
|
|
368
|
+
chosen = types.find((x) => x.id === select!.value) ?? chosen;
|
|
369
|
+
describe();
|
|
370
|
+
});
|
|
371
|
+
typeHolder.replaceChildren(select);
|
|
372
|
+
} else typeHolder.replaceChildren(el("p", "ask-type", chosen.name));
|
|
373
|
+
describe();
|
|
374
|
+
door.hidden = false;
|
|
375
|
+
};
|
|
376
|
+
/*
|
|
377
|
+
* What was typed last time and not sent comes back, said so — with a
|
|
378
|
+
* way to clear it. A request goes back to being one once its type is
|
|
379
|
+
* offered again.
|
|
380
|
+
*/
|
|
381
|
+
const restored = draft && (draft.title || draft.detail) ? draft : null;
|
|
382
|
+
const again = el("p", "draft");
|
|
383
|
+
again.hidden = !restored;
|
|
384
|
+
if (restored) {
|
|
385
|
+
summary.value = restored.title;
|
|
386
|
+
detail.value = restored.detail;
|
|
387
|
+
const clear = button(t.draftClear, "link");
|
|
388
|
+
clear.dataset.draftClear = "";
|
|
389
|
+
clear.addEventListener("click", () => {
|
|
390
|
+
summary.value = "";
|
|
391
|
+
detail.value = "";
|
|
392
|
+
draft = null;
|
|
393
|
+
again.hidden = true;
|
|
394
|
+
ready();
|
|
395
|
+
summary.focus();
|
|
396
|
+
});
|
|
397
|
+
again.append(document.createTextNode(`${t.draftRestored} · `), clear);
|
|
398
|
+
}
|
|
399
|
+
const known = requestTypesNow();
|
|
400
|
+
const offerAndRestore = (types: RequestTypeOption[]) => {
|
|
401
|
+
offer(types);
|
|
402
|
+
if (!restored?.asking || door.hidden) return;
|
|
403
|
+
const was = types.find((x) => x.id === restored.typeId);
|
|
404
|
+
if (was) {
|
|
405
|
+
chosen = was;
|
|
406
|
+
if (select) select.value = was.id;
|
|
407
|
+
describe();
|
|
408
|
+
}
|
|
409
|
+
mode = "request";
|
|
410
|
+
apply();
|
|
411
|
+
};
|
|
412
|
+
if (known) offerAndRestore(known);
|
|
413
|
+
else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
|
|
414
|
+
this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
|
|
243
415
|
|
|
416
|
+
panel.append(head, door, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
|
|
244
417
|
const foot = el("div", "foot");
|
|
245
418
|
const cancel = button(t.close);
|
|
246
419
|
cancel.addEventListener("click", () => this.close());
|
|
247
420
|
foot.append(cancel, status, el("span", "grow"), submitBtn);
|
|
248
421
|
panel.append(foot);
|
|
422
|
+
apply();
|
|
249
423
|
|
|
250
424
|
submitBtn.addEventListener("click", async () => {
|
|
251
425
|
submitBtn.disabled = true;
|
|
252
426
|
status.dataset.tone = "";
|
|
253
427
|
status.textContent = t.submitting;
|
|
428
|
+
// Take the annotated image, not the raw capture — the drawing IS the
|
|
429
|
+
// report for most people.
|
|
430
|
+
const picture = () => (this.annotator?.hasShapes() ? this.annotator.toDataUrl() : bundle.screenshotDataUrl);
|
|
254
431
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
432
|
+
if (mode === "request" && chosen) {
|
|
433
|
+
const filed = await submitRequest({
|
|
434
|
+
ticketTypeId: chosen.id,
|
|
435
|
+
title: summary.value.trim(),
|
|
436
|
+
description: detail.value,
|
|
437
|
+
screenshotDataUrl: withShot.checked ? picture() : null,
|
|
438
|
+
});
|
|
439
|
+
const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
|
|
440
|
+
this.close(true);
|
|
441
|
+
if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const filed = await submitBundle(summary.value.trim(), { ...bundle, note: detail.value, screenshotDataUrl: picture() });
|
|
264
445
|
// Cancelable: a host with its own toast system calls preventDefault()
|
|
265
446
|
// and shows its own. Ours is the default, not the only option.
|
|
266
447
|
const announced = this.dispatchEvent(
|
|
@@ -274,7 +455,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
274
455
|
// Close first, then announce. The dialog is what the user was looking
|
|
275
456
|
// at, and a confirmation written into a panel that then closes says
|
|
276
457
|
// nothing — which is why filing a ticket appeared to do nothing.
|
|
277
|
-
this.close();
|
|
458
|
+
this.close(true);
|
|
278
459
|
if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
|
|
279
460
|
} catch (error) {
|
|
280
461
|
const message = failureMessage(error, t);
|
|
@@ -285,7 +466,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
285
466
|
showToast(message, "error");
|
|
286
467
|
// The dialog stays open and nothing is cleared: what they wrote is
|
|
287
468
|
// still in the box, and pressing again is the right thing to do.
|
|
288
|
-
|
|
469
|
+
ready();
|
|
289
470
|
}
|
|
290
471
|
});
|
|
291
472
|
|
|
@@ -360,6 +541,11 @@ function el(tag: string, className = "", text = ""): HTMLElement {
|
|
|
360
541
|
return node;
|
|
361
542
|
}
|
|
362
543
|
|
|
544
|
+
/** "3 วัน" or "4 ชั่วโมง" — the way the desk's setting offers it. */
|
|
545
|
+
function hoursWords(t: Strings, h: number): string {
|
|
546
|
+
return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
|
|
547
|
+
}
|
|
548
|
+
|
|
363
549
|
function button(text: string, className = ""): HTMLButtonElement {
|
|
364
550
|
const node = document.createElement("button");
|
|
365
551
|
node.type = "button";
|
|
@@ -369,8 +555,12 @@ function button(text: string, className = ""): HTMLButtonElement {
|
|
|
369
555
|
}
|
|
370
556
|
|
|
371
557
|
function labelFor(text: string, hint = "", required = false): HTMLElement {
|
|
372
|
-
|
|
373
|
-
|
|
558
|
+
return fillLabel(document.createElement("label"), text, hint, required);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A label's words, written again when the dialog turns from a report to a request. */
|
|
562
|
+
function fillLabel(label: HTMLElement, text: string, hint = "", required = false): HTMLElement {
|
|
563
|
+
label.replaceChildren(document.createTextNode(text));
|
|
374
564
|
if (required) {
|
|
375
565
|
const star = document.createElement("span");
|
|
376
566
|
star.className = "req";
|