@lightworkai.official/debug-capture 0.13.0 → 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 +21 -14
- package/dist/index.mjs +29 -22
- package/dist/requests.d.ts +1 -0
- package/dist/ui/strings.d.ts +13 -6
- 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 +230 -179
- package/src/ui/strings.ts +36 -20
- package/src/ui/styles.ts +18 -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);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** แจ้งปัญหา or ขอให้ทีมดำเนินการ — two cards, nothing else. */
|
|
172
|
-
private renderChooser(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
|
|
173
|
-
const panel = this.shell(t);
|
|
174
|
-
panel.classList.add("narrow");
|
|
175
|
-
panel.setAttribute("aria-label", t.chooseTitle);
|
|
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();
|
|
189
|
+
this.renderForm(t, reason);
|
|
271
190
|
}
|
|
272
191
|
|
|
273
|
-
|
|
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,220 @@ 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;
|
|
245
|
+
let select: HTMLSelectElement | null = null;
|
|
309
246
|
|
|
310
247
|
const head = el("div", "head");
|
|
248
|
+
const heading = el("h2", "", t.title);
|
|
311
249
|
const closeBtn = button("✕", "icon");
|
|
312
250
|
closeBtn.setAttribute("aria-label", t.close);
|
|
313
251
|
closeBtn.addEventListener("click", () => this.close());
|
|
314
|
-
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
|
+
};
|
|
315
272
|
|
|
316
273
|
const summary = document.createElement("input");
|
|
317
274
|
summary.type = "text";
|
|
318
|
-
|
|
319
|
-
const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
|
|
320
|
-
|
|
275
|
+
const summaryLabel = labelFor("");
|
|
321
276
|
const detail = document.createElement("textarea");
|
|
322
|
-
|
|
277
|
+
const detailLabel = labelFor("");
|
|
323
278
|
|
|
324
279
|
const meta = el("div", "meta");
|
|
325
|
-
meta.innerHTML = "";
|
|
326
280
|
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][]) {
|
|
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) => {
|
|
347
287
|
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
288
|
const b = document.createElement("b");
|
|
351
289
|
b.textContent = `${key}: `;
|
|
352
|
-
|
|
290
|
+
const v = document.createTextNode(value);
|
|
291
|
+
row.append(b, v);
|
|
353
292
|
meta.append(row);
|
|
354
|
-
|
|
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();
|
|
355
316
|
|
|
356
317
|
const status = el("p", "status", "");
|
|
357
318
|
const submitBtn = button(t.submit, "primary");
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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();
|
|
361
348
|
});
|
|
362
349
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 });
|
|
366
415
|
|
|
416
|
+
panel.append(head, door, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
|
|
367
417
|
const foot = el("div", "foot");
|
|
368
418
|
const cancel = button(t.close);
|
|
369
419
|
cancel.addEventListener("click", () => this.close());
|
|
370
420
|
foot.append(cancel, status, el("span", "grow"), submitBtn);
|
|
371
421
|
panel.append(foot);
|
|
422
|
+
apply();
|
|
372
423
|
|
|
373
424
|
submitBtn.addEventListener("click", async () => {
|
|
374
425
|
submitBtn.disabled = true;
|
|
375
426
|
status.dataset.tone = "";
|
|
376
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);
|
|
377
431
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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() });
|
|
387
445
|
// Cancelable: a host with its own toast system calls preventDefault()
|
|
388
446
|
// and shows its own. Ours is the default, not the only option.
|
|
389
447
|
const announced = this.dispatchEvent(
|
|
@@ -397,7 +455,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
397
455
|
// Close first, then announce. The dialog is what the user was looking
|
|
398
456
|
// at, and a confirmation written into a panel that then closes says
|
|
399
457
|
// nothing — which is why filing a ticket appeared to do nothing.
|
|
400
|
-
this.close();
|
|
458
|
+
this.close(true);
|
|
401
459
|
if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
|
|
402
460
|
} catch (error) {
|
|
403
461
|
const message = failureMessage(error, t);
|
|
@@ -408,7 +466,7 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
408
466
|
showToast(message, "error");
|
|
409
467
|
// The dialog stays open and nothing is cleared: what they wrote is
|
|
410
468
|
// still in the box, and pressing again is the right thing to do.
|
|
411
|
-
|
|
469
|
+
ready();
|
|
412
470
|
}
|
|
413
471
|
});
|
|
414
472
|
|
|
@@ -488,17 +546,6 @@ function hoursWords(t: Strings, h: number): string {
|
|
|
488
546
|
return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
|
|
489
547
|
}
|
|
490
548
|
|
|
491
|
-
/** A card of a choice: its name, and a line under it. */
|
|
492
|
-
function choice(title: string, hint: string): HTMLButtonElement {
|
|
493
|
-
const node = button("", "choice");
|
|
494
|
-
const b = document.createElement("b");
|
|
495
|
-
b.textContent = title;
|
|
496
|
-
const span = document.createElement("span");
|
|
497
|
-
span.textContent = hint;
|
|
498
|
-
node.append(b, span);
|
|
499
|
-
return node;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
549
|
function button(text: string, className = ""): HTMLButtonElement {
|
|
503
550
|
const node = document.createElement("button");
|
|
504
551
|
node.type = "button";
|
|
@@ -508,8 +555,12 @@ function button(text: string, className = ""): HTMLButtonElement {
|
|
|
508
555
|
}
|
|
509
556
|
|
|
510
557
|
function labelFor(text: string, hint = "", required = false): HTMLElement {
|
|
511
|
-
|
|
512
|
-
|
|
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));
|
|
513
564
|
if (required) {
|
|
514
565
|
const star = document.createElement("span");
|
|
515
566
|
star.className = "req";
|
package/src/ui/strings.ts
CHANGED
|
@@ -65,14 +65,14 @@ export interface Strings {
|
|
|
65
65
|
handoffBlocked: string;
|
|
66
66
|
handoffOpen: string;
|
|
67
67
|
/*
|
|
68
|
-
* ขอให้ทีมดำเนินการ —
|
|
69
|
-
* types to its reporters (
|
|
68
|
+
* ขอให้ทีมดำเนินการ — the same dialog turned into a request, where the desk
|
|
69
|
+
* opens request types to its reporters (docs/WIDGET-ASK-PLAN.md): the door
|
|
70
|
+
* is a line in the report form, and back.
|
|
70
71
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
chooseRequestHint: string;
|
|
72
|
+
askInstead: string;
|
|
73
|
+
askInsteadAction: string;
|
|
74
|
+
problemInstead: string;
|
|
75
|
+
problemInsteadAction: string;
|
|
76
76
|
requestTitle: string;
|
|
77
77
|
requestTypeLabel: string;
|
|
78
78
|
/** Carries `{time}`. */
|
|
@@ -83,7 +83,15 @@ export interface Strings {
|
|
|
83
83
|
requestDetailPlaceholder: string;
|
|
84
84
|
requestSubmit: string;
|
|
85
85
|
requestSubmitted: string;
|
|
86
|
-
|
|
86
|
+
/** The picture, a request's to keep or take off. */
|
|
87
|
+
requestWithShot: string;
|
|
88
|
+
requestAnnotateHint: string;
|
|
89
|
+
/** What was typed last time and not sent, back in the form — and the way to clear it. */
|
|
90
|
+
draftRestored: string;
|
|
91
|
+
draftClear: string;
|
|
92
|
+
/** In the captured row, turned: what a request does NOT send. */
|
|
93
|
+
requestLeaves: string;
|
|
94
|
+
requestLeavesWhat: string;
|
|
87
95
|
/** Carry `{n}`. */
|
|
88
96
|
days: string;
|
|
89
97
|
hours: string;
|
|
@@ -132,11 +140,10 @@ const th: Strings = {
|
|
|
132
140
|
handoffFailed: "เปิดรายการที่แจ้งไว้ไม่สำเร็จ",
|
|
133
141
|
handoffBlocked: "เบราว์เซอร์บล็อกการเปิดแท็บใหม่",
|
|
134
142
|
handoffOpen: "เปิดในแท็บใหม่",
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
chooseRequestHint: "ขอสิทธิ์ ขอแก้ข้อมูล หรือขอให้ทีมทำบางอย่าง",
|
|
143
|
+
askInstead: "เป็นคำขอ?",
|
|
144
|
+
askInsteadAction: "เลือกเรื่องที่ขอ",
|
|
145
|
+
problemInstead: "เป็นปัญหาที่ต้องแก้?",
|
|
146
|
+
problemInsteadAction: "แจ้งปัญหาแทน",
|
|
140
147
|
requestTitle: "ขอให้ทีมดำเนินการ",
|
|
141
148
|
requestTypeLabel: "เรื่องที่ขอ",
|
|
142
149
|
requestTarget: "ทีมตั้งใจให้เสร็จภายใน {time}",
|
|
@@ -146,7 +153,12 @@ const th: Strings = {
|
|
|
146
153
|
requestDetailPlaceholder: "สิ่งที่ทีมต้องรู้เพื่อดำเนินการ",
|
|
147
154
|
requestSubmit: "ส่งคำขอ",
|
|
148
155
|
requestSubmitted: "ส่งคำขอแล้ว",
|
|
149
|
-
|
|
156
|
+
requestWithShot: "แนบภาพหน้าจอนี้ไปกับคำขอ",
|
|
157
|
+
requestAnnotateHint: "ทำเครื่องหมายสิ่งที่ขอบนภาพ — วงกรอบ ชี้ลูกศร ไฮไลต์ หรือพิมพ์ข้อความ",
|
|
158
|
+
draftRestored: "ข้อความที่พิมพ์ไว้ครั้งก่อน ยังไม่ได้ส่ง",
|
|
159
|
+
draftClear: "ล้าง",
|
|
160
|
+
requestLeaves: "ไม่ส่งไปกับคำขอ",
|
|
161
|
+
requestLeavesWhat: "บันทึกการใช้งานของหน้านี้ — ใช้กับการแจ้งปัญหาเท่านั้น",
|
|
150
162
|
days: "{n} วัน",
|
|
151
163
|
hours: "{n} ชั่วโมง",
|
|
152
164
|
};
|
|
@@ -194,11 +206,10 @@ const en: Strings = {
|
|
|
194
206
|
handoffFailed: "Could not open your reports",
|
|
195
207
|
handoffBlocked: "Your browser blocked the new tab",
|
|
196
208
|
handoffOpen: "Open in a new tab",
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
chooseRequestHint: "Access, a data change, or something done for you",
|
|
209
|
+
askInstead: "Asking for something?",
|
|
210
|
+
askInsteadAction: "Choose what you need",
|
|
211
|
+
problemInstead: "Something broken?",
|
|
212
|
+
problemInsteadAction: "Report a problem instead",
|
|
202
213
|
requestTitle: "Ask the team",
|
|
203
214
|
requestTypeLabel: "What you need",
|
|
204
215
|
requestTarget: "The team aims to finish within {time}",
|
|
@@ -208,7 +219,12 @@ const en: Strings = {
|
|
|
208
219
|
requestDetailPlaceholder: "What the team needs to know to do it",
|
|
209
220
|
requestSubmit: "Send request",
|
|
210
221
|
requestSubmitted: "Request sent",
|
|
211
|
-
|
|
222
|
+
requestWithShot: "Attach this screenshot to the request",
|
|
223
|
+
requestAnnotateHint: "Mark what you are asking about — box it, point at it, highlight it, or type on it",
|
|
224
|
+
draftRestored: "What you typed last time, not sent yet",
|
|
225
|
+
draftClear: "Clear",
|
|
226
|
+
requestLeaves: "Not sent with a request",
|
|
227
|
+
requestLeavesWhat: "this page's activity log — it is for problem reports only",
|
|
212
228
|
days: "{n} days",
|
|
213
229
|
hours: "{n} hours",
|
|
214
230
|
};
|
package/src/ui/styles.ts
CHANGED
|
@@ -94,16 +94,23 @@ canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; b
|
|
|
94
94
|
}
|
|
95
95
|
.foot .grow { flex: 1; }
|
|
96
96
|
|
|
97
|
-
/*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
/* ขอให้ทีมดำเนินการ — the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md).
|
|
98
|
+
The door is a quiet line under the heading: read by the one who asks,
|
|
99
|
+
costing nothing to the many who report. */
|
|
100
|
+
[hidden] { display: none !important; }
|
|
101
|
+
.door { margin: -8px 0 0; font-size: 13px; color: #64748b; }
|
|
102
|
+
button.link {
|
|
103
|
+
display: inline; padding: 0; border: 0; border-radius: 4px; background: none;
|
|
104
|
+
font-size: 13px; font-weight: 600; color: #2563eb;
|
|
105
|
+
text-decoration: underline; text-underline-offset: 3px;
|
|
103
106
|
}
|
|
104
|
-
button.
|
|
105
|
-
button.
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
.ask
|
|
107
|
+
button.link:hover:not(:disabled) { background: none; color: #1d4ed8; }
|
|
108
|
+
button.link:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
109
|
+
.ask p { margin: 0; }
|
|
110
|
+
.draft { margin: -8px 0 0; padding: 8px 12px; border-radius: 10px; background: #eff6ff; font-size: 13px; color: #1e3a8a; }
|
|
111
|
+
.ask .status { margin-top: 6px; }
|
|
112
|
+
.ask-type { font-size: 14px; font-weight: 600; }
|
|
113
|
+
.shot-choice { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; }
|
|
114
|
+
.shot-choice input { width: 16px; height: 16px; margin: 0; accent-color: #2563eb; }
|
|
115
|
+
.dropped { opacity: 0.35; pointer-events: none; }
|
|
109
116
|
`;
|