@lightworkai.official/debug-capture 0.13.0 → 0.14.1
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 +19 -0
- package/dist/debug-capture.js +54 -15
- package/dist/index.mjs +65 -26
- package/dist/requests.d.ts +1 -0
- package/dist/ui/strings.d.ts +13 -5
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +1 -1
- package/src/requests.ts +11 -8
- package/src/ui/annotator.ts +2 -0
- package/src/ui/element.ts +317 -178
- package/src/ui/strings.ts +36 -17
- package/src/ui/styles.ts +50 -11
package/src/ui/element.ts
CHANGED
|
@@ -24,6 +24,19 @@ import { CSS } from "./styles";
|
|
|
24
24
|
|
|
25
25
|
export const REPORTER_TAG = "lw-debug-reporter";
|
|
26
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
|
+
|
|
27
40
|
/**
|
|
28
41
|
* Failed data-layer queries the host wants on the record — react-query, Apollo,
|
|
29
42
|
* whatever it uses. Set by the host; read at capture time. A callback rather
|
|
@@ -82,6 +95,10 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
82
95
|
private annotator: Annotator | null = null;
|
|
83
96
|
private bundle: DebugBundle | null = null;
|
|
84
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;
|
|
85
102
|
|
|
86
103
|
constructor() {
|
|
87
104
|
super();
|
|
@@ -95,6 +112,14 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
95
112
|
// shortcuts must not hear it (ui/keys). Once, on the root — it outlives
|
|
96
113
|
// every open and close.
|
|
97
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
|
+
});
|
|
98
123
|
}
|
|
99
124
|
|
|
100
125
|
connectedCallback(): void {
|
|
@@ -119,23 +144,19 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
119
144
|
private async open(reason: DebugReason): Promise<void> {
|
|
120
145
|
if (this.busy) return;
|
|
121
146
|
this.busy = true;
|
|
147
|
+
const active = document.activeElement;
|
|
148
|
+
this.returnFocus = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
122
149
|
const t = strings(getConfig().locale);
|
|
123
|
-
|
|
124
150
|
/*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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.
|
|
130
158
|
*/
|
|
131
|
-
if (
|
|
132
|
-
const asks = requestTypesNow();
|
|
133
|
-
if (asks?.length) {
|
|
134
|
-
this.renderChooser(t, reason, asks);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (asks === null) void loadRequestTypes();
|
|
138
|
-
}
|
|
159
|
+
if (requestTypesNow() === null) void loadRequestTypes();
|
|
139
160
|
await this.report(reason, t);
|
|
140
161
|
}
|
|
141
162
|
|
|
@@ -165,117 +186,22 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
165
186
|
screenshotMethod: shot.method,
|
|
166
187
|
erroredQueries: safeQueries(),
|
|
167
188
|
});
|
|
168
|
-
this.renderForm(t);
|
|
189
|
+
this.renderForm(t, reason);
|
|
169
190
|
}
|
|
170
191
|
|
|
171
|
-
/**
|
|
172
|
-
private
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const head = el("div", "head");
|
|
177
|
-
const closeBtn = button("✕", "icon");
|
|
178
|
-
closeBtn.setAttribute("aria-label", t.close);
|
|
179
|
-
closeBtn.addEventListener("click", () => this.close());
|
|
180
|
-
head.append(el("h2", "", t.chooseTitle), closeBtn);
|
|
181
|
-
|
|
182
|
-
const problem = choice(t.chooseProblem, t.chooseProblemHint);
|
|
183
|
-
problem.dataset.choose = "problem";
|
|
184
|
-
// Our own dialog leaves the screen before the share starts, or it is in the picture.
|
|
185
|
-
problem.addEventListener("click", () => {
|
|
186
|
-
this.root.replaceChildren();
|
|
187
|
-
void this.report(reason, t);
|
|
188
|
-
});
|
|
189
|
-
const ask = choice(t.chooseRequest, t.chooseRequestHint);
|
|
190
|
-
ask.dataset.choose = "request";
|
|
191
|
-
ask.addEventListener("click", () => this.renderRequestForm(t, reason, types));
|
|
192
|
-
|
|
193
|
-
const choices = el("div", "choices");
|
|
194
|
-
choices.append(problem, ask);
|
|
195
|
-
panel.append(head, choices);
|
|
196
|
-
problem.focus();
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A request: what is asked for, a subject, a line or two — no screenshot, no logs. */
|
|
200
|
-
private renderRequestForm(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
|
|
201
|
-
const panel = this.shell(t);
|
|
202
|
-
panel.classList.add("narrow");
|
|
203
|
-
panel.setAttribute("aria-label", t.requestTitle);
|
|
204
|
-
const head = el("div", "head");
|
|
205
|
-
const closeBtn = button("✕", "icon");
|
|
206
|
-
closeBtn.setAttribute("aria-label", t.close);
|
|
207
|
-
closeBtn.addEventListener("click", () => this.close());
|
|
208
|
-
head.append(el("h2", "", t.requestTitle), closeBtn);
|
|
209
|
-
|
|
210
|
-
let chosen: RequestTypeOption = types[0]!;
|
|
211
|
-
const about = el("p", "status", "");
|
|
212
|
-
const describe = () => {
|
|
213
|
-
const parts = [chosen.description, chosen.targetHours ? t.requestTarget.replace("{time}", hoursWords(t, chosen.targetHours)) : ""].filter(Boolean);
|
|
214
|
-
about.textContent = parts.join(" · ");
|
|
215
|
-
};
|
|
216
|
-
const typeRow: HTMLElement[] = [labelFor(t.requestTypeLabel, "", true)];
|
|
217
|
-
if (types.length > 1) {
|
|
218
|
-
const select = document.createElement("select");
|
|
219
|
-
select.dataset.requestType = "";
|
|
220
|
-
for (const option of types) {
|
|
221
|
-
const o = document.createElement("option");
|
|
222
|
-
o.value = option.id;
|
|
223
|
-
o.textContent = option.name;
|
|
224
|
-
select.append(o);
|
|
225
|
-
}
|
|
226
|
-
select.addEventListener("change", () => {
|
|
227
|
-
chosen = types.find((x) => x.id === select.value) ?? chosen;
|
|
228
|
-
describe();
|
|
229
|
-
});
|
|
230
|
-
typeRow.push(select);
|
|
231
|
-
} else typeRow.push(el("p", "ask-type", chosen.name));
|
|
232
|
-
describe();
|
|
233
|
-
|
|
234
|
-
const summary = document.createElement("input");
|
|
235
|
-
summary.type = "text";
|
|
236
|
-
summary.placeholder = t.requestSummaryPlaceholder;
|
|
237
|
-
summary.maxLength = 300;
|
|
238
|
-
const detail = document.createElement("textarea");
|
|
239
|
-
detail.placeholder = t.requestDetailPlaceholder;
|
|
240
|
-
|
|
241
|
-
const status = el("p", "status", "");
|
|
242
|
-
const submitBtn = button(t.requestSubmit, "primary");
|
|
243
|
-
submitBtn.disabled = true;
|
|
244
|
-
summary.addEventListener("input", () => {
|
|
245
|
-
submitBtn.disabled = summary.value.trim().length === 0;
|
|
246
|
-
});
|
|
247
|
-
const back = button(t.back);
|
|
248
|
-
back.addEventListener("click", () => this.renderChooser(t, reason, types));
|
|
249
|
-
const foot = el("div", "foot");
|
|
250
|
-
foot.append(back, status, el("span", "grow"), submitBtn);
|
|
251
|
-
panel.append(head, ...typeRow, about, labelFor(t.requestSummaryLabel, "", true), summary, labelFor(t.requestDetailLabel), detail, foot);
|
|
252
|
-
|
|
253
|
-
submitBtn.addEventListener("click", async () => {
|
|
254
|
-
submitBtn.disabled = true;
|
|
255
|
-
status.dataset.tone = "";
|
|
256
|
-
status.textContent = t.submitting;
|
|
257
|
-
try {
|
|
258
|
-
const filed = await submitRequest({ ticketTypeId: chosen.id, title: summary.value.trim(), description: detail.value });
|
|
259
|
-
const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
|
|
260
|
-
this.close();
|
|
261
|
-
if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
|
|
262
|
-
} catch (error) {
|
|
263
|
-
const message = failureMessage(error, t);
|
|
264
|
-
status.dataset.tone = "error";
|
|
265
|
-
status.textContent = message;
|
|
266
|
-
showToast(message, "error");
|
|
267
|
-
submitBtn.disabled = false;
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
summary.focus();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
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;
|
|
274
197
|
this.annotator?.destroy();
|
|
275
198
|
this.annotator = null;
|
|
276
199
|
this.bundle = null;
|
|
277
200
|
this.busy = false;
|
|
278
201
|
this.root.replaceChildren();
|
|
202
|
+
const back = this.returnFocus;
|
|
203
|
+
this.returnFocus = null;
|
|
204
|
+
if (back?.isConnected) back.focus();
|
|
279
205
|
}
|
|
280
206
|
|
|
281
207
|
private shell(t: Strings): HTMLElement {
|
|
@@ -302,88 +228,266 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
302
228
|
panel.append(head, el("p", "status", message));
|
|
303
229
|
}
|
|
304
230
|
|
|
305
|
-
|
|
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 {
|
|
306
240
|
const bundle = this.bundle;
|
|
307
241
|
if (!bundle) return;
|
|
308
242
|
const panel = this.shell(t);
|
|
243
|
+
let mode: "problem" | "request" = "problem";
|
|
244
|
+
let chosen: RequestTypeOption | null = null;
|
|
309
245
|
|
|
310
246
|
const head = el("div", "head");
|
|
247
|
+
const heading = el("h2", "", t.title);
|
|
311
248
|
const closeBtn = button("✕", "icon");
|
|
312
249
|
closeBtn.setAttribute("aria-label", t.close);
|
|
313
250
|
closeBtn.addEventListener("click", () => this.close());
|
|
314
|
-
head.append(
|
|
251
|
+
head.append(heading, closeBtn);
|
|
252
|
+
|
|
253
|
+
/*
|
|
254
|
+
* แจ้งปัญหา or คำขอ — two cards under the heading, the way the desk's own
|
|
255
|
+
* สร้างเวิร์กโฟลว์ asks it, shown only where the desk offers something to
|
|
256
|
+
* ask. แจ้งปัญหา is chosen as the form opens (the press is a report for
|
|
257
|
+
* most, so no click is added); either card switches the form, any time,
|
|
258
|
+
* and nothing typed is lost by it.
|
|
259
|
+
*/
|
|
260
|
+
const kinds = el("div", "kinds");
|
|
261
|
+
kinds.hidden = true;
|
|
262
|
+
kinds.setAttribute("role", "radiogroup");
|
|
263
|
+
kinds.setAttribute("aria-label", t.chooseTitle);
|
|
264
|
+
const kindCard = (kind: "problem" | "request", icon: IconNode, title: string, hint: string) => {
|
|
265
|
+
const card = button("", "kind");
|
|
266
|
+
card.setAttribute("role", "radio");
|
|
267
|
+
card.dataset.kind = kind;
|
|
268
|
+
const top = el("span", "kind-top");
|
|
269
|
+
top.append(svgIcon(icon), el("b", "", title));
|
|
270
|
+
card.append(top, el("span", "kind-hint", hint), svgIcon(CHECK, "tick"));
|
|
271
|
+
card.addEventListener("click", () => setMode(kind));
|
|
272
|
+
return card;
|
|
273
|
+
};
|
|
274
|
+
const problemCard = kindCard("problem", BUG, t.kindProblem, t.kindProblemHint);
|
|
275
|
+
const requestCard = kindCard("request", WORKFLOW, t.kindRequest, t.kindRequestHint);
|
|
276
|
+
kinds.append(problemCard, requestCard);
|
|
277
|
+
// A radio group's own keys: an arrow moves the choice, and the focus with it.
|
|
278
|
+
kinds.addEventListener("keydown", (e) => {
|
|
279
|
+
if (!ARROWS.includes(e.key)) return;
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
setMode(mode === "problem" ? "request" : "problem");
|
|
282
|
+
(mode === "problem" ? problemCard : requestCard).focus();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// What is asked for: one card each — the type's own words and how long the team means to take, as the portal shows them.
|
|
286
|
+
const ask = el("div", "ask");
|
|
287
|
+
ask.hidden = true;
|
|
288
|
+
const typeList = el("div", "types");
|
|
289
|
+
typeList.setAttribute("role", "radiogroup");
|
|
290
|
+
typeList.setAttribute("aria-label", t.requestTypeLabel);
|
|
291
|
+
ask.append(labelFor(t.requestTypeLabel, "", true), typeList);
|
|
292
|
+
const typeCards: HTMLButtonElement[] = [];
|
|
293
|
+
const markType = () => {
|
|
294
|
+
for (const card of typeCards) {
|
|
295
|
+
const on = card.dataset.requestType === chosen?.id;
|
|
296
|
+
card.setAttribute("aria-checked", String(on));
|
|
297
|
+
card.tabIndex = on ? 0 : -1;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
315
300
|
|
|
316
301
|
const summary = document.createElement("input");
|
|
317
302
|
summary.type = "text";
|
|
318
|
-
|
|
319
|
-
const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
|
|
320
|
-
|
|
303
|
+
const summaryLabel = labelFor("");
|
|
321
304
|
const detail = document.createElement("textarea");
|
|
322
|
-
|
|
305
|
+
const detailLabel = labelFor("");
|
|
323
306
|
|
|
324
307
|
const meta = el("div", "meta");
|
|
325
|
-
meta.innerHTML = "";
|
|
326
308
|
const { context } = bundle;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// with the bundle.
|
|
334
|
-
//
|
|
335
|
-
// The replay counts as one more thing captured, listed with the rest.
|
|
336
|
-
// It is the support team's debug log, taken the way the network and
|
|
337
|
-
// console logs are — not a choice put to the reporter. There was a
|
|
338
|
-
// tick box here once; the platform owner's call was that a report
|
|
339
|
-
// should always carry what makes it reproducible.
|
|
340
|
-
[
|
|
341
|
-
t.captured,
|
|
342
|
-
`${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
|
|
343
|
-
bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
|
|
344
|
-
}`,
|
|
345
|
-
],
|
|
346
|
-
] as [string, string][]) {
|
|
309
|
+
const captured = `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
|
|
310
|
+
bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
|
|
311
|
+
}`;
|
|
312
|
+
// textContent, not innerHTML: every one of these values is attacker-
|
|
313
|
+
// influenced (a URL, a display name, a captured label).
|
|
314
|
+
const metaRow = (key: string, value: string) => {
|
|
347
315
|
const row = document.createElement("div");
|
|
348
|
-
// textContent, not innerHTML: every one of these values is attacker-
|
|
349
|
-
// influenced (a URL, a display name, a captured label).
|
|
350
316
|
const b = document.createElement("b");
|
|
351
317
|
b.textContent = `${key}: `;
|
|
352
|
-
|
|
318
|
+
const v = document.createTextNode(value);
|
|
319
|
+
row.append(b, v);
|
|
353
320
|
meta.append(row);
|
|
354
|
-
|
|
321
|
+
return { b, v };
|
|
322
|
+
};
|
|
323
|
+
metaRow(t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`);
|
|
324
|
+
metaRow(t.page, context.route.href);
|
|
325
|
+
// No version row: it is for the person reading, not the one filing — it
|
|
326
|
+
// still travels with the bundle. The replay is listed with the rest of
|
|
327
|
+
// what was captured: the support team's debug log, not a choice put to
|
|
328
|
+
// the reporter.
|
|
329
|
+
const logs = metaRow(t.captured, captured);
|
|
330
|
+
|
|
331
|
+
// A request's picture is its own to keep or take off; a report always carries what it captured.
|
|
332
|
+
const shotChoice = document.createElement("label");
|
|
333
|
+
shotChoice.className = "shot-choice";
|
|
334
|
+
shotChoice.hidden = true;
|
|
335
|
+
const withShot = document.createElement("input");
|
|
336
|
+
withShot.type = "checkbox";
|
|
337
|
+
withShot.checked = true;
|
|
338
|
+
withShot.dataset.requestShot = "";
|
|
339
|
+
shotChoice.append(withShot, document.createTextNode(` ${t.requestWithShot}`));
|
|
340
|
+
const shot = bundle.screenshotDataUrl ? this.screenshotSection(t, bundle.screenshotDataUrl) : [el("p", "status", t.noScreenshot)];
|
|
341
|
+
// What to mark on the picture: the problem, or what is asked for.
|
|
342
|
+
const markWhat = bundle.screenshotDataUrl ? shot[0]! : null;
|
|
343
|
+
if (!bundle.screenshotDataUrl) shotChoice.replaceChildren();
|
|
355
344
|
|
|
356
345
|
const status = el("p", "status", "");
|
|
357
346
|
const submitBtn = button(t.submit, "primary");
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
347
|
+
const ready = () => {
|
|
348
|
+
submitBtn.disabled = summary.value.trim().length === 0 || (mode === "request" && !chosen);
|
|
349
|
+
};
|
|
350
|
+
summary.addEventListener("input", ready);
|
|
351
|
+
|
|
352
|
+
const apply = () => {
|
|
353
|
+
const asking = mode === "request";
|
|
354
|
+
heading.textContent = kinds.hidden ? t.title : t.chooseTitle;
|
|
355
|
+
panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
|
|
356
|
+
for (const [card, on] of [[problemCard, !asking], [requestCard, asking]] as const) {
|
|
357
|
+
card.setAttribute("aria-checked", String(on));
|
|
358
|
+
card.tabIndex = on ? 0 : -1;
|
|
359
|
+
}
|
|
360
|
+
ask.hidden = !asking;
|
|
361
|
+
fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
|
|
362
|
+
summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
|
|
363
|
+
fillLabel(detailLabel, asking ? t.requestDetailLabel : t.detailLabel);
|
|
364
|
+
detail.placeholder = asking ? t.requestDetailPlaceholder : t.detailPlaceholder;
|
|
365
|
+
logs.b.textContent = `${asking ? t.requestLeaves : t.captured}: `;
|
|
366
|
+
logs.v.textContent = asking ? t.requestLeavesWhat : captured;
|
|
367
|
+
shotChoice.hidden = !asking || !bundle.screenshotDataUrl;
|
|
368
|
+
if (markWhat) fillLabel(markWhat, asking ? t.requestAnnotateHint : t.annotateHint);
|
|
369
|
+
for (const node of shot) node.classList.toggle("dropped", asking && !withShot.checked);
|
|
370
|
+
submitBtn.textContent = asking ? t.requestSubmit : t.submit;
|
|
371
|
+
ready();
|
|
372
|
+
};
|
|
373
|
+
withShot.addEventListener("change", apply);
|
|
374
|
+
function setMode(next: "problem" | "request"): void {
|
|
375
|
+
if (next === mode) return;
|
|
376
|
+
mode = next;
|
|
377
|
+
apply();
|
|
378
|
+
}
|
|
362
379
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
380
|
+
/*
|
|
381
|
+
* What the desk offers, as soon as it is known: now, or when the read
|
|
382
|
+
* lands with the form already open. Only on a press by hand — a crash's
|
|
383
|
+
* invitation is a problem by definition.
|
|
384
|
+
*/
|
|
385
|
+
const offer = (types: RequestTypeOption[]) => {
|
|
386
|
+
if (this.bundle !== bundle || reason.type !== "manual" || !types.length) return;
|
|
387
|
+
chosen = types[0]!;
|
|
388
|
+
typeCards.length = 0;
|
|
389
|
+
for (const option of types) {
|
|
390
|
+
const card = button("", "type");
|
|
391
|
+
card.setAttribute("role", "radio");
|
|
392
|
+
card.dataset.requestType = option.id;
|
|
393
|
+
const body = el("span", "type-body");
|
|
394
|
+
body.append(el("b", "", option.name));
|
|
395
|
+
if (option.description) body.append(el("span", "type-hint", option.description));
|
|
396
|
+
if (option.targetHours) {
|
|
397
|
+
const due = el("span", "type-due");
|
|
398
|
+
due.append(svgIcon(CLOCK), document.createTextNode(t.requestTarget.replace("{time}", hoursWords(t, option.targetHours))));
|
|
399
|
+
body.append(due);
|
|
400
|
+
}
|
|
401
|
+
card.append(svgIcon(WORKFLOW), body);
|
|
402
|
+
card.addEventListener("click", () => {
|
|
403
|
+
chosen = option;
|
|
404
|
+
markType();
|
|
405
|
+
ready();
|
|
406
|
+
});
|
|
407
|
+
typeCards.push(card);
|
|
408
|
+
}
|
|
409
|
+
typeList.replaceChildren(...typeCards);
|
|
410
|
+
typeList.onkeydown = (e) => {
|
|
411
|
+
if (!ARROWS.includes(e.key) || !chosen) return;
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
const at = types.findIndex((x) => x.id === chosen!.id);
|
|
414
|
+
const step = e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1;
|
|
415
|
+
chosen = types[(at + step + types.length) % types.length]!;
|
|
416
|
+
markType();
|
|
417
|
+
typeCards.find((c) => c.dataset.requestType === chosen!.id)?.focus();
|
|
418
|
+
};
|
|
419
|
+
markType();
|
|
420
|
+
kinds.hidden = false;
|
|
421
|
+
apply();
|
|
422
|
+
};
|
|
423
|
+
/*
|
|
424
|
+
* What was typed last time and not sent comes back, said so — with a
|
|
425
|
+
* way to clear it. A request goes back to being one once its type is
|
|
426
|
+
* offered again.
|
|
427
|
+
*/
|
|
428
|
+
const restored = draft && (draft.title || draft.detail) ? draft : null;
|
|
429
|
+
const again = el("p", "draft");
|
|
430
|
+
again.hidden = !restored;
|
|
431
|
+
if (restored) {
|
|
432
|
+
summary.value = restored.title;
|
|
433
|
+
detail.value = restored.detail;
|
|
434
|
+
const clear = button(t.draftClear, "link");
|
|
435
|
+
clear.dataset.draftClear = "";
|
|
436
|
+
clear.addEventListener("click", () => {
|
|
437
|
+
summary.value = "";
|
|
438
|
+
detail.value = "";
|
|
439
|
+
draft = null;
|
|
440
|
+
again.hidden = true;
|
|
441
|
+
ready();
|
|
442
|
+
summary.focus();
|
|
443
|
+
});
|
|
444
|
+
again.append(document.createTextNode(`${t.draftRestored} · `), clear);
|
|
445
|
+
}
|
|
446
|
+
const known = requestTypesNow();
|
|
447
|
+
const offerAndRestore = (types: RequestTypeOption[]) => {
|
|
448
|
+
offer(types);
|
|
449
|
+
if (!restored?.asking || kinds.hidden) return;
|
|
450
|
+
const was = types.find((x) => x.id === restored.typeId);
|
|
451
|
+
if (was) {
|
|
452
|
+
chosen = was;
|
|
453
|
+
markType();
|
|
454
|
+
}
|
|
455
|
+
mode = "request";
|
|
456
|
+
apply();
|
|
457
|
+
};
|
|
458
|
+
if (known) offerAndRestore(known);
|
|
459
|
+
else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
|
|
460
|
+
this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
|
|
366
461
|
|
|
462
|
+
panel.append(head, kinds, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
|
|
367
463
|
const foot = el("div", "foot");
|
|
368
464
|
const cancel = button(t.close);
|
|
369
465
|
cancel.addEventListener("click", () => this.close());
|
|
370
466
|
foot.append(cancel, status, el("span", "grow"), submitBtn);
|
|
371
467
|
panel.append(foot);
|
|
468
|
+
apply();
|
|
372
469
|
|
|
373
470
|
submitBtn.addEventListener("click", async () => {
|
|
374
471
|
submitBtn.disabled = true;
|
|
375
472
|
status.dataset.tone = "";
|
|
376
473
|
status.textContent = t.submitting;
|
|
474
|
+
// Take the annotated image, not the raw capture — the drawing IS the
|
|
475
|
+
// report for most people.
|
|
476
|
+
const picture = () => (this.annotator?.hasShapes() ? this.annotator.toDataUrl() : bundle.screenshotDataUrl);
|
|
377
477
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
478
|
+
if (mode === "request" && chosen) {
|
|
479
|
+
const filed = await submitRequest({
|
|
480
|
+
ticketTypeId: chosen.id,
|
|
481
|
+
title: summary.value.trim(),
|
|
482
|
+
description: detail.value,
|
|
483
|
+
screenshotDataUrl: withShot.checked ? picture() : null,
|
|
484
|
+
});
|
|
485
|
+
const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
|
|
486
|
+
this.close(true);
|
|
487
|
+
if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const filed = await submitBundle(summary.value.trim(), { ...bundle, note: detail.value, screenshotDataUrl: picture() });
|
|
387
491
|
// Cancelable: a host with its own toast system calls preventDefault()
|
|
388
492
|
// and shows its own. Ours is the default, not the only option.
|
|
389
493
|
const announced = this.dispatchEvent(
|
|
@@ -397,7 +501,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
397
501
|
// Close first, then announce. The dialog is what the user was looking
|
|
398
502
|
// at, and a confirmation written into a panel that then closes says
|
|
399
503
|
// nothing — which is why filing a ticket appeared to do nothing.
|
|
400
|
-
this.close();
|
|
504
|
+
this.close(true);
|
|
401
505
|
if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
|
|
402
506
|
} catch (error) {
|
|
403
507
|
const message = failureMessage(error, t);
|
|
@@ -408,7 +512,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
408
512
|
showToast(message, "error");
|
|
409
513
|
// The dialog stays open and nothing is cleared: what they wrote is
|
|
410
514
|
// still in the box, and pressing again is the right thing to do.
|
|
411
|
-
|
|
515
|
+
ready();
|
|
412
516
|
}
|
|
413
517
|
});
|
|
414
518
|
|
|
@@ -488,15 +592,46 @@ function hoursWords(t: Strings, h: number): string {
|
|
|
488
592
|
return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
|
|
489
593
|
}
|
|
490
594
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
595
|
+
const ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
|
|
596
|
+
|
|
597
|
+
/*
|
|
598
|
+
* The desk's own icons (lucide, as its สร้างเวิร์กโฟลว์ and the portal draw
|
|
599
|
+
* them), carried as their few nodes: the widget takes no icon library.
|
|
600
|
+
*/
|
|
601
|
+
type IconNode = [string, Record<string, string>][];
|
|
602
|
+
const BUG: IconNode = [
|
|
603
|
+
["path", { d: "M12 20v-9" }],
|
|
604
|
+
["path", { d: "M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" }],
|
|
605
|
+
["path", { d: "M14.12 3.88 16 2" }],
|
|
606
|
+
["path", { d: "M21 21a4 4 0 0 0-3.81-4" }],
|
|
607
|
+
["path", { d: "M21 5a4 4 0 0 1-3.55 3.97" }],
|
|
608
|
+
["path", { d: "M22 13h-4" }],
|
|
609
|
+
["path", { d: "M3 21a4 4 0 0 1 3.81-4" }],
|
|
610
|
+
["path", { d: "M3 5a4 4 0 0 0 3.55 3.97" }],
|
|
611
|
+
["path", { d: "M6 13H2" }],
|
|
612
|
+
["path", { d: "m8 2 1.88 1.88" }],
|
|
613
|
+
["path", { d: "M9 7.13V6a3 3 0 1 1 6 0v1.13" }],
|
|
614
|
+
];
|
|
615
|
+
const WORKFLOW: IconNode = [
|
|
616
|
+
["rect", { width: "8", height: "8", x: "3", y: "3", rx: "2" }],
|
|
617
|
+
["path", { d: "M7 11v4a2 2 0 0 0 2 2h4" }],
|
|
618
|
+
["rect", { width: "8", height: "8", x: "13", y: "13", rx: "2" }],
|
|
619
|
+
];
|
|
620
|
+
const CLOCK: IconNode = [
|
|
621
|
+
["circle", { cx: "12", cy: "12", r: "10" }],
|
|
622
|
+
["path", { d: "M12 6v6l4 2" }],
|
|
623
|
+
];
|
|
624
|
+
const CHECK: IconNode = [["path", { d: "M20 6 9 17l-5-5" }]];
|
|
625
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
626
|
+
function svgIcon(nodes: IconNode, className = "icon"): SVGSVGElement {
|
|
627
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
628
|
+
for (const [k, v] of Object.entries({ viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", class: className })) svg.setAttribute(k, v);
|
|
629
|
+
for (const [tag, attrs] of nodes) {
|
|
630
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
631
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
632
|
+
svg.append(node);
|
|
633
|
+
}
|
|
634
|
+
return svg;
|
|
500
635
|
}
|
|
501
636
|
|
|
502
637
|
function button(text: string, className = ""): HTMLButtonElement {
|
|
@@ -508,8 +643,12 @@ function button(text: string, className = ""): HTMLButtonElement {
|
|
|
508
643
|
}
|
|
509
644
|
|
|
510
645
|
function labelFor(text: string, hint = "", required = false): HTMLElement {
|
|
511
|
-
|
|
512
|
-
|
|
646
|
+
return fillLabel(document.createElement("label"), text, hint, required);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** A label's words, written again when the dialog turns from a report to a request. */
|
|
650
|
+
function fillLabel(label: HTMLElement, text: string, hint = "", required = false): HTMLElement {
|
|
651
|
+
label.replaceChildren(document.createTextNode(text));
|
|
513
652
|
if (required) {
|
|
514
653
|
const star = document.createElement("span");
|
|
515
654
|
star.className = "req";
|