@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/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
  }
@@ -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
+ }
@@ -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
- private close(): void {
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
- private renderForm(t: Strings): void {
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(el("h2", "", t.title), closeBtn);
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
- summary.placeholder = t.summaryPlaceholder;
196
- const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
197
-
275
+ const summaryLabel = labelFor("");
198
276
  const detail = document.createElement("textarea");
199
- detail.placeholder = t.detailPlaceholder;
277
+ const detailLabel = labelFor("");
200
278
 
201
279
  const meta = el("div", "meta");
202
- meta.innerHTML = "";
203
280
  const { context } = bundle;
204
- for (const [key, value] of [
205
- [t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`],
206
- [t.page, context.route.href],
207
- // No version row. It read "vundefined · undefined" once version and
208
- // environment became optional, and it was telling the person filing the
209
- // report something only the person reading it needs — both still travel
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
- row.append(b, document.createTextNode(value));
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
- submitBtn.disabled = true;
236
- summary.addEventListener("input", () => {
237
- submitBtn.disabled = summary.value.trim().length === 0;
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
- panel.append(head, summaryLabel, summary, labelFor(t.detailLabel), detail, meta);
241
- if (bundle.screenshotDataUrl) panel.append(...this.screenshotSection(t, bundle.screenshotDataUrl));
242
- else panel.append(el("p", "status", t.noScreenshot));
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
- const filed = await submitBundle(summary.value.trim(), {
256
- ...bundle,
257
- note: detail.value,
258
- // Take the annotated image, not the raw capture — the drawing IS the
259
- // report for most people.
260
- screenshotDataUrl: this.annotator?.hasShapes()
261
- ? this.annotator.toDataUrl()
262
- : bundle.screenshotDataUrl,
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
- submitBtn.disabled = false;
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
- const label = document.createElement("label");
373
- label.append(document.createTextNode(text));
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";