storymapper 0.1.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/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
|
@@ -0,0 +1,2095 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticket-form.js — Geteilte Ticket-Formular-Builder + State-Helfer.
|
|
3
|
+
*
|
|
4
|
+
* SM-116 (Epic B / B1): aus renderer-ticket-modal.js extrahiert, damit der
|
|
5
|
+
* Vollseiten-Editor (B2+) UND das Detail-Modal DIESELBEN Builder benutzen.
|
|
6
|
+
* Reine DOM-Bauer + State-Diff-Helfer — KEINE Modal-Annahmen, kein showModal.
|
|
7
|
+
* UMD-gewickelt: factory(core, dnd). In Node direkt require()-bar, im Browser
|
|
8
|
+
* als window.STORYMAP.ticketForm.
|
|
9
|
+
*
|
|
10
|
+
* Hinweis: buildTestExecutionHeader oeffnet beim Pill-Klick das referenzierte
|
|
11
|
+
* Test-Definition-Ticket ueber ctx.openTicketModal (vom Host injiziert) bzw.
|
|
12
|
+
* window.STORYMAP.rendererTicketModal.openTicketModal als Browser-Fallback.
|
|
13
|
+
*/
|
|
14
|
+
(function (root, factory) {
|
|
15
|
+
if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"));
|
|
16
|
+
else (root.STORYMAP = root.STORYMAP || {}).ticketForm = factory(
|
|
17
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
18
|
+
root.STORYMAP && root.STORYMAP.dnd
|
|
19
|
+
);
|
|
20
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd) {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
if (!core) throw new Error("ticket-form: core module missing");
|
|
24
|
+
if (!dnd) throw new Error("ticket-form: dnd module missing");
|
|
25
|
+
|
|
26
|
+
const DRAG_TYPE_AC = "tm-ac-row";
|
|
27
|
+
const DRAG_TYPE_CHECK = "tm-check-row"; // E19-followup: DoR/DoD rows are drag-sortable
|
|
28
|
+
// SM-54: test-definition sections — Prerequisites + Steps. Distinct drag-types so
|
|
29
|
+
// a prereq row can't be dropped into the steps table and vice versa.
|
|
30
|
+
const DRAG_TYPE_PREREQ = "tm-prereq-row";
|
|
31
|
+
const DRAG_TYPE_TEST_STEP = "tm-test-step-row";
|
|
32
|
+
|
|
33
|
+
// E18.D: lokale Mutationen identifizieren sich als "Local"; Save-Subscriber
|
|
34
|
+
// in main.js persistiert debounced an den HTTP-Adapter.
|
|
35
|
+
const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
|
|
36
|
+
|
|
37
|
+
// ---- Konstanten (keine Magic Numbers) -------------------------------
|
|
38
|
+
const CONSTANTS = {
|
|
39
|
+
FIELD_DEBOUNCE_MS: 500, // unbenutzt vorerst (Save ist explizit), reserviert
|
|
40
|
+
TITLE_MAX_LENGTH: 300,
|
|
41
|
+
DESCRIPTION_MAX_LENGTH: 50000,
|
|
42
|
+
LIVE_SYNC_ATTR: "data-sm-sync-key", // markiert Felder, die Live-Sync update darf
|
|
43
|
+
FIELD_PULSE_MS: 900, // SM-5: Modal-Field-Pulse bei externer Änderung
|
|
44
|
+
FIELD_PULSE_CLASS: "sm-card-flash" // reuse Card-Animate keyframe (SM-20 made it generic)
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---- DOM-Helper -----------------------------------------------------
|
|
48
|
+
function el(tag, props, children) {
|
|
49
|
+
const e = document.createElement(tag);
|
|
50
|
+
if (props) {
|
|
51
|
+
for (const k of Object.keys(props)) {
|
|
52
|
+
if (k === "class") e.className = props[k];
|
|
53
|
+
else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
|
|
54
|
+
else if (k === "style") Object.assign(e.style, props[k]);
|
|
55
|
+
else if (k === "text") e.textContent = props[k];
|
|
56
|
+
else if (k === "html") e.innerHTML = props[k];
|
|
57
|
+
else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
58
|
+
else e.setAttribute(k, props[k]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
62
|
+
return e;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// SM-119 (Epic B / B4-lite): the Description is a plain-text contentEditable
|
|
66
|
+
// block (grows with content, no scrollbar) instead of a fixed-height
|
|
67
|
+
// textarea. These helpers let the value read/write paths treat a
|
|
68
|
+
// contentEditable div and a form control (input/select/textarea) uniformly.
|
|
69
|
+
function isEditableNode(node) {
|
|
70
|
+
return !!(node && node.getAttribute && node.getAttribute("contenteditable") === "true");
|
|
71
|
+
}
|
|
72
|
+
// Read the field's text. For a contentEditable we prefer innerText (it
|
|
73
|
+
// renders block/<br> structure back to "\n" line breaks in real browsers);
|
|
74
|
+
// jsdom has no innerText, so we fall back to textContent there.
|
|
75
|
+
function readFieldText(node) {
|
|
76
|
+
if (!node) return "";
|
|
77
|
+
if (isEditableNode(node)) {
|
|
78
|
+
return (typeof node.innerText === "string") ? node.innerText : (node.textContent || "");
|
|
79
|
+
}
|
|
80
|
+
return node.value || "";
|
|
81
|
+
}
|
|
82
|
+
// Write the field's text. For a contentEditable we set textContent (a flat
|
|
83
|
+
// text node); with `white-space: pre-wrap` newlines render correctly and
|
|
84
|
+
// round-trip via readFieldText.
|
|
85
|
+
function writeFieldText(node, text) {
|
|
86
|
+
if (!node) return;
|
|
87
|
+
if (isEditableNode(node)) node.textContent = text;
|
|
88
|
+
else node.value = text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- Builders pro Feldgruppe (pure DOM-Bauer) ------------------------
|
|
92
|
+
|
|
93
|
+
function buildRow(labelText, controlEl, syncKey) {
|
|
94
|
+
if (syncKey && controlEl) controlEl.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, syncKey);
|
|
95
|
+
const lbl = el("label", { class: "tm-label", text: labelText });
|
|
96
|
+
const row = el("div", { class: "tm-row" }, [lbl, controlEl]);
|
|
97
|
+
return row;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildErrorBanner() {
|
|
101
|
+
const banner = el("div", { class: "tm-error-banner", style: { display: "none" } });
|
|
102
|
+
banner.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "_error_banner");
|
|
103
|
+
return banner;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// SM-158: opts.multiline renders the title as an auto-growing textarea so a
|
|
107
|
+
// long title WRAPS across the full width instead of being clipped by a
|
|
108
|
+
// single-line input (the full-page editor uses this for its title hero). The
|
|
109
|
+
// modal keeps the compact single-line input. Both expose `.value` + the
|
|
110
|
+
// "title" sync-key, so collectFormState / syncFieldFromTicket are unchanged.
|
|
111
|
+
function buildTitleField(ticket, opts) {
|
|
112
|
+
opts = opts || {};
|
|
113
|
+
if (opts.multiline) {
|
|
114
|
+
const ta = el("textarea", {
|
|
115
|
+
class: "tm-input tm-title-input",
|
|
116
|
+
rows: "1",
|
|
117
|
+
maxlength: CONSTANTS.TITLE_MAX_LENGTH
|
|
118
|
+
});
|
|
119
|
+
ta.value = ticket.title || "";
|
|
120
|
+
ta.addEventListener("input", function () { autoGrowTextarea(ta); });
|
|
121
|
+
setTimeout(function () { autoGrowTextarea(ta); }, 0);
|
|
122
|
+
return buildRow("Title", ta, "title");
|
|
123
|
+
}
|
|
124
|
+
const input = el("input", {
|
|
125
|
+
type: "text",
|
|
126
|
+
class: "tm-input",
|
|
127
|
+
value: ticket.title || "",
|
|
128
|
+
maxlength: CONSTANTS.TITLE_MAX_LENGTH
|
|
129
|
+
});
|
|
130
|
+
return buildRow("Title", input, "title");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildTypeField(ticket, ticketTypes) {
|
|
134
|
+
const select = el("select", { class: "tm-input" });
|
|
135
|
+
for (const t of ticketTypes) {
|
|
136
|
+
const opt = el("option", { value: t, text: t });
|
|
137
|
+
if (t === ticket.type) opt.setAttribute("selected", "selected");
|
|
138
|
+
select.appendChild(opt);
|
|
139
|
+
}
|
|
140
|
+
return buildRow("Type", select, "type");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// SM-238: human-readable derivation breakdown for an epic's rolled-up status.
|
|
144
|
+
function epicStatusBreakdownText(stats) {
|
|
145
|
+
const n = stats.total;
|
|
146
|
+
const noun = n === 1 ? "story" : "stories";
|
|
147
|
+
return "derived from " + n + " " + noun + " — " + stats.done + " done, "
|
|
148
|
+
+ stats.doing + " in progress, " + stats.todo + " to do";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildStatusField(ticket, statuses, snapshot) {
|
|
152
|
+
// SM-238: an epic's status is DERIVED (roll-up) — show a read-only pill +
|
|
153
|
+
// the derivation breakdown instead of an editable dropdown. The pill carries
|
|
154
|
+
// the "status" sync-key so syncFieldFromTicket can repaint it live. No status
|
|
155
|
+
// patch is ever emitted for an epic: buildUpdatePatch has no status branch
|
|
156
|
+
// and the in-place commit strips status — so the empty read-back is harmless.
|
|
157
|
+
if (ticket.type === "epic") {
|
|
158
|
+
const stats = core.epicChildStats(snapshot || { tickets: [] }, ticket.id);
|
|
159
|
+
const pill = el("span", { class: "tm-status-derived", text: ticket.status });
|
|
160
|
+
pill.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "status");
|
|
161
|
+
pill.dataset.status = ticket.status;
|
|
162
|
+
const breakdown = el("div", { class: "tm-status-breakdown", text: epicStatusBreakdownText(stats) });
|
|
163
|
+
breakdown.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "status_breakdown");
|
|
164
|
+
const wrap = el("div", { class: "tm-status-derived-wrap" }, [pill, breakdown]);
|
|
165
|
+
return buildRow("Status", wrap, null);
|
|
166
|
+
}
|
|
167
|
+
const select = el("select", { class: "tm-input tm-status" });
|
|
168
|
+
for (const s of statuses) {
|
|
169
|
+
const opt = el("option", { value: s, text: s });
|
|
170
|
+
if (s === ticket.status) opt.setAttribute("selected", "selected");
|
|
171
|
+
select.appendChild(opt);
|
|
172
|
+
}
|
|
173
|
+
return buildRow("Status", select, "status");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildPositionFields(ticket, snapshot, typeConfig) {
|
|
177
|
+
typeConfig = typeConfig || {};
|
|
178
|
+
const releases = (snapshot.releases || []).filter(r => !r.isDeleted);
|
|
179
|
+
const processSteps = (snapshot.processSteps || []).filter(p => !p.isDeleted);
|
|
180
|
+
const epics = (snapshot.tickets || []).filter(t => t.type === "epic" && !t.isDeleted && t.id !== ticket.id);
|
|
181
|
+
|
|
182
|
+
function dropdown(name, items, currentId, withNone) {
|
|
183
|
+
const select = el("select", { class: "tm-input" });
|
|
184
|
+
if (withNone) {
|
|
185
|
+
const noneOpt = el("option", { value: "", text: "— none —" });
|
|
186
|
+
if (!currentId) noneOpt.setAttribute("selected", "selected");
|
|
187
|
+
select.appendChild(noneOpt);
|
|
188
|
+
}
|
|
189
|
+
for (const it of items) {
|
|
190
|
+
const opt = el("option", { value: it.id, text: it.name || it.title || it.id });
|
|
191
|
+
if (it.id === currentId) opt.setAttribute("selected", "selected");
|
|
192
|
+
select.appendChild(opt);
|
|
193
|
+
}
|
|
194
|
+
return select;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const pos = ticket.position || {};
|
|
198
|
+
// SM-77-followup: the container-epic lives in a `contains`-link from
|
|
199
|
+
// the epic (SM-52 canonical). `ticket.position.epicId` is always null
|
|
200
|
+
// after the SM-52 migration — reading it here used to silently break
|
|
201
|
+
// the Epic-dropdown and erased the container on every no-op save.
|
|
202
|
+
// SM-244 BUGFIX: a CREATE-mode draft has no id yet (so containerEpicIdOf
|
|
203
|
+
// can't find a link), but it CAN carry an intended parent in
|
|
204
|
+
// position.epicId (e.g. the epic card's "+" button). Fall back to it so the
|
|
205
|
+
// Epic dropdown is pre-selected and the contains-link is actually created.
|
|
206
|
+
// For existing tickets pos.epicId is always null, so this is a no-op there.
|
|
207
|
+
const containerEpicId = ((typeof core.containerEpicIdOf === "function")
|
|
208
|
+
? core.containerEpicIdOf(snapshot, ticket.id) : null) || (pos.epicId || null);
|
|
209
|
+
|
|
210
|
+
// SM-67: when this ticket is contained by an epic, its release+
|
|
211
|
+
// processStep are inherited from the epic. The Release + ProcessStep
|
|
212
|
+
// dropdowns become read-only (disabled) with the epic's values pre-
|
|
213
|
+
// selected, and a hint shows which epic dictates them. Changing the
|
|
214
|
+
// Epic dropdown live-updates the disabled values; clearing it
|
|
215
|
+
// (— none —) re-enables them so the story can be detached + relocated.
|
|
216
|
+
const epicById = new Map(epics.map(e => [e.id, e]));
|
|
217
|
+
const initialContainer = (ticket.type !== "epic" && containerEpicId)
|
|
218
|
+
? epicById.get(containerEpicId) : null;
|
|
219
|
+
|
|
220
|
+
const relSelect = dropdown("releaseId", releases,
|
|
221
|
+
initialContainer ? (initialContainer.position && initialContainer.position.releaseId) || "" : pos.releaseId,
|
|
222
|
+
true);
|
|
223
|
+
const psSelect = dropdown("processStepId", processSteps,
|
|
224
|
+
initialContainer ? (initialContainer.position && initialContainer.position.processStepId) || "" : pos.processStepId,
|
|
225
|
+
true);
|
|
226
|
+
const epicSelect = (typeConfig.allowParentEpic !== false)
|
|
227
|
+
? dropdown("epicId", epics, containerEpicId, true) : null;
|
|
228
|
+
|
|
229
|
+
function hintText(epic) {
|
|
230
|
+
return epic ? ("Inherited from " + (epic.ticketKey || epic.title || epic.id)) : "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyInheritance(epic) {
|
|
234
|
+
if (epic && epic.position) {
|
|
235
|
+
relSelect.value = epic.position.releaseId || "";
|
|
236
|
+
psSelect.value = epic.position.processStepId || "";
|
|
237
|
+
relSelect.disabled = true;
|
|
238
|
+
psSelect.disabled = true;
|
|
239
|
+
} else {
|
|
240
|
+
relSelect.disabled = false;
|
|
241
|
+
psSelect.disabled = false;
|
|
242
|
+
}
|
|
243
|
+
if (relHint) relHint.textContent = hintText(epic);
|
|
244
|
+
if (psHint) psHint.textContent = hintText(epic);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let relHint = null;
|
|
248
|
+
let psHint = null;
|
|
249
|
+
const rows = [];
|
|
250
|
+
if (typeConfig.showRelease !== false) {
|
|
251
|
+
rows.push(buildRow("Release", relSelect, "position.releaseId"));
|
|
252
|
+
relHint = el("div", { class: "tm-field-hint", text: hintText(initialContainer) });
|
|
253
|
+
rows.push(relHint);
|
|
254
|
+
}
|
|
255
|
+
if (typeConfig.showProcessStep !== false) {
|
|
256
|
+
rows.push(buildRow("Process Step", psSelect, "position.processStepId"));
|
|
257
|
+
psHint = el("div", { class: "tm-field-hint", text: hintText(initialContainer) });
|
|
258
|
+
rows.push(psHint);
|
|
259
|
+
}
|
|
260
|
+
if (epicSelect) {
|
|
261
|
+
rows.push(buildRow("Epic", epicSelect, "position.epicId"));
|
|
262
|
+
epicSelect.addEventListener("change", () => {
|
|
263
|
+
const v = epicSelect.value || "";
|
|
264
|
+
applyInheritance(v ? epicById.get(v) : null);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// Apply the initial inherited state if applicable.
|
|
268
|
+
if (initialContainer) applyInheritance(initialContainer);
|
|
269
|
+
return rows;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// SM-119: plain-text contentEditable description. Grows with content (no
|
|
273
|
+
// fixed height, no inner scrollbar) and stays a PLAIN string — paste is
|
|
274
|
+
// forced to text/plain so no HTML/styling creeps in, and the value is read
|
|
275
|
+
// back via readFieldText (innerText → "\n", textContent fallback). Shared
|
|
276
|
+
// builder → the modal AND the full-page editor get this automatically.
|
|
277
|
+
//
|
|
278
|
+
// SM-157: when `opts.onCommit(text)` is wired (edit-mode, the ticket exists),
|
|
279
|
+
// a Jira-style ✓/✗ pair APPEARS as soon as the text differs from the saved
|
|
280
|
+
// value, so free-text edits are committed deliberately and never silently
|
|
281
|
+
// lost. ✓ (or focus-out / Cmd+Enter) commits; ✗ (or Esc) reverts. Without
|
|
282
|
+
// onCommit (create-mode / pure render) the field is a plain draft input.
|
|
283
|
+
function buildDescriptionField(ticket, opts) {
|
|
284
|
+
opts = opts || {};
|
|
285
|
+
const ed = el("div", {
|
|
286
|
+
class: "tm-input tm-description-edit",
|
|
287
|
+
contenteditable: "true",
|
|
288
|
+
role: "textbox",
|
|
289
|
+
"aria-multiline": "true",
|
|
290
|
+
spellcheck: "true",
|
|
291
|
+
dataset: { placeholder: "Description…" }
|
|
292
|
+
});
|
|
293
|
+
ed.textContent = ticket.description || "";
|
|
294
|
+
// Force plain-text paste so the contentEditable never accumulates markup.
|
|
295
|
+
ed.addEventListener("paste", function (ev) {
|
|
296
|
+
const cd = ev.clipboardData || (typeof window !== "undefined" && window.clipboardData);
|
|
297
|
+
if (!cd) return;
|
|
298
|
+
ev.preventDefault();
|
|
299
|
+
const text = (cd.getData && (cd.getData("text/plain") || cd.getData("text"))) || "";
|
|
300
|
+
const sel = (typeof window !== "undefined" && window.getSelection) ? window.getSelection() : null;
|
|
301
|
+
if (sel && sel.rangeCount) {
|
|
302
|
+
const range = sel.getRangeAt(0);
|
|
303
|
+
range.deleteContents();
|
|
304
|
+
const node = document.createTextNode(text);
|
|
305
|
+
range.insertNode(node);
|
|
306
|
+
range.setStartAfter(node); range.setEndAfter(node);
|
|
307
|
+
sel.removeAllRanges(); sel.addRange(range);
|
|
308
|
+
} else {
|
|
309
|
+
ed.appendChild(document.createTextNode(text));
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const row = buildRow("Description", ed, "description");
|
|
313
|
+
|
|
314
|
+
if (typeof opts.onCommit === "function") {
|
|
315
|
+
let baseline = ticket.description || "";
|
|
316
|
+
let reverting = false;
|
|
317
|
+
const actions = el("div", { class: "tm-desc-actions", style: { display: "none" } });
|
|
318
|
+
const saveBtn = el("button", { class: "tm-desc-save", type: "button", title: "Save (⌘/Ctrl+Enter)", text: "✓" });
|
|
319
|
+
const cancelBtn = el("button", { class: "tm-desc-cancel", type: "button", title: "Discard (Esc)", text: "✕" });
|
|
320
|
+
actions.appendChild(saveBtn);
|
|
321
|
+
actions.appendChild(cancelBtn);
|
|
322
|
+
row.appendChild(actions);
|
|
323
|
+
|
|
324
|
+
function isDirty() { return readFieldText(ed) !== baseline; }
|
|
325
|
+
function refresh() { actions.style.display = isDirty() ? "" : "none"; }
|
|
326
|
+
function commit() {
|
|
327
|
+
const text = readFieldText(ed);
|
|
328
|
+
if (text !== baseline) { baseline = text; opts.onCommit(text); }
|
|
329
|
+
refresh();
|
|
330
|
+
}
|
|
331
|
+
function revert() { reverting = true; writeFieldText(ed, baseline); refresh(); reverting = false; }
|
|
332
|
+
|
|
333
|
+
ed.addEventListener("input", refresh);
|
|
334
|
+
// Buttons use mousedown+preventDefault so clicking them does NOT blur the
|
|
335
|
+
// editable (which would otherwise fire the blur-commit first).
|
|
336
|
+
saveBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
337
|
+
saveBtn.addEventListener("click", function () { commit(); });
|
|
338
|
+
cancelBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
339
|
+
cancelBtn.addEventListener("click", function () { revert(); });
|
|
340
|
+
// Focus-out commits (the "focus change saves" behaviour the user expects),
|
|
341
|
+
// unless we're mid-revert.
|
|
342
|
+
ed.addEventListener("blur", function () { if (!reverting) commit(); });
|
|
343
|
+
ed.addEventListener("keydown", function (e) {
|
|
344
|
+
if (e.key === "Escape") { e.preventDefault(); revert(); if (ed.blur) ed.blur(); }
|
|
345
|
+
else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); }
|
|
346
|
+
});
|
|
347
|
+
// Live-sync hook: when an external commit rewrites the field (see
|
|
348
|
+
// syncFieldFromTicket), reset the baseline so ✓/✗ don't show spuriously
|
|
349
|
+
// and a later blur doesn't re-commit a stale value.
|
|
350
|
+
ed.__tfSetBaseline = function (v) { baseline = v || ""; refresh(); };
|
|
351
|
+
}
|
|
352
|
+
return row;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* E19-followup: DoR/DoD-Section ist ein vollwertiger per-Ticket-Editor
|
|
357
|
+
* (analog Acceptance Criteria). Pro Row: ⋮⋮-Grip · checked-Checkbox ·
|
|
358
|
+
* Label-Input · `required`-Toggle · Delete-Button. `+ Add Item` am Ende.
|
|
359
|
+
* Drag-sortierbar via dnd.js.
|
|
360
|
+
*
|
|
361
|
+
* collectFormState liest beim Save die Reihenfolge und alle Felder direkt
|
|
362
|
+
* aus dem DOM und persistiert sie als `definitionOfReady`/`definitionOfDone`
|
|
363
|
+
* via Store-Op. Kein inline-Persist mehr — Save-Subscriber kümmert sich.
|
|
364
|
+
*/
|
|
365
|
+
function buildChecklistSection(title, items, syncKey, refs) {
|
|
366
|
+
const wrap = el("section", { class: "tm-checklist" });
|
|
367
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, syncKey);
|
|
368
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: title }));
|
|
369
|
+
const list = el("div", { class: "tm-checklist-list" });
|
|
370
|
+
for (const item of (items || [])) {
|
|
371
|
+
list.appendChild(buildCheckRow(item, refs));
|
|
372
|
+
}
|
|
373
|
+
wrap.appendChild(list);
|
|
374
|
+
const addBtn = el("button", {
|
|
375
|
+
class: "tm-checklist-add",
|
|
376
|
+
type: "button",
|
|
377
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Item</span>'
|
|
378
|
+
});
|
|
379
|
+
addBtn.addEventListener("click", () => {
|
|
380
|
+
const fresh = { id: "ci-" + Math.random().toString(36).slice(2, 10), label: "", required: true, checked: false };
|
|
381
|
+
const row = buildCheckRow(fresh, refs);
|
|
382
|
+
list.appendChild(row);
|
|
383
|
+
const labelInput = row.querySelector(".tm-checkitem-text-input");
|
|
384
|
+
if (labelInput) labelInput.focus();
|
|
385
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
386
|
+
});
|
|
387
|
+
wrap.appendChild(addBtn);
|
|
388
|
+
return wrap;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildCheckRow(item, refs) {
|
|
392
|
+
const row = el("div", { class: "tm-checkitem-row" });
|
|
393
|
+
row.dataset.itemId = item.id;
|
|
394
|
+
const grip = el("span", { class: "tm-checkitem-grip", title: "Drag to reorder", text: "⋮⋮" });
|
|
395
|
+
const checked = el("input", { type: "checkbox", class: "tm-checkitem-checked" });
|
|
396
|
+
if (item.checked) checked.setAttribute("checked", "checked");
|
|
397
|
+
const labelInput = el("input", { type: "text", class: "tm-input tm-checkitem-text-input", value: item.label || "", placeholder: "Item label" });
|
|
398
|
+
const requiredLabel = el("label", { class: "tm-checkitem-required-wrap", title: "Required to pass the gate" });
|
|
399
|
+
const requiredInput = el("input", { type: "checkbox", class: "tm-checkitem-required" });
|
|
400
|
+
if (item.required !== false) requiredInput.setAttribute("checked", "checked");
|
|
401
|
+
requiredLabel.appendChild(requiredInput);
|
|
402
|
+
requiredLabel.appendChild(el("span", { class: "tm-checkitem-required-hint", text: "required" }));
|
|
403
|
+
const remove = el("button", { class: "tm-checkitem-remove", type: "button", title: "Remove item", text: "×" });
|
|
404
|
+
remove.addEventListener("click", () => { row.remove(); if (refs && refs.markDirty) refs.markDirty(); });
|
|
405
|
+
[checked, labelInput, requiredInput].forEach(el => {
|
|
406
|
+
el.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
407
|
+
});
|
|
408
|
+
labelInput.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
409
|
+
row.appendChild(grip);
|
|
410
|
+
row.appendChild(checked);
|
|
411
|
+
row.appendChild(labelInput);
|
|
412
|
+
row.appendChild(requiredLabel);
|
|
413
|
+
row.appendChild(remove);
|
|
414
|
+
|
|
415
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
416
|
+
dnd.enableDraggable(row, { dragType: DRAG_TYPE_CHECK, dragId: item.id });
|
|
417
|
+
dnd.enableDropTarget(row, {
|
|
418
|
+
accepts: [DRAG_TYPE_CHECK],
|
|
419
|
+
onEnter: (e) => e.classList.add("tm-checkitem-drop-target"),
|
|
420
|
+
onLeave: (e) => e.classList.remove("tm-checkitem-drop-target"),
|
|
421
|
+
onDrop: ({ id, event }) => {
|
|
422
|
+
row.classList.remove("tm-checkitem-drop-target");
|
|
423
|
+
const list = row.parentNode;
|
|
424
|
+
if (!list) return;
|
|
425
|
+
const dragged = list.querySelector('.tm-checkitem-row[data-item-id="' + cssEscape(id) + '"]');
|
|
426
|
+
if (!dragged || dragged === row) return;
|
|
427
|
+
const rect = row.getBoundingClientRect();
|
|
428
|
+
const before = (event && typeof event.clientY === "number")
|
|
429
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
430
|
+
if (before) list.insertBefore(dragged, row);
|
|
431
|
+
else list.insertBefore(dragged, row.nextSibling);
|
|
432
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return row;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildAcceptanceCriteriaSection(ticket, refs) {
|
|
440
|
+
const wrap = el("section", { class: "tm-ac-section" });
|
|
441
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "acceptanceCriteria");
|
|
442
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Acceptance Criteria" }));
|
|
443
|
+
const list = el("div", { class: "tm-ac-list" });
|
|
444
|
+
for (const ac of (ticket.acceptanceCriteria || [])) {
|
|
445
|
+
list.appendChild(buildAcRow(ac, refs));
|
|
446
|
+
}
|
|
447
|
+
wrap.appendChild(list);
|
|
448
|
+
const addBtn = el("button", {
|
|
449
|
+
class: "tm-ac-add",
|
|
450
|
+
type: "button",
|
|
451
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Criterion</span>'
|
|
452
|
+
});
|
|
453
|
+
addBtn.addEventListener("click", () => {
|
|
454
|
+
const newAc = { id: "ac-" + Math.random().toString(36).slice(2, 10), text: "", completed: false };
|
|
455
|
+
list.appendChild(buildAcRow(newAc, refs));
|
|
456
|
+
refs.markDirty();
|
|
457
|
+
});
|
|
458
|
+
wrap.appendChild(addBtn);
|
|
459
|
+
return wrap;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function buildAcRow(ac, refs) {
|
|
463
|
+
const row = el("div", { class: "tm-ac-row" });
|
|
464
|
+
// Grip-handle als visueller Drag-Indikator. Drag-Mechanik: pointerdown
|
|
465
|
+
// auf irgendeinem Teil der Row startet einen Drag (Inputs/Buttons sind
|
|
466
|
+
// durch dnd.js per `closest("button,input,...")`-Guard ausgenommen, sodass
|
|
467
|
+
// Tippen + Checkbox-Click + Remove weiter funktionieren). Der Grip selbst
|
|
468
|
+
// existiert nur als visueller Hinweis und non-input-Aufsetzpunkt.
|
|
469
|
+
const grip = el("span", { class: "tm-ac-grip", text: "⋮⋮", title: "Drag to reorder" });
|
|
470
|
+
const check = el("input", { type: "checkbox" });
|
|
471
|
+
if (ac.completed) check.setAttribute("checked", "checked");
|
|
472
|
+
const text = el("input", { type: "text", class: "tm-input tm-ac-text", value: ac.text || "" });
|
|
473
|
+
const remove = el("button", { class: "tm-ac-remove", type: "button", title: "Remove", text: "×" });
|
|
474
|
+
check.addEventListener("change", refs.markDirty);
|
|
475
|
+
text.addEventListener("input", refs.markDirty);
|
|
476
|
+
remove.addEventListener("click", () => { row.remove(); refs.markDirty(); });
|
|
477
|
+
row.dataset.acId = ac.id;
|
|
478
|
+
row.appendChild(grip);
|
|
479
|
+
row.appendChild(check);
|
|
480
|
+
row.appendChild(text);
|
|
481
|
+
row.appendChild(remove);
|
|
482
|
+
|
|
483
|
+
// Drag-Reorder: Row ist Drag-Source UND Drop-Target. onDrop bewegt die
|
|
484
|
+
// gezogene Row vor die Target-Row (oder ans Listenende, wenn Cursor im
|
|
485
|
+
// unteren Halb der Target-Row ist). collectFormState liest die neue
|
|
486
|
+
// Reihenfolge dann direkt aus dem DOM beim Save.
|
|
487
|
+
dnd.enableDraggable(row, { dragType: DRAG_TYPE_AC, dragId: ac.id });
|
|
488
|
+
dnd.enableDropTarget(row, {
|
|
489
|
+
accepts: [DRAG_TYPE_AC],
|
|
490
|
+
onEnter: (el) => el.classList.add("tm-ac-drop-target"),
|
|
491
|
+
onLeave: (el) => el.classList.remove("tm-ac-drop-target"),
|
|
492
|
+
onDrop: ({ id, event }) => {
|
|
493
|
+
row.classList.remove("tm-ac-drop-target");
|
|
494
|
+
const list = row.parentNode;
|
|
495
|
+
if (!list) return;
|
|
496
|
+
const dragged = list.querySelector('.tm-ac-row[data-ac-id="' + cssEscape(id) + '"]');
|
|
497
|
+
if (!dragged || dragged === row) return;
|
|
498
|
+
const rect = row.getBoundingClientRect();
|
|
499
|
+
const before = (event && typeof event.clientY === "number")
|
|
500
|
+
? event.clientY < rect.top + rect.height / 2
|
|
501
|
+
: true;
|
|
502
|
+
if (before) list.insertBefore(dragged, row);
|
|
503
|
+
else list.insertBefore(dragged, row.nextSibling);
|
|
504
|
+
refs.markDirty();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
return row;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function cssEscape(s) {
|
|
511
|
+
// Minimal CSS-attribute-selector escaper. dragged.dataset.acId is our id;
|
|
512
|
+
// we use it in [data-ac-id="..."]. Escape backslashes + double-quotes.
|
|
513
|
+
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- SM-54: Prerequisites section (test-definition type) ------------
|
|
517
|
+
//
|
|
518
|
+
// Analog der DoR/DoD-Editor-Section: pro Row Grip + checked-Checkbox +
|
|
519
|
+
// Label-Input + required-Toggle + Delete-Button. "+ Add Prerequisite" am
|
|
520
|
+
// Ende. Drag-sortable via dnd.js mit eigenem drag-type. Empty-Label-Rows
|
|
521
|
+
// werden beim Collect verworfen (siehe collectPrerequisites).
|
|
522
|
+
function buildPrerequisitesSection(ticket, refs) {
|
|
523
|
+
const wrap = el("section", { class: "tm-prereqs" });
|
|
524
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "prerequisites");
|
|
525
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Prerequisites" }));
|
|
526
|
+
const list = el("div", { class: "tm-prereq-list" });
|
|
527
|
+
for (const p of (ticket.prerequisites || [])) {
|
|
528
|
+
list.appendChild(buildPrereqRow(p, refs));
|
|
529
|
+
}
|
|
530
|
+
wrap.appendChild(list);
|
|
531
|
+
const addBtn = el("button", {
|
|
532
|
+
class: "tm-prereq-add",
|
|
533
|
+
type: "button",
|
|
534
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Prerequisite</span>'
|
|
535
|
+
});
|
|
536
|
+
addBtn.addEventListener("click", () => {
|
|
537
|
+
const fresh = { id: "tpre-" + Math.random().toString(36).slice(2, 10),
|
|
538
|
+
label: "", required: true, checked: false };
|
|
539
|
+
const row = buildPrereqRow(fresh, refs);
|
|
540
|
+
list.appendChild(row);
|
|
541
|
+
const labelInput = row.querySelector(".tm-prereq-text-input");
|
|
542
|
+
if (labelInput) labelInput.focus();
|
|
543
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
544
|
+
});
|
|
545
|
+
wrap.appendChild(addBtn);
|
|
546
|
+
return wrap;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function buildPrereqRow(item, refs) {
|
|
550
|
+
const row = el("div", { class: "tm-prereq-row" });
|
|
551
|
+
row.dataset.itemId = item.id;
|
|
552
|
+
const grip = el("span", { class: "tm-prereq-grip", title: "Drag to reorder", text: "⋮⋮" });
|
|
553
|
+
const checked = el("input", { type: "checkbox", class: "tm-prereq-checked" });
|
|
554
|
+
if (item.checked) checked.setAttribute("checked", "checked");
|
|
555
|
+
const labelInput = el("input", {
|
|
556
|
+
type: "text", class: "tm-input tm-prereq-text-input",
|
|
557
|
+
value: item.label || "", placeholder: "Prerequisite label"
|
|
558
|
+
});
|
|
559
|
+
const requiredLabel = el("label", { class: "tm-prereq-required-wrap", title: "Required" });
|
|
560
|
+
const requiredInput = el("input", { type: "checkbox", class: "tm-prereq-required" });
|
|
561
|
+
if (item.required !== false) requiredInput.setAttribute("checked", "checked");
|
|
562
|
+
requiredLabel.appendChild(requiredInput);
|
|
563
|
+
requiredLabel.appendChild(el("span", { class: "tm-prereq-required-hint", text: "required" }));
|
|
564
|
+
const remove = el("button", {
|
|
565
|
+
class: "tm-prereq-remove", type: "button", title: "Remove prerequisite", text: "×"
|
|
566
|
+
});
|
|
567
|
+
remove.addEventListener("click", () => {
|
|
568
|
+
row.remove();
|
|
569
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
570
|
+
});
|
|
571
|
+
[checked, requiredInput].forEach(input => {
|
|
572
|
+
input.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
573
|
+
});
|
|
574
|
+
labelInput.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
575
|
+
|
|
576
|
+
row.appendChild(grip);
|
|
577
|
+
row.appendChild(checked);
|
|
578
|
+
row.appendChild(labelInput);
|
|
579
|
+
row.appendChild(requiredLabel);
|
|
580
|
+
row.appendChild(remove);
|
|
581
|
+
|
|
582
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
583
|
+
dnd.enableDraggable(row, { dragType: DRAG_TYPE_PREREQ, dragId: item.id });
|
|
584
|
+
dnd.enableDropTarget(row, {
|
|
585
|
+
accepts: [DRAG_TYPE_PREREQ],
|
|
586
|
+
onEnter: (e) => e.classList.add("tm-prereq-drop-target"),
|
|
587
|
+
onLeave: (e) => e.classList.remove("tm-prereq-drop-target"),
|
|
588
|
+
onDrop: ({ id, event }) => {
|
|
589
|
+
row.classList.remove("tm-prereq-drop-target");
|
|
590
|
+
const list = row.parentNode;
|
|
591
|
+
if (!list) return;
|
|
592
|
+
const dragged = list.querySelector('.tm-prereq-row[data-item-id="' + cssEscape(id) + '"]');
|
|
593
|
+
if (!dragged || dragged === row) return;
|
|
594
|
+
const rect = row.getBoundingClientRect();
|
|
595
|
+
const before = (event && typeof event.clientY === "number")
|
|
596
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
597
|
+
if (before) list.insertBefore(dragged, row);
|
|
598
|
+
else list.insertBefore(dragged, row.nextSibling);
|
|
599
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return row;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---- SM-54: Steps section (test-definition type) -------------------
|
|
607
|
+
//
|
|
608
|
+
// Tabelle mit Header (Step / Data / Expected Result) und einer Row pro
|
|
609
|
+
// Step. Pro Row: ⋮⋮-Grip + drei Text-Inputs + Delete-Button. "+ Add Step"
|
|
610
|
+
// am Ende. Drag-sortable via dnd.js mit eigenem drag-type. Empty-Rows
|
|
611
|
+
// (alle drei Felder leer) werden beim Collect verworfen.
|
|
612
|
+
function buildStepsSection(ticket, refs) {
|
|
613
|
+
const wrap = el("section", { class: "tm-test-steps" });
|
|
614
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "steps");
|
|
615
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Steps" }));
|
|
616
|
+
// SM-54-followup: header grid mirrors the row grid exactly so labels
|
|
617
|
+
// sit above their columns. Empty cells fill grip + insert + delete
|
|
618
|
+
// positions; explicit grid-column avoids "STEP", "DATA",
|
|
619
|
+
// "EXPECTED RESULT" all landing in the first three columns.
|
|
620
|
+
const tableHead = el("div", { class: "tm-test-step-head" }, [
|
|
621
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-grip" }),
|
|
622
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-step", text: "Step" }),
|
|
623
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-data", text: "Data" }),
|
|
624
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-expected", text: "Expected Result" }),
|
|
625
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-insert" }),
|
|
626
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-remove" })
|
|
627
|
+
]);
|
|
628
|
+
wrap.appendChild(tableHead);
|
|
629
|
+
const list = el("div", { class: "tm-test-step-list" });
|
|
630
|
+
for (const s of (ticket.steps || [])) {
|
|
631
|
+
list.appendChild(buildTestStepRow(s, refs, list));
|
|
632
|
+
}
|
|
633
|
+
wrap.appendChild(list);
|
|
634
|
+
const addBtn = el("button", {
|
|
635
|
+
class: "tm-test-step-add",
|
|
636
|
+
type: "button",
|
|
637
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Step</span>'
|
|
638
|
+
});
|
|
639
|
+
addBtn.addEventListener("click", () => {
|
|
640
|
+
const fresh = { id: "tstep-" + Math.random().toString(36).slice(2, 10),
|
|
641
|
+
step: "", data: "", expectedResult: "" };
|
|
642
|
+
const row = buildTestStepRow(fresh, refs, list);
|
|
643
|
+
list.appendChild(row);
|
|
644
|
+
const stepArea = row.querySelector(".tm-test-step-step");
|
|
645
|
+
if (stepArea) stepArea.focus();
|
|
646
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
647
|
+
});
|
|
648
|
+
wrap.appendChild(addBtn);
|
|
649
|
+
return wrap;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Auto-grow a textarea to fit content. We cap at MAX_TEST_STEP_TA_HEIGHT_PX
|
|
653
|
+
// so a giant paste doesn't blow up the modal — past that, the textarea
|
|
654
|
+
// scrolls internally.
|
|
655
|
+
const MAX_TEST_STEP_TA_HEIGHT_PX = 240;
|
|
656
|
+
function autoGrowTextarea(ta) {
|
|
657
|
+
ta.style.height = "auto";
|
|
658
|
+
ta.style.height = Math.min(MAX_TEST_STEP_TA_HEIGHT_PX, Math.max(ta.scrollHeight, 32)) + "px";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function buildTestStepRow(item, refs, listRef) {
|
|
662
|
+
const row = el("div", { class: "tm-test-step-row" });
|
|
663
|
+
row.dataset.itemId = item.id;
|
|
664
|
+
const grip = el("span", { class: "tm-test-step-grip", title: "Drag to reorder", text: "⋮⋮" });
|
|
665
|
+
// SM-54-followup: multi-line textareas — test steps often need a
|
|
666
|
+
// paragraph or two, not a one-liner. Auto-grow on input, capped.
|
|
667
|
+
const stepArea = el("textarea", {
|
|
668
|
+
class: "tm-input tm-test-step-step", rows: "2", placeholder: "Action"
|
|
669
|
+
});
|
|
670
|
+
stepArea.value = item.step || "";
|
|
671
|
+
const dataArea = el("textarea", {
|
|
672
|
+
class: "tm-input tm-test-step-data", rows: "2", placeholder: "Data"
|
|
673
|
+
});
|
|
674
|
+
dataArea.value = item.data || "";
|
|
675
|
+
const expectedArea = el("textarea", {
|
|
676
|
+
class: "tm-input tm-test-step-expected", rows: "2", placeholder: "Expected result"
|
|
677
|
+
});
|
|
678
|
+
expectedArea.value = item.expectedResult || "";
|
|
679
|
+
// SM-54-followup: insert-step-above button so the user can splice a
|
|
680
|
+
// new step between two existing ones (not only append at the bottom).
|
|
681
|
+
const insertAbove = el("button", {
|
|
682
|
+
class: "tm-test-step-insert", type: "button",
|
|
683
|
+
title: "Insert step above this one", text: "↑+"
|
|
684
|
+
});
|
|
685
|
+
insertAbove.addEventListener("click", (ev) => {
|
|
686
|
+
ev.stopPropagation();
|
|
687
|
+
const fresh = { id: "tstep-" + Math.random().toString(36).slice(2, 10),
|
|
688
|
+
step: "", data: "", expectedResult: "" };
|
|
689
|
+
const newRow = buildTestStepRow(fresh, refs, listRef);
|
|
690
|
+
const list = listRef || row.parentNode;
|
|
691
|
+
if (list) list.insertBefore(newRow, row);
|
|
692
|
+
const ta = newRow.querySelector(".tm-test-step-step");
|
|
693
|
+
if (ta) ta.focus();
|
|
694
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
695
|
+
});
|
|
696
|
+
const remove = el("button", {
|
|
697
|
+
class: "tm-test-step-remove", type: "button", title: "Remove step", text: "×"
|
|
698
|
+
});
|
|
699
|
+
remove.addEventListener("click", () => {
|
|
700
|
+
row.remove();
|
|
701
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
702
|
+
});
|
|
703
|
+
[stepArea, dataArea, expectedArea].forEach(ta => {
|
|
704
|
+
ta.addEventListener("input", () => {
|
|
705
|
+
autoGrowTextarea(ta);
|
|
706
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
707
|
+
});
|
|
708
|
+
// Initial size after mount — defer so the element is in the DOM and
|
|
709
|
+
// scrollHeight reflects the actual content.
|
|
710
|
+
setTimeout(() => autoGrowTextarea(ta), 0);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
row.appendChild(grip);
|
|
714
|
+
row.appendChild(stepArea);
|
|
715
|
+
row.appendChild(dataArea);
|
|
716
|
+
row.appendChild(expectedArea);
|
|
717
|
+
row.appendChild(insertAbove);
|
|
718
|
+
row.appendChild(remove);
|
|
719
|
+
|
|
720
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
721
|
+
dnd.enableDraggable(row, { dragType: DRAG_TYPE_TEST_STEP, dragId: item.id });
|
|
722
|
+
dnd.enableDropTarget(row, {
|
|
723
|
+
accepts: [DRAG_TYPE_TEST_STEP],
|
|
724
|
+
onEnter: (e) => e.classList.add("tm-test-step-drop-target"),
|
|
725
|
+
onLeave: (e) => e.classList.remove("tm-test-step-drop-target"),
|
|
726
|
+
onDrop: ({ id, event }) => {
|
|
727
|
+
row.classList.remove("tm-test-step-drop-target");
|
|
728
|
+
const list = row.parentNode;
|
|
729
|
+
if (!list) return;
|
|
730
|
+
const dragged = list.querySelector('.tm-test-step-row[data-item-id="' + cssEscape(id) + '"]');
|
|
731
|
+
if (!dragged || dragged === row) return;
|
|
732
|
+
const rect = row.getBoundingClientRect();
|
|
733
|
+
const before = (event && typeof event.clientY === "number")
|
|
734
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
735
|
+
if (before) list.insertBefore(dragged, row);
|
|
736
|
+
else list.insertBefore(dragged, row.nextSibling);
|
|
737
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return row;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function buildLabelsField(ticket) {
|
|
745
|
+
const input = el("input", {
|
|
746
|
+
type: "text",
|
|
747
|
+
class: "tm-input",
|
|
748
|
+
value: (ticket.labels || []).join(", "),
|
|
749
|
+
placeholder: "comma-separated"
|
|
750
|
+
});
|
|
751
|
+
return buildRow("Labels", input, "labels");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ---- SM-57: Test-Execution sections --------------------------------
|
|
755
|
+
//
|
|
756
|
+
// For type='test-execution' the modal renders three extra blocks:
|
|
757
|
+
// 1. A "executes → DEF-KEY: Title" pill at the very top of the body
|
|
758
|
+
// (clickable, opens the definition's modal).
|
|
759
|
+
// 2. A metadata strip (runAt / runBy / env) — read-only after creation.
|
|
760
|
+
// 3. A 6-column execution-steps table. step/data/expectedResult are
|
|
761
|
+
// frozen from the definition (rendered disabled). actualResult,
|
|
762
|
+
// status, note are editable and persist via store.updateTicket
|
|
763
|
+
// executionSteps.
|
|
764
|
+
// 4. An outcome section: derived display + manual-override picker.
|
|
765
|
+
//
|
|
766
|
+
// Add/remove buttons and drag-sort are deliberately absent — the step
|
|
767
|
+
// list is a snapshot of the definition and may not grow or shrink.
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Find the test-definition this execution references. Returns null if
|
|
771
|
+
* the link is missing or the target has been deleted.
|
|
772
|
+
*/
|
|
773
|
+
function resolveTestDefinition(execTicket, snapshot) {
|
|
774
|
+
if (!execTicket || !execTicket.referencedTestDefinitionId) return null;
|
|
775
|
+
const tickets = (snapshot && snapshot.tickets) || [];
|
|
776
|
+
const def = tickets.find(t => t.id === execTicket.referencedTestDefinitionId);
|
|
777
|
+
if (!def || def.isDeleted) return null;
|
|
778
|
+
return def;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Header pill: "executes → DEF-KEY: Title". Click opens the definition's
|
|
783
|
+
* modal (best-effort — if the definition was deleted between renders the
|
|
784
|
+
* click is a no-op). Returns null when the link is unresolved so the
|
|
785
|
+
* caller skips appending an empty pill.
|
|
786
|
+
*/
|
|
787
|
+
function buildTestExecutionHeader(ticket, snapshot, ctx) {
|
|
788
|
+
const def = resolveTestDefinition(ticket, snapshot);
|
|
789
|
+
const wrap = el("section", { class: "tm-exec-header" });
|
|
790
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "referencedTestDefinitionId");
|
|
791
|
+
if (!def) {
|
|
792
|
+
wrap.appendChild(el("span", {
|
|
793
|
+
class: "tm-exec-header-missing",
|
|
794
|
+
text: "executes → (definition unavailable)"
|
|
795
|
+
}));
|
|
796
|
+
return wrap;
|
|
797
|
+
}
|
|
798
|
+
const pill = el("button", {
|
|
799
|
+
class: "tm-exec-header-pill",
|
|
800
|
+
type: "button",
|
|
801
|
+
title: "Open the test-definition this run executes"
|
|
802
|
+
});
|
|
803
|
+
pill.appendChild(el("span", { class: "tm-exec-header-arrow", text: "executes →" }));
|
|
804
|
+
pill.appendChild(el("span", { class: "tm-exec-header-key", text: def.ticketKey || def.id }));
|
|
805
|
+
pill.appendChild(el("span", { class: "tm-exec-header-title", text: ": " + (def.title || "") }));
|
|
806
|
+
pill.addEventListener("click", (ev) => {
|
|
807
|
+
ev.stopPropagation();
|
|
808
|
+
// SM-116: this builder lives in the shared ticket-form module now, so it
|
|
809
|
+
// cannot see the modal's openTicketModal directly. The host injects it via
|
|
810
|
+
// ctx (Object.assign({}, ctx, { openTicketModal })); a window fallback keeps
|
|
811
|
+
// the browser working even when ctx was not augmented.
|
|
812
|
+
const opener = ctx && (ctx.openTicketModal
|
|
813
|
+
|| (typeof window !== "undefined" && window.STORYMAP
|
|
814
|
+
&& window.STORYMAP.rendererTicketModal
|
|
815
|
+
&& window.STORYMAP.rendererTicketModal.openTicketModal));
|
|
816
|
+
if (typeof opener === "function") opener(ctx, def.id, {});
|
|
817
|
+
});
|
|
818
|
+
wrap.appendChild(pill);
|
|
819
|
+
return wrap;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Read-only metadata strip: runAt (formatted), runBy (actor name),
|
|
824
|
+
* env (string or "—"). Pure presentation; the values come from the
|
|
825
|
+
* starter dialog and aren't editable here.
|
|
826
|
+
*/
|
|
827
|
+
function buildExecutionMetadata(ticket) {
|
|
828
|
+
function fmtTime(ms) {
|
|
829
|
+
if (typeof ms !== "number") return "—";
|
|
830
|
+
try {
|
|
831
|
+
const d = new Date(ms);
|
|
832
|
+
// Format: 2026-05-27 10:23 (ISO-ish, no timezone since we use local)
|
|
833
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
834
|
+
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
835
|
+
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
|
836
|
+
} catch (_) { return "—"; }
|
|
837
|
+
}
|
|
838
|
+
const wrap = el("section", { class: "tm-exec-meta" });
|
|
839
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "executionMetadata");
|
|
840
|
+
function entry(label, value) {
|
|
841
|
+
const cell = el("div", { class: "tm-exec-meta-cell" });
|
|
842
|
+
cell.appendChild(el("span", { class: "tm-exec-meta-label", text: label }));
|
|
843
|
+
cell.appendChild(el("span", { class: "tm-exec-meta-value", text: value }));
|
|
844
|
+
return cell;
|
|
845
|
+
}
|
|
846
|
+
wrap.appendChild(entry("Run at", fmtTime(ticket.runAt)));
|
|
847
|
+
const runBy = ticket.runBy && ticket.runBy.name ? ticket.runBy.name : "—";
|
|
848
|
+
wrap.appendChild(entry("Run by", runBy));
|
|
849
|
+
wrap.appendChild(entry("Env", ticket.env || "—"));
|
|
850
|
+
return wrap;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* 6-column step table. Header row + data rows. step/data/expectedResult
|
|
855
|
+
* are RENDERED but disabled — they're the frozen contract from the
|
|
856
|
+
* definition. actualResult + status + note are inline-editable and feed
|
|
857
|
+
* collectExecutionSteps via [data-sm-sync-key=executionSteps].
|
|
858
|
+
*/
|
|
859
|
+
function buildExecutionStepsSection(ticket, refs) {
|
|
860
|
+
const wrap = el("section", { class: "tm-exec-steps" });
|
|
861
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "executionSteps");
|
|
862
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Execution Steps" }));
|
|
863
|
+
const head = el("div", { class: "tm-exec-step-head" }, [
|
|
864
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-step", text: "Step" }),
|
|
865
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-data", text: "Data" }),
|
|
866
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-expected", text: "Expected" }),
|
|
867
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-actual", text: "Actual" }),
|
|
868
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-status", text: "Status" }),
|
|
869
|
+
el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-note", text: "Note" })
|
|
870
|
+
]);
|
|
871
|
+
wrap.appendChild(head);
|
|
872
|
+
const list = el("div", { class: "tm-exec-step-list" });
|
|
873
|
+
for (const s of (ticket.executionSteps || [])) {
|
|
874
|
+
list.appendChild(buildExecutionStepRow(s, refs));
|
|
875
|
+
}
|
|
876
|
+
wrap.appendChild(list);
|
|
877
|
+
return wrap;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function buildExecutionStepRow(item, refs) {
|
|
881
|
+
const row = el("div", { class: "tm-exec-step-row" });
|
|
882
|
+
row.dataset.itemId = item.id;
|
|
883
|
+
// Read-only columns — disabled textareas so a user can still copy text.
|
|
884
|
+
function ro(extraClass, value) {
|
|
885
|
+
const ta = el("textarea", { class: "tm-input tm-exec-step-ro " + extraClass, rows: "2", disabled: "disabled" });
|
|
886
|
+
ta.value = value || "";
|
|
887
|
+
return ta;
|
|
888
|
+
}
|
|
889
|
+
const stepArea = ro("tm-exec-step-step", item.step);
|
|
890
|
+
const dataArea = ro("tm-exec-step-data", item.data);
|
|
891
|
+
const expectedArea = ro("tm-exec-step-expected", item.expectedResult);
|
|
892
|
+
const actualArea = el("textarea", {
|
|
893
|
+
class: "tm-input tm-exec-step-actual",
|
|
894
|
+
rows: "2",
|
|
895
|
+
placeholder: "Observed result"
|
|
896
|
+
});
|
|
897
|
+
actualArea.value = item.actualResult || "";
|
|
898
|
+
const statusSel = el("select", { class: "tm-input tm-exec-step-status" });
|
|
899
|
+
const STATUSES = core.TEST_EXEC_STEP_STATUSES
|
|
900
|
+
|| ["pending", "passed", "failed", "blocked", "skipped"];
|
|
901
|
+
for (const s of STATUSES) {
|
|
902
|
+
const opt = el("option", { value: s, text: s });
|
|
903
|
+
if (s === (item.status || "pending")) opt.setAttribute("selected", "selected");
|
|
904
|
+
statusSel.appendChild(opt);
|
|
905
|
+
}
|
|
906
|
+
statusSel.dataset.status = item.status || "pending";
|
|
907
|
+
// Color the picker via a status-pill data attribute (CSS handles colors).
|
|
908
|
+
function reflectStatusColor() { statusSel.dataset.status = statusSel.value; }
|
|
909
|
+
statusSel.addEventListener("change", reflectStatusColor);
|
|
910
|
+
const noteArea = el("textarea", {
|
|
911
|
+
class: "tm-input tm-exec-step-note",
|
|
912
|
+
rows: "2",
|
|
913
|
+
placeholder: "Note"
|
|
914
|
+
});
|
|
915
|
+
noteArea.value = item.note || "";
|
|
916
|
+
[actualArea, statusSel, noteArea].forEach(node => {
|
|
917
|
+
node.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
918
|
+
node.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
row.appendChild(stepArea);
|
|
922
|
+
row.appendChild(dataArea);
|
|
923
|
+
row.appendChild(expectedArea);
|
|
924
|
+
row.appendChild(actualArea);
|
|
925
|
+
row.appendChild(statusSel);
|
|
926
|
+
row.appendChild(noteArea);
|
|
927
|
+
return row;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Outcome section: shows derived outcome + count + manual-override
|
|
932
|
+
* picker. Override values: "auto" (= use derived) + the five outcome
|
|
933
|
+
* enum values. When the user picks anything other than auto, the
|
|
934
|
+
* "manual override" hint becomes visible.
|
|
935
|
+
*/
|
|
936
|
+
function buildOutcomeSection(ticket, refs) {
|
|
937
|
+
const wrap = el("section", { class: "tm-exec-outcome" });
|
|
938
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "outcomeOverride");
|
|
939
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Outcome" }));
|
|
940
|
+
|
|
941
|
+
const derivedRow = el("div", { class: "tm-exec-outcome-derived" });
|
|
942
|
+
const steps = ticket.executionSteps || [];
|
|
943
|
+
const derived = (typeof core.deriveOutcome === "function")
|
|
944
|
+
? core.deriveOutcome(steps) : "pending";
|
|
945
|
+
const passedCount = steps.filter(s => s && s.status === "passed").length;
|
|
946
|
+
derivedRow.appendChild(el("span", { class: "tm-exec-outcome-derived-label", text: "Derived:" }));
|
|
947
|
+
const derivedPill = el("span", {
|
|
948
|
+
class: "tm-exec-outcome-pill",
|
|
949
|
+
text: derived + " (" + passedCount + "/" + steps.length + " passed)"
|
|
950
|
+
});
|
|
951
|
+
derivedPill.dataset.outcome = derived;
|
|
952
|
+
derivedRow.appendChild(derivedPill);
|
|
953
|
+
wrap.appendChild(derivedRow);
|
|
954
|
+
|
|
955
|
+
const overrideRow = el("div", { class: "tm-exec-outcome-override-row" });
|
|
956
|
+
overrideRow.appendChild(el("span", { class: "tm-exec-outcome-derived-label", text: "Override:" }));
|
|
957
|
+
const sel = el("select", { class: "tm-input tm-exec-outcome-override" });
|
|
958
|
+
const cur = ticket.outcomeOverride || "auto";
|
|
959
|
+
const OPTIONS = ["auto"].concat(core.TEST_EXEC_OUTCOMES
|
|
960
|
+
|| ["pending", "passed", "failed", "blocked", "skipped"]);
|
|
961
|
+
for (const v of OPTIONS) {
|
|
962
|
+
const opt = el("option", { value: v, text: v });
|
|
963
|
+
if (v === cur) opt.setAttribute("selected", "selected");
|
|
964
|
+
sel.appendChild(opt);
|
|
965
|
+
}
|
|
966
|
+
overrideRow.appendChild(sel);
|
|
967
|
+
const hint = el("span", { class: "tm-exec-outcome-override-hint", text: "manual override" });
|
|
968
|
+
if (cur === "auto") hint.style.display = "none";
|
|
969
|
+
overrideRow.appendChild(hint);
|
|
970
|
+
sel.addEventListener("change", () => {
|
|
971
|
+
hint.style.display = sel.value === "auto" ? "none" : "";
|
|
972
|
+
if (refs && refs.markDirty) refs.markDirty();
|
|
973
|
+
});
|
|
974
|
+
wrap.appendChild(overrideRow);
|
|
975
|
+
return wrap;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ---- SM-48: Links-Section ------------------------------------------
|
|
979
|
+
//
|
|
980
|
+
// Two-pane editor at the bottom of the modal:
|
|
981
|
+
// Forward links — outgoing from this ticket. Editable (× removes, +
|
|
982
|
+
// opens add-link dialog).
|
|
983
|
+
// Backward links — incoming from any ticket whose links[] references
|
|
984
|
+
// this ticket. Read-only — flip them at the source ticket instead.
|
|
985
|
+
//
|
|
986
|
+
// Each row shows: link-type pill (color from project.linkTypes.color),
|
|
987
|
+
// inverse/forward label, target ticket-key + title. Click on the
|
|
988
|
+
// ticket-key portion opens that ticket's modal (best effort — if the
|
|
989
|
+
// target was just deleted the click is a no-op).
|
|
990
|
+
//
|
|
991
|
+
// Live-sync: `data-sm-sync-key=links` marks the host so the syncField
|
|
992
|
+
// pulse helpers + the existing observer rebuild it on external commits.
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Resolve a linkType id to its catalogue entry. Returns a synthetic
|
|
996
|
+
* fallback for unknown ids so the UI keeps rendering (linkType deleted
|
|
997
|
+
* after the link was created — see SM-47 confirm).
|
|
998
|
+
*/
|
|
999
|
+
function lookupLinkType(project, linkTypeId) {
|
|
1000
|
+
const list = (project && project.linkTypes) || [];
|
|
1001
|
+
const hit = list.find(lt => lt.id === linkTypeId);
|
|
1002
|
+
if (hit) return hit;
|
|
1003
|
+
return { id: linkTypeId, label: linkTypeId, inverseLabel: linkTypeId, semantic: "freeform" };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Find tickets that link TO `targetTicketId` — backward edges.
|
|
1008
|
+
* Pure: returns `[ { sourceTicket, link } ]`. Excludes the target itself
|
|
1009
|
+
* defensively (a ticket should never link to itself, but core.validateLink
|
|
1010
|
+
* already enforces that).
|
|
1011
|
+
*/
|
|
1012
|
+
function findBackwardLinks(snapshot, targetTicketId) {
|
|
1013
|
+
const out = [];
|
|
1014
|
+
const tickets = (snapshot && snapshot.tickets) || [];
|
|
1015
|
+
for (const t of tickets) {
|
|
1016
|
+
if (!t.links || !t.links.length || t.id === targetTicketId) continue;
|
|
1017
|
+
for (const l of t.links) {
|
|
1018
|
+
if (l.targetTicketId === targetTicketId) out.push({ sourceTicket: t, link: l });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function buildLinkPill(linkType, kind) {
|
|
1025
|
+
const text = kind === "backward" ? (linkType.inverseLabel || linkType.label) : linkType.label;
|
|
1026
|
+
const pill = el("span", { class: "tm-link-pill tm-link-pill-" + kind, text: text });
|
|
1027
|
+
if (linkType.color) pill.style.backgroundColor = linkType.color;
|
|
1028
|
+
return pill;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function buildTargetRef(targetTicket) {
|
|
1032
|
+
const span = el("span", { class: "tm-link-target" });
|
|
1033
|
+
if (!targetTicket) {
|
|
1034
|
+
span.appendChild(el("span", { class: "tm-link-key tm-link-missing", text: "(deleted)" }));
|
|
1035
|
+
return span;
|
|
1036
|
+
}
|
|
1037
|
+
span.appendChild(el("span", { class: "tm-link-key", text: targetTicket.ticketKey || targetTicket.id }));
|
|
1038
|
+
span.appendChild(el("span", { class: "tm-link-title", text: " " + (targetTicket.title || "") }));
|
|
1039
|
+
return span;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function buildLinksSection(ticket, snapshot, ctx) {
|
|
1043
|
+
const wrap = el("section", { class: "tm-links" });
|
|
1044
|
+
wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "links");
|
|
1045
|
+
// Stash ctx on the wrap so live-sync rebuilds re-wire +/× handlers.
|
|
1046
|
+
wrap.__tmCtx = ctx || null;
|
|
1047
|
+
renderLinksSectionInto(wrap, ticket, snapshot, ctx);
|
|
1048
|
+
return wrap;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function renderLinksSectionInto(wrap, ticket, snapshot, ctx) {
|
|
1052
|
+
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
|
1053
|
+
const project = (snapshot && snapshot.project) || {};
|
|
1054
|
+
const tickets = (snapshot && snapshot.tickets) || [];
|
|
1055
|
+
const findTicket = (id) => tickets.find(t => t.id === id) || null;
|
|
1056
|
+
|
|
1057
|
+
// SM-70: In create-mode (ticket.id == null) the inline add-form pushes
|
|
1058
|
+
// links onto ticket.links directly (no store), so createOnSave can pass
|
|
1059
|
+
// them as `body.links`. Re-render is triggered via this closure.
|
|
1060
|
+
const isPending = !ticket.id;
|
|
1061
|
+
const rerender = () => renderLinksSectionInto(wrap, ticket, snapshot, ctx);
|
|
1062
|
+
|
|
1063
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Links" }));
|
|
1064
|
+
|
|
1065
|
+
// Forward.
|
|
1066
|
+
const fwdHost = el("div", { class: "tm-links-list tm-links-forward" });
|
|
1067
|
+
const forwardLinks = (ticket.links || []);
|
|
1068
|
+
if (!forwardLinks.length) {
|
|
1069
|
+
fwdHost.appendChild(el("div", { class: "tm-links-empty", text: "No outgoing links." }));
|
|
1070
|
+
} else {
|
|
1071
|
+
for (const link of forwardLinks) {
|
|
1072
|
+
const lt = lookupLinkType(project, link.linkTypeId);
|
|
1073
|
+
const tgt = findTicket(link.targetTicketId);
|
|
1074
|
+
const row = el("div", { class: "tm-link-row", dataset: { linkId: link.id } });
|
|
1075
|
+
row.appendChild(buildLinkPill(lt, "forward"));
|
|
1076
|
+
row.appendChild(buildTargetRef(tgt));
|
|
1077
|
+
const del = el("button", { class: "tm-link-delete", title: "Remove link", text: "×" });
|
|
1078
|
+
del.addEventListener("click", (ev) => {
|
|
1079
|
+
ev.stopPropagation();
|
|
1080
|
+
if (isPending) {
|
|
1081
|
+
// Mutate the in-memory draft and re-render.
|
|
1082
|
+
ticket.links = (ticket.links || []).filter(l => l.id !== link.id);
|
|
1083
|
+
rerender();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (!ctx || !ctx.store || !ticket.id) return;
|
|
1087
|
+
try {
|
|
1088
|
+
ctx.store.removeLink(ticket.id, link.id, LOCAL_ACTOR);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "remove failed", { kind: "error" });
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
row.appendChild(del);
|
|
1094
|
+
fwdHost.appendChild(row);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
wrap.appendChild(fwdHost);
|
|
1098
|
+
|
|
1099
|
+
// Inline add-form. Edit-mode → store.addLink. Create-mode → push onto
|
|
1100
|
+
// ticket.links (pending) and call rerender() so the new row shows up.
|
|
1101
|
+
wrap.appendChild(buildInlineAddLinkForm(ticket.id, snapshot, ctx, {
|
|
1102
|
+
pendingTicket: isPending ? ticket : null,
|
|
1103
|
+
onPendingChange: rerender
|
|
1104
|
+
}));
|
|
1105
|
+
|
|
1106
|
+
// Backward.
|
|
1107
|
+
const bwdHost = el("div", { class: "tm-links-list tm-links-backward" });
|
|
1108
|
+
bwdHost.appendChild(el("h5", { class: "tm-links-subhead", text: "Referenced by" }));
|
|
1109
|
+
const backward = ticket.id ? findBackwardLinks(snapshot, ticket.id) : [];
|
|
1110
|
+
if (!backward.length) {
|
|
1111
|
+
bwdHost.appendChild(el("div", { class: "tm-links-empty", text: "Nothing references this ticket." }));
|
|
1112
|
+
} else {
|
|
1113
|
+
for (const { sourceTicket, link } of backward) {
|
|
1114
|
+
const lt = lookupLinkType(project, link.linkTypeId);
|
|
1115
|
+
const row = el("div", { class: "tm-link-row tm-link-row-readonly" });
|
|
1116
|
+
row.appendChild(buildLinkPill(lt, "backward"));
|
|
1117
|
+
row.appendChild(buildTargetRef(sourceTicket));
|
|
1118
|
+
bwdHost.appendChild(row);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
wrap.appendChild(bwdHost);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Inline Add-Link form: lives at the bottom of the Links section in the
|
|
1126
|
+
* ticket-detail modal. Replaces the earlier popup-modal flow — the user
|
|
1127
|
+
* must never lose the ticket they're editing.
|
|
1128
|
+
*
|
|
1129
|
+
* Collapsed state: a "+ Add link" button.
|
|
1130
|
+
* Expanded state: Type-Select + searchable Target-Combobox (filter input +
|
|
1131
|
+
* popover list) + Add-button + Cancel-button. Submit calls store.addLink
|
|
1132
|
+
* and re-collapses the form so the user sees the new row in the list
|
|
1133
|
+
* above (live-sync rebuild handles that).
|
|
1134
|
+
*/
|
|
1135
|
+
function buildInlineAddLinkForm(sourceTicketId, snapshot, ctx, pendingOpts) {
|
|
1136
|
+
const wrap = el("div", { class: "tm-link-add-wrap" });
|
|
1137
|
+
const trigger = el("button", { class: "btn tm-link-add", text: "+ Add link", type: "button" });
|
|
1138
|
+
const form = el("div", { class: "tm-link-add-form", style: { display: "none" } });
|
|
1139
|
+
wrap.appendChild(trigger);
|
|
1140
|
+
wrap.appendChild(form);
|
|
1141
|
+
|
|
1142
|
+
// SM-70: pending-mode. When sourceTicketId is null (Create), the form
|
|
1143
|
+
// mutates pendingOpts.pendingTicket.links and re-renders the section.
|
|
1144
|
+
const pendingTicket = pendingOpts && pendingOpts.pendingTicket;
|
|
1145
|
+
const onPendingChange = (pendingOpts && pendingOpts.onPendingChange) || (() => {});
|
|
1146
|
+
|
|
1147
|
+
const project = (snapshot && snapshot.project) || {};
|
|
1148
|
+
const allTickets = (snapshot && snapshot.tickets || []).filter(t => !t.isDeleted && t.id !== sourceTicketId);
|
|
1149
|
+
const linkTypes = (project.linkTypes && project.linkTypes.length) ? project.linkTypes : [];
|
|
1150
|
+
|
|
1151
|
+
// Track the selected target via a closure variable — the combobox keeps
|
|
1152
|
+
// the display string ("T-2 — Story-B") in the input but the underlying
|
|
1153
|
+
// id is what we send to addLink.
|
|
1154
|
+
let selectedTargetId = null;
|
|
1155
|
+
|
|
1156
|
+
// SM-70: pre-select the link-type that matches the source ticket's
|
|
1157
|
+
// semantic role. test-definition → 'tests'; test-execution → 'executes'.
|
|
1158
|
+
// Falls back to the first defined linkType.
|
|
1159
|
+
const sourceTicket = pendingTicket
|
|
1160
|
+
|| (sourceTicketId ? allTickets.find(t => t.id === sourceTicketId) ||
|
|
1161
|
+
(snapshot && snapshot.tickets || []).find(t => t.id === sourceTicketId) : null);
|
|
1162
|
+
const sourceType = (sourceTicket && sourceTicket.type) || null;
|
|
1163
|
+
const preferredLinkTypeId =
|
|
1164
|
+
sourceType === "test-definition" && linkTypes.some(lt => lt.id === "tests") ? "tests"
|
|
1165
|
+
: sourceType === "test-execution" && linkTypes.some(lt => lt.id === "executes") ? "executes"
|
|
1166
|
+
: (linkTypes[0] && linkTypes[0].id);
|
|
1167
|
+
|
|
1168
|
+
// Type row.
|
|
1169
|
+
const typeRow = el("div", { class: "tm-link-add-row" });
|
|
1170
|
+
typeRow.appendChild(el("label", { class: "tm-label", text: "Type" }));
|
|
1171
|
+
const typeSel = el("select", { class: "tm-add-link-type" });
|
|
1172
|
+
for (const lt of linkTypes) {
|
|
1173
|
+
typeSel.appendChild(el("option", { value: lt.id, text: lt.label }));
|
|
1174
|
+
}
|
|
1175
|
+
if (preferredLinkTypeId) typeSel.value = preferredLinkTypeId;
|
|
1176
|
+
typeRow.appendChild(typeSel);
|
|
1177
|
+
form.appendChild(typeRow);
|
|
1178
|
+
|
|
1179
|
+
// Target combobox row: input + popover list of matches.
|
|
1180
|
+
const tgtRow = el("div", { class: "tm-link-add-row" });
|
|
1181
|
+
tgtRow.appendChild(el("label", { class: "tm-label", text: "Target" }));
|
|
1182
|
+
const comboWrap = el("div", { class: "tm-link-combo" });
|
|
1183
|
+
const filterInput = el("input", { type: "text", class: "tm-add-link-filter",
|
|
1184
|
+
placeholder: "Ticket key or title…", autocomplete: "off" });
|
|
1185
|
+
const popover = el("div", { class: "tm-link-combo-popover", style: { display: "none" } });
|
|
1186
|
+
comboWrap.appendChild(filterInput);
|
|
1187
|
+
comboWrap.appendChild(popover);
|
|
1188
|
+
tgtRow.appendChild(comboWrap);
|
|
1189
|
+
form.appendChild(tgtRow);
|
|
1190
|
+
|
|
1191
|
+
function refillPopover(term) {
|
|
1192
|
+
popover.innerHTML = "";
|
|
1193
|
+
const q = (term || "").trim().toLowerCase();
|
|
1194
|
+
let matched = 0;
|
|
1195
|
+
for (const t of allTickets) {
|
|
1196
|
+
const display = (t.ticketKey || t.id) + " — " + (t.title || "");
|
|
1197
|
+
if (q && display.toLowerCase().indexOf(q) < 0) continue;
|
|
1198
|
+
const opt = el("div", { class: "tm-link-combo-option", dataset: { ticketId: t.id }, text: display });
|
|
1199
|
+
opt.addEventListener("mousedown", (ev) => {
|
|
1200
|
+
// mousedown (not click) so it fires before input.blur hides the popover.
|
|
1201
|
+
ev.preventDefault();
|
|
1202
|
+
selectedTargetId = t.id;
|
|
1203
|
+
filterInput.value = display;
|
|
1204
|
+
popover.style.display = "none";
|
|
1205
|
+
});
|
|
1206
|
+
popover.appendChild(opt);
|
|
1207
|
+
matched++;
|
|
1208
|
+
if (matched >= 30) break; // cap rendered list — keep popover usable
|
|
1209
|
+
}
|
|
1210
|
+
popover.style.display = matched > 0 ? "block" : "none";
|
|
1211
|
+
}
|
|
1212
|
+
filterInput.addEventListener("focus", () => refillPopover(filterInput.value));
|
|
1213
|
+
filterInput.addEventListener("input", () => {
|
|
1214
|
+
selectedTargetId = null; // typing invalidates the prior selection
|
|
1215
|
+
refillPopover(filterInput.value);
|
|
1216
|
+
});
|
|
1217
|
+
filterInput.addEventListener("blur", () => {
|
|
1218
|
+
// Defer-hide so the option's mousedown can fire first.
|
|
1219
|
+
setTimeout(() => { popover.style.display = "none"; }, 80);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Action buttons (inline, not separate modal).
|
|
1223
|
+
const actions = el("div", { class: "tm-link-add-actions" });
|
|
1224
|
+
const cancelBtn = el("button", { class: "btn tm-link-add-cancel", text: "Cancel", type: "button" });
|
|
1225
|
+
const addBtn = el("button", { class: "btn tm-link-add-submit", text: "Add", type: "button" });
|
|
1226
|
+
actions.appendChild(cancelBtn);
|
|
1227
|
+
actions.appendChild(addBtn);
|
|
1228
|
+
form.appendChild(actions);
|
|
1229
|
+
|
|
1230
|
+
function reset() {
|
|
1231
|
+
filterInput.value = "";
|
|
1232
|
+
selectedTargetId = null;
|
|
1233
|
+
popover.style.display = "none";
|
|
1234
|
+
if (preferredLinkTypeId) typeSel.value = preferredLinkTypeId;
|
|
1235
|
+
else if (linkTypes.length) typeSel.value = linkTypes[0].id;
|
|
1236
|
+
}
|
|
1237
|
+
function collapse() {
|
|
1238
|
+
form.style.display = "none";
|
|
1239
|
+
trigger.style.display = "";
|
|
1240
|
+
reset();
|
|
1241
|
+
}
|
|
1242
|
+
function expand() {
|
|
1243
|
+
trigger.style.display = "none";
|
|
1244
|
+
form.style.display = "";
|
|
1245
|
+
reset();
|
|
1246
|
+
// Defer focus until the form is in the DOM.
|
|
1247
|
+
setTimeout(() => filterInput.focus(), 0);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
trigger.addEventListener("click", (ev) => { ev.stopPropagation(); expand(); });
|
|
1251
|
+
cancelBtn.addEventListener("click", (ev) => { ev.stopPropagation(); collapse(); });
|
|
1252
|
+
addBtn.addEventListener("click", (ev) => {
|
|
1253
|
+
ev.stopPropagation();
|
|
1254
|
+
const linkTypeId = typeSel.value;
|
|
1255
|
+
if (!linkTypeId) {
|
|
1256
|
+
if (ctx && ctx.flashStatus) ctx.flashStatus("pick a link type", { kind: "error" });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (!selectedTargetId) {
|
|
1260
|
+
if (ctx && ctx.flashStatus) ctx.flashStatus("pick a target ticket from the list", { kind: "error" });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
// SM-70: pending-mode pushes onto the draft ticket; the rerender call
|
|
1264
|
+
// builds a fresh forward-list including the new row.
|
|
1265
|
+
if (pendingTicket) {
|
|
1266
|
+
const pendingId = "ln-pending-" + Math.random().toString(36).slice(2, 10);
|
|
1267
|
+
pendingTicket.links = pendingTicket.links || [];
|
|
1268
|
+
// Avoid duplicates (same linkTypeId + targetTicketId).
|
|
1269
|
+
const dup = pendingTicket.links.some(l =>
|
|
1270
|
+
l.linkTypeId === linkTypeId && l.targetTicketId === selectedTargetId);
|
|
1271
|
+
if (dup) {
|
|
1272
|
+
if (ctx && ctx.flashStatus) ctx.flashStatus("link already added", { kind: "error" });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
pendingTicket.links.push({
|
|
1276
|
+
id: pendingId,
|
|
1277
|
+
linkTypeId: linkTypeId,
|
|
1278
|
+
targetTicketId: selectedTargetId,
|
|
1279
|
+
createdAt: Date.now()
|
|
1280
|
+
});
|
|
1281
|
+
collapse();
|
|
1282
|
+
onPendingChange();
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
try {
|
|
1286
|
+
ctx.store.addLink(sourceTicketId, { linkTypeId, targetTicketId: selectedTargetId }, LOCAL_ACTOR);
|
|
1287
|
+
collapse();
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
if (ctx && ctx.flashStatus) ctx.flashStatus(err.message || "add failed", { kind: "error" });
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
return wrap;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ---- Field-Population (Live-Sync) ------------------------------------
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Update a single sync-keyed field's value from the latest ticket,
|
|
1300
|
+
* UNLESS the field is currently focused (user is typing). Skipping
|
|
1301
|
+
* focused fields prevents stomp.
|
|
1302
|
+
*/
|
|
1303
|
+
function syncFieldFromTicket(modal, ticket, snapshot) {
|
|
1304
|
+
const focused = document.activeElement;
|
|
1305
|
+
function shouldSkip(elNode) { return elNode === focused; }
|
|
1306
|
+
|
|
1307
|
+
function pick(key) { return modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]'); }
|
|
1308
|
+
|
|
1309
|
+
const titleEl = pick("title");
|
|
1310
|
+
if (titleEl && !shouldSkip(titleEl) && titleEl.value !== ticket.title) titleEl.value = ticket.title || "";
|
|
1311
|
+
|
|
1312
|
+
const typeEl = pick("type");
|
|
1313
|
+
if (typeEl && !shouldSkip(typeEl) && typeEl.value !== ticket.type) typeEl.value = ticket.type;
|
|
1314
|
+
|
|
1315
|
+
let statusEl = pick("status");
|
|
1316
|
+
if (statusEl && !shouldSkip(statusEl)) {
|
|
1317
|
+
// SM-238: if the ticket's type no longer matches the rendered control
|
|
1318
|
+
// (epic ⇄ work item, e.g. after an in-editor type change), swap it in
|
|
1319
|
+
// place — the editable <select> for work items, the read-only derived
|
|
1320
|
+
// pill for epics. This keeps the modal AND the full-page editor consistent
|
|
1321
|
+
// without each surface wiring its own type-change handler.
|
|
1322
|
+
const isEpic = ticket.type === "epic";
|
|
1323
|
+
const isPill = !!(statusEl.classList && statusEl.classList.contains("tm-status-derived"));
|
|
1324
|
+
if (isEpic !== isPill && statusEl.closest) {
|
|
1325
|
+
const row = statusEl.closest(".tm-row");
|
|
1326
|
+
if (row && row.parentNode) {
|
|
1327
|
+
const wf = (typeof core.getWorkflowForType === "function")
|
|
1328
|
+
? core.getWorkflowForType(snapshot && snapshot.project, ticket.type) : null;
|
|
1329
|
+
const statuses = wf ? wf.statuses.map(s => (typeof s === "string" ? s : s.id)) : [ticket.status];
|
|
1330
|
+
row.parentNode.replaceChild(buildStatusField(ticket, statuses, snapshot), row);
|
|
1331
|
+
statusEl = pick("status");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (statusEl && !shouldSkip(statusEl)) {
|
|
1336
|
+
// SM-238: the epic derived-status pill is a span, not a <select> — update
|
|
1337
|
+
// its textContent + recompute the breakdown from the fresh snapshot (a
|
|
1338
|
+
// child status change shifts the counts without touching the epic itself).
|
|
1339
|
+
if (statusEl.classList && statusEl.classList.contains("tm-status-derived")) {
|
|
1340
|
+
if (statusEl.textContent !== ticket.status) statusEl.textContent = ticket.status;
|
|
1341
|
+
statusEl.dataset.status = ticket.status;
|
|
1342
|
+
const bd = pick("status_breakdown");
|
|
1343
|
+
if (bd && snapshot && typeof core.epicChildStats === "function") {
|
|
1344
|
+
bd.textContent = epicStatusBreakdownText(core.epicChildStats(snapshot, ticket.id));
|
|
1345
|
+
}
|
|
1346
|
+
} else if (statusEl.value !== ticket.status) {
|
|
1347
|
+
statusEl.value = ticket.status;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const releaseEl = pick("position.releaseId");
|
|
1352
|
+
const rid = (ticket.position && ticket.position.releaseId) || "";
|
|
1353
|
+
if (releaseEl && !shouldSkip(releaseEl) && releaseEl.value !== rid) releaseEl.value = rid;
|
|
1354
|
+
|
|
1355
|
+
const psEl = pick("position.processStepId");
|
|
1356
|
+
const psid = (ticket.position && ticket.position.processStepId) || "";
|
|
1357
|
+
if (psEl && !shouldSkip(psEl) && psEl.value !== psid) psEl.value = psid;
|
|
1358
|
+
|
|
1359
|
+
// SM-152: the container epic lives in a `contains`-link (SM-52 —
|
|
1360
|
+
// ticket.position.epicId is always null). Reading position.epicId here set
|
|
1361
|
+
// the dropdown to "— none —" on every external commit and a follow-up save
|
|
1362
|
+
// then dropped the contains-link, silently detaching the story. Resolve via
|
|
1363
|
+
// containerEpicIdOf and re-run inheritance (dispatch change) so the
|
|
1364
|
+
// Release/ProcessStep dropdowns track the container.
|
|
1365
|
+
const epicEl = pick("position.epicId");
|
|
1366
|
+
if (epicEl && !shouldSkip(epicEl)) {
|
|
1367
|
+
const containerId = (snapshot && typeof core.containerEpicIdOf === "function")
|
|
1368
|
+
? (core.containerEpicIdOf(snapshot, ticket.id) || "")
|
|
1369
|
+
: ((ticket.position && ticket.position.epicId) || "");
|
|
1370
|
+
if (epicEl.value !== containerId) {
|
|
1371
|
+
epicEl.value = containerId;
|
|
1372
|
+
// The change listener installed in buildPositionFields re-applies
|
|
1373
|
+
// inheritance using its own epicById closure.
|
|
1374
|
+
try { epicEl.dispatchEvent(new Event("change")); } catch (_) { /* ignore */ }
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const descEl = pick("description");
|
|
1379
|
+
if (descEl && !shouldSkip(descEl) && readFieldText(descEl) !== (ticket.description || "")) {
|
|
1380
|
+
writeFieldText(descEl, ticket.description || "");
|
|
1381
|
+
// SM-157: keep the inline-confirm baseline in step with the external value.
|
|
1382
|
+
if (typeof descEl.__tfSetBaseline === "function") descEl.__tfSetBaseline(ticket.description || "");
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const labelsEl = pick("labels");
|
|
1386
|
+
const labelsValue = (ticket.labels || []).join(", ");
|
|
1387
|
+
if (labelsEl && !shouldSkip(labelsEl) && labelsEl.value !== labelsValue) labelsEl.value = labelsValue;
|
|
1388
|
+
|
|
1389
|
+
// SM-152: Acceptance Criteria — reflect external edits, but skip the
|
|
1390
|
+
// rebuild while the user is typing inside the section (focus-guard, same
|
|
1391
|
+
// as prerequisites/steps). Pairs with the buildUpdatePatch diff-guard so
|
|
1392
|
+
// an unrelated local save can't clobber a freshly-synced AC list.
|
|
1393
|
+
const acWrap = pick("acceptanceCriteria");
|
|
1394
|
+
if (acWrap && !(document.activeElement && acWrap.contains(document.activeElement))) {
|
|
1395
|
+
rebuildAcInPlace(acWrap, ticket.acceptanceCriteria || []);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// DoR / DoD checklists: rebuild fully (state changes can be sparse). The
|
|
1399
|
+
// checkboxes never receive keyboard focus during typing (a click toggles
|
|
1400
|
+
// them), so we don't need a focus-aware diff here.
|
|
1401
|
+
const dorWrap = pick("definitionOfReady");
|
|
1402
|
+
const dodWrap = pick("definitionOfDone");
|
|
1403
|
+
if (dorWrap) rebuildChecklistInPlace(dorWrap, (ticket.definitionOfReady || { items: [] }).items, "DoR");
|
|
1404
|
+
if (dodWrap) rebuildChecklistInPlace(dodWrap, (ticket.definitionOfDone || { items: [] }).items, "DoD");
|
|
1405
|
+
|
|
1406
|
+
// SM-48: Links rebuild. Same focus-guard semantics as DoR/DoD — but
|
|
1407
|
+
// the Links section has no editable text inputs, so a flat rebuild
|
|
1408
|
+
// is safe even when the section is the active area (the +Add modal
|
|
1409
|
+
// is a separate overlay; closing it triggers the rebuild). Skip if
|
|
1410
|
+
// a target lookup needs `snapshot.tickets` and we have none.
|
|
1411
|
+
const linksWrap = pick("links");
|
|
1412
|
+
if (linksWrap && snapshot) {
|
|
1413
|
+
// ctx is not in scope here; the existing buildLinksSection accepts
|
|
1414
|
+
// an undefined ctx for read-only render — × and + buttons re-attach
|
|
1415
|
+
// only when called from the modal's own factory (with ctx). For the
|
|
1416
|
+
// live-sync path, render-only is enough: the user reopens the modal
|
|
1417
|
+
// for any local action and the modal carries ctx itself.
|
|
1418
|
+
renderLinksSectionInto(linksWrap, ticket, snapshot, linksWrap.__tmCtx || null);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// SM-54: Prerequisites + Steps. Rebuild only if no input inside the
|
|
1422
|
+
// section currently has focus (don't stomp a typing user). The rebuild
|
|
1423
|
+
// re-uses buildPrereqRow / buildTestStepRow so the new rows get fresh
|
|
1424
|
+
// event listeners + dnd hooks.
|
|
1425
|
+
const prereqsWrap = pick("prerequisites");
|
|
1426
|
+
if (prereqsWrap && !(document.activeElement && prereqsWrap.contains(document.activeElement))) {
|
|
1427
|
+
rebuildPrereqsInPlace(prereqsWrap, ticket.prerequisites || []);
|
|
1428
|
+
}
|
|
1429
|
+
const stepsWrap = pick("steps");
|
|
1430
|
+
if (stepsWrap && !(document.activeElement && stepsWrap.contains(document.activeElement))) {
|
|
1431
|
+
rebuildTestStepsInPlace(stepsWrap, ticket.steps || []);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// SM-152: live-sync rebuild of the Acceptance-Criteria list. Mirrors
|
|
1436
|
+
// rebuildPrereqsInPlace: drop + rebuild rows with a no-op markDirty (the
|
|
1437
|
+
// "+ Criterion" trigger is omitted — the user reopens the modal to add).
|
|
1438
|
+
function rebuildAcInPlace(wrap, items) {
|
|
1439
|
+
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
|
1440
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Acceptance Criteria" }));
|
|
1441
|
+
const list = el("div", { class: "tm-ac-list" });
|
|
1442
|
+
const refs = { markDirty: () => {} };
|
|
1443
|
+
for (const ac of items) list.appendChild(buildAcRow(ac, refs));
|
|
1444
|
+
wrap.appendChild(list);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function rebuildPrereqsInPlace(wrap, items) {
|
|
1448
|
+
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
|
1449
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Prerequisites" }));
|
|
1450
|
+
const list = el("div", { class: "tm-prereq-list" });
|
|
1451
|
+
const refs = { markDirty: () => {} };
|
|
1452
|
+
for (const item of items) list.appendChild(buildPrereqRow(item, refs));
|
|
1453
|
+
wrap.appendChild(list);
|
|
1454
|
+
// Note: live-sync rebuild skips the "+ Add" trigger to avoid wiring two
|
|
1455
|
+
// refs.markDirty into the same ticket-modal — the user reopens the modal
|
|
1456
|
+
// to add new items, which is consistent with how DoR/DoD live-sync works.
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function rebuildTestStepsInPlace(wrap, items) {
|
|
1460
|
+
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
|
1461
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Steps" }));
|
|
1462
|
+
// SM-152: header must mirror buildStepsSection's 6-cell grid (grip + step
|
|
1463
|
+
// + data + expected + insert + remove) so the labels sit above the right
|
|
1464
|
+
// columns after an external commit — the old 3-cell head misaligned them.
|
|
1465
|
+
const tableHead = el("div", { class: "tm-test-step-head" }, [
|
|
1466
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-grip" }),
|
|
1467
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-step", text: "Step" }),
|
|
1468
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-data", text: "Data" }),
|
|
1469
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-expected", text: "Expected Result" }),
|
|
1470
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-insert" }),
|
|
1471
|
+
el("span", { class: "tm-test-step-head-cell tm-test-step-head-remove" })
|
|
1472
|
+
]);
|
|
1473
|
+
wrap.appendChild(tableHead);
|
|
1474
|
+
const list = el("div", { class: "tm-test-step-list" });
|
|
1475
|
+
const refs = { markDirty: () => {} };
|
|
1476
|
+
for (const item of items) list.appendChild(buildTestStepRow(item, refs, list));
|
|
1477
|
+
wrap.appendChild(list);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* SM-5: Pulse every sync-keyed field that differs between prev and next
|
|
1482
|
+
* ticket. Called only when the upstream subscribe sees `reason ===
|
|
1483
|
+
* "applyRemote"` (external edit — MCP or another browser); local commits
|
|
1484
|
+
* skip this because the user already has direct feedback from typing.
|
|
1485
|
+
* Reuses the `.sm-card-flash` keyframe (SM-20 widened that selector to
|
|
1486
|
+
* cover anything that gets the class).
|
|
1487
|
+
*
|
|
1488
|
+
* Returns the set of keys that pulsed, for tests + telemetry.
|
|
1489
|
+
*/
|
|
1490
|
+
function diffSyncFieldKeys(prev, next) {
|
|
1491
|
+
const out = new Set();
|
|
1492
|
+
if (!prev || !next) return out;
|
|
1493
|
+
const simpleKeys = ["title", "type", "status", "description"];
|
|
1494
|
+
for (const k of simpleKeys) {
|
|
1495
|
+
if ((prev[k] || "") !== (next[k] || "")) out.add(k);
|
|
1496
|
+
}
|
|
1497
|
+
const prevPos = prev.position || {};
|
|
1498
|
+
const nextPos = next.position || {};
|
|
1499
|
+
if ((prevPos.releaseId || "") !== (nextPos.releaseId || "")) out.add("position.releaseId");
|
|
1500
|
+
if ((prevPos.processStepId || "") !== (nextPos.processStepId || "")) out.add("position.processStepId");
|
|
1501
|
+
if ((prevPos.epicId || "") !== (nextPos.epicId || "")) out.add("position.epicId");
|
|
1502
|
+
const prevLabels = (prev.labels || []).join(",");
|
|
1503
|
+
const nextLabels = (next.labels || []).join(",");
|
|
1504
|
+
if (prevLabels !== nextLabels) out.add("labels");
|
|
1505
|
+
if (JSON.stringify(prev.acceptanceCriteria || []) !== JSON.stringify(next.acceptanceCriteria || [])) {
|
|
1506
|
+
out.add("acceptanceCriteria");
|
|
1507
|
+
}
|
|
1508
|
+
const prevDor = JSON.stringify((prev.definitionOfReady && prev.definitionOfReady.items) || []);
|
|
1509
|
+
const nextDor = JSON.stringify((next.definitionOfReady && next.definitionOfReady.items) || []);
|
|
1510
|
+
if (prevDor !== nextDor) out.add("definitionOfReady");
|
|
1511
|
+
const prevDod = JSON.stringify((prev.definitionOfDone && prev.definitionOfDone.items) || []);
|
|
1512
|
+
const nextDod = JSON.stringify((next.definitionOfDone && next.definitionOfDone.items) || []);
|
|
1513
|
+
if (prevDod !== nextDod) out.add("definitionOfDone");
|
|
1514
|
+
// SM-48: pulse the Links section when forward links on this ticket change.
|
|
1515
|
+
// Backward-link changes (on other tickets' links[]) don't pulse — the
|
|
1516
|
+
// diff helper only sees this ticket's prev/next.
|
|
1517
|
+
const prevLinks = JSON.stringify(prev.links || []);
|
|
1518
|
+
const nextLinks = JSON.stringify(next.links || []);
|
|
1519
|
+
if (prevLinks !== nextLinks) out.add("links");
|
|
1520
|
+
// SM-54: pulse Prereqs / Steps sections when those fields change.
|
|
1521
|
+
if (JSON.stringify(prev.prerequisites || []) !== JSON.stringify(next.prerequisites || [])) {
|
|
1522
|
+
out.add("prerequisites");
|
|
1523
|
+
}
|
|
1524
|
+
if (JSON.stringify(prev.steps || []) !== JSON.stringify(next.steps || [])) {
|
|
1525
|
+
out.add("steps");
|
|
1526
|
+
}
|
|
1527
|
+
return out;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function pulseChangedFields(modal, prevTicket, nextTicket) {
|
|
1531
|
+
const keys = diffSyncFieldKeys(prevTicket, nextTicket);
|
|
1532
|
+
for (const key of keys) {
|
|
1533
|
+
// Multiple matches are possible (theoretically) — pulse all of them.
|
|
1534
|
+
const nodes = modal.querySelectorAll('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]');
|
|
1535
|
+
for (const node of nodes) {
|
|
1536
|
+
// Restart-the-animation pattern: remove → force reflow → re-add.
|
|
1537
|
+
node.classList.remove(CONSTANTS.FIELD_PULSE_CLASS);
|
|
1538
|
+
// eslint-disable-next-line no-unused-expressions
|
|
1539
|
+
void node.offsetWidth;
|
|
1540
|
+
node.classList.add(CONSTANTS.FIELD_PULSE_CLASS);
|
|
1541
|
+
setTimeout(function () { node.classList.remove(CONSTANTS.FIELD_PULSE_CLASS); }, CONSTANTS.FIELD_PULSE_MS);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return keys;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* E19-followup: Live-sync für DoR/DoD ist jetzt anders — die Section ist
|
|
1549
|
+
* ein voll editierbarer Editor mit Inputs. Wenn der User gerade darin
|
|
1550
|
+
* tippt (irgendein Input mit Focus), nicht stompen. Sonst Editor neu
|
|
1551
|
+
* rendern (Items kommen vom WS-Push). Focus-Guard analog Title-Input.
|
|
1552
|
+
*/
|
|
1553
|
+
function rebuildChecklistInPlace(wrap, items, kind) {
|
|
1554
|
+
// Focus-Guard: wenn ein Input innerhalb der Section gerade Focus hat,
|
|
1555
|
+
// überspring den Rebuild — der User tippt gerade.
|
|
1556
|
+
const focused = document.activeElement;
|
|
1557
|
+
if (focused && wrap.contains(focused)) return;
|
|
1558
|
+
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
|
1559
|
+
wrap.appendChild(el("h4", { class: "tm-section-title", text: "Definition of " + (kind === "DoR" ? "Ready" : "Done") }));
|
|
1560
|
+
const list = el("div", { class: "tm-checklist-list" });
|
|
1561
|
+
const refs = { markDirty: () => {} };
|
|
1562
|
+
for (const item of items) list.appendChild(buildCheckRow(item, refs));
|
|
1563
|
+
wrap.appendChild(list);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ---- Diff: form-state → patch ---------------------------------------
|
|
1567
|
+
|
|
1568
|
+
function collectFormState(modal) {
|
|
1569
|
+
function pick(key) { return modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]'); }
|
|
1570
|
+
// readFieldText handles both form controls (.value) and the contentEditable
|
|
1571
|
+
// description block (innerText/textContent). SM-119.
|
|
1572
|
+
function val(key) { const n = pick(key); return n ? readFieldText(n) : undefined; }
|
|
1573
|
+
function labelsArr() {
|
|
1574
|
+
const v = val("labels") || "";
|
|
1575
|
+
return v.split(",").map(s => s.trim()).filter(Boolean);
|
|
1576
|
+
}
|
|
1577
|
+
const acRows = Array.from(modal.querySelectorAll(".tm-ac-row"));
|
|
1578
|
+
const acceptanceCriteria = acRows.map(row => {
|
|
1579
|
+
const checkbox = row.querySelector('input[type="checkbox"]');
|
|
1580
|
+
const text = row.querySelector('input[type="text"]');
|
|
1581
|
+
return { id: row.dataset.acId, text: text.value, completed: !!checkbox.checked };
|
|
1582
|
+
});
|
|
1583
|
+
return {
|
|
1584
|
+
title: val("title"),
|
|
1585
|
+
type: val("type"),
|
|
1586
|
+
status: val("status"),
|
|
1587
|
+
description: val("description"),
|
|
1588
|
+
position: {
|
|
1589
|
+
releaseId: val("position.releaseId") || null,
|
|
1590
|
+
processStepId: val("position.processStepId") || null,
|
|
1591
|
+
epicId: val("position.epicId") || null
|
|
1592
|
+
},
|
|
1593
|
+
acceptanceCriteria: acceptanceCriteria,
|
|
1594
|
+
labels: labelsArr(),
|
|
1595
|
+
definitionOfReady: { items: collectChecklistItems(modal, "definitionOfReady") },
|
|
1596
|
+
definitionOfDone: { items: collectChecklistItems(modal, "definitionOfDone") },
|
|
1597
|
+
// SM-54: test-definition sections. `null` (= "not rendered") signals
|
|
1598
|
+
// buildUpdatePatch to leave the existing values alone; an array means
|
|
1599
|
+
// "this is the new full list".
|
|
1600
|
+
prerequisites: collectPrerequisites(modal),
|
|
1601
|
+
steps: collectTestSteps(modal),
|
|
1602
|
+
// SM-57: test-execution sections. Same null-vs-array convention.
|
|
1603
|
+
// outcomeOverride is `undefined` when the section isn't rendered;
|
|
1604
|
+
// null when the user picked "auto"; a string otherwise.
|
|
1605
|
+
executionSteps: collectExecutionSteps(modal),
|
|
1606
|
+
outcomeOverride: collectOutcomeOverride(modal)
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* SM-57: Read execution-step rows. Returns `null` if the section isn't
|
|
1612
|
+
* rendered (wrong type / gate off). Steps are NEVER added or removed
|
|
1613
|
+
* here — we just read whatever's in the DOM. The frozen step/data/
|
|
1614
|
+
* expectedResult fields are sent back as-is (disabled inputs still carry
|
|
1615
|
+
* their value).
|
|
1616
|
+
*/
|
|
1617
|
+
function collectExecutionSteps(modal) {
|
|
1618
|
+
const wrap = modal.querySelector('section.tm-exec-steps[' + CONSTANTS.LIVE_SYNC_ATTR + '="executionSteps"]');
|
|
1619
|
+
if (!wrap) return null;
|
|
1620
|
+
const rows = wrap.querySelectorAll(".tm-exec-step-row");
|
|
1621
|
+
const out = [];
|
|
1622
|
+
rows.forEach((row) => {
|
|
1623
|
+
const stepEl = row.querySelector(".tm-exec-step-step");
|
|
1624
|
+
const dataEl = row.querySelector(".tm-exec-step-data");
|
|
1625
|
+
const expectedEl = row.querySelector(".tm-exec-step-expected");
|
|
1626
|
+
const actualEl = row.querySelector(".tm-exec-step-actual");
|
|
1627
|
+
const statusEl = row.querySelector(".tm-exec-step-status");
|
|
1628
|
+
const noteEl = row.querySelector(".tm-exec-step-note");
|
|
1629
|
+
const noteVal = (noteEl && noteEl.value || "").trim();
|
|
1630
|
+
const item = {
|
|
1631
|
+
id: row.dataset.itemId,
|
|
1632
|
+
stepId: row.dataset.itemId, // frontend mirrors stepId == itemId on the run-row
|
|
1633
|
+
step: (stepEl && stepEl.value) || "",
|
|
1634
|
+
data: (dataEl && dataEl.value) || "",
|
|
1635
|
+
expectedResult: (expectedEl && expectedEl.value) || "",
|
|
1636
|
+
actualResult: (actualEl && actualEl.value) || "",
|
|
1637
|
+
status: (statusEl && statusEl.value) || "pending"
|
|
1638
|
+
};
|
|
1639
|
+
if (noteVal) item.note = noteVal;
|
|
1640
|
+
out.push(item);
|
|
1641
|
+
});
|
|
1642
|
+
return out;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* SM-57: Read the outcome-override picker value. Returns:
|
|
1647
|
+
* - undefined → section not rendered (don't touch ticket.outcomeOverride)
|
|
1648
|
+
* - null → user picked "auto" (= clear the manual override)
|
|
1649
|
+
* - string → one of the enum values
|
|
1650
|
+
*/
|
|
1651
|
+
function collectOutcomeOverride(modal) {
|
|
1652
|
+
const sel = modal.querySelector(".tm-exec-outcome-override");
|
|
1653
|
+
if (!sel) return undefined;
|
|
1654
|
+
const v = sel.value;
|
|
1655
|
+
if (v === "auto" || !v) return null;
|
|
1656
|
+
return v;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* SM-54: Read Prerequisites rows from the DOM in current order. Empty-label
|
|
1661
|
+
* rows are dropped (analog DoR/DoD). Returns `null` if the section isn't
|
|
1662
|
+
* rendered (gate off or wrong type), so buildUpdatePatch can leave the
|
|
1663
|
+
* existing prerequisites untouched.
|
|
1664
|
+
*/
|
|
1665
|
+
function collectPrerequisites(modal) {
|
|
1666
|
+
const wrap = modal.querySelector('section.tm-prereqs[' + CONSTANTS.LIVE_SYNC_ATTR + '="prerequisites"]');
|
|
1667
|
+
if (!wrap) return null;
|
|
1668
|
+
const rows = wrap.querySelectorAll(".tm-prereq-row");
|
|
1669
|
+
const out = [];
|
|
1670
|
+
rows.forEach((row) => {
|
|
1671
|
+
const labelInput = row.querySelector(".tm-prereq-text-input");
|
|
1672
|
+
const label = (labelInput && labelInput.value || "").trim();
|
|
1673
|
+
if (!label) return;
|
|
1674
|
+
const checkedInput = row.querySelector(".tm-prereq-checked");
|
|
1675
|
+
const requiredInput = row.querySelector(".tm-prereq-required");
|
|
1676
|
+
out.push({
|
|
1677
|
+
id: row.dataset.itemId,
|
|
1678
|
+
label: label,
|
|
1679
|
+
required: !!(requiredInput && requiredInput.checked),
|
|
1680
|
+
checked: !!(checkedInput && checkedInput.checked)
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
return out;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* SM-54: Read Steps rows from the DOM. A row whose THREE text fields are
|
|
1688
|
+
* all empty is dropped (empty placeholder added by "+ Add Step" but not
|
|
1689
|
+
* filled in). Returns `null` if the section isn't rendered.
|
|
1690
|
+
*/
|
|
1691
|
+
function collectTestSteps(modal) {
|
|
1692
|
+
const wrap = modal.querySelector('section.tm-test-steps[' + CONSTANTS.LIVE_SYNC_ATTR + '="steps"]');
|
|
1693
|
+
if (!wrap) return null;
|
|
1694
|
+
const rows = wrap.querySelectorAll(".tm-test-step-row");
|
|
1695
|
+
const out = [];
|
|
1696
|
+
rows.forEach((row) => {
|
|
1697
|
+
const stepInput = row.querySelector(".tm-test-step-step");
|
|
1698
|
+
const dataInput = row.querySelector(".tm-test-step-data");
|
|
1699
|
+
const expectedInput = row.querySelector(".tm-test-step-expected");
|
|
1700
|
+
const step = (stepInput && stepInput.value || "").trim();
|
|
1701
|
+
const data = (dataInput && dataInput.value || "").trim();
|
|
1702
|
+
const expected = (expectedInput && expectedInput.value || "").trim();
|
|
1703
|
+
if (!step && !data && !expected) return;
|
|
1704
|
+
out.push({
|
|
1705
|
+
id: row.dataset.itemId,
|
|
1706
|
+
step: step,
|
|
1707
|
+
data: data,
|
|
1708
|
+
expectedResult: expected
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
return out;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Read DoR/DoD rows from the DOM in current order. Empty-label rows are
|
|
1716
|
+
* dropped (analog dialog-definitions-settings). E19-followup.
|
|
1717
|
+
*/
|
|
1718
|
+
function collectChecklistItems(modal, syncKey) {
|
|
1719
|
+
const wrap = modal.querySelector('section.tm-checklist[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + syncKey + '"]');
|
|
1720
|
+
if (!wrap) return null; // section not rendered for this type (gate off)
|
|
1721
|
+
const rows = wrap.querySelectorAll(".tm-checkitem-row");
|
|
1722
|
+
const out = [];
|
|
1723
|
+
rows.forEach((row) => {
|
|
1724
|
+
const labelInput = row.querySelector(".tm-checkitem-text-input");
|
|
1725
|
+
const label = (labelInput && labelInput.value || "").trim();
|
|
1726
|
+
if (!label) return;
|
|
1727
|
+
const checkedInput = row.querySelector(".tm-checkitem-checked");
|
|
1728
|
+
const requiredInput = row.querySelector(".tm-checkitem-required");
|
|
1729
|
+
out.push({
|
|
1730
|
+
id: row.dataset.itemId,
|
|
1731
|
+
label: label,
|
|
1732
|
+
required: !!(requiredInput && requiredInput.checked),
|
|
1733
|
+
checked: !!(checkedInput && checkedInput.checked)
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
return out;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Produce a "field patch" suitable for PUT /tickets/:tid (which uses
|
|
1741
|
+
* updateTicket allowing: title/description/type/status/position/
|
|
1742
|
+
* acceptanceCriteria/labels). Position keeps the existing sortOrder
|
|
1743
|
+
* (caller doesn't reorder from the modal).
|
|
1744
|
+
*/
|
|
1745
|
+
function buildUpdatePatch(form, original) {
|
|
1746
|
+
const patch = {};
|
|
1747
|
+
if (form.title !== original.title) patch.title = form.title;
|
|
1748
|
+
if (form.type !== original.type) patch.type = form.type;
|
|
1749
|
+
if (form.description !== original.description) patch.description = form.description;
|
|
1750
|
+
const op = original.position || {};
|
|
1751
|
+
const fp = form.position || {};
|
|
1752
|
+
if ((fp.releaseId || null) !== (op.releaseId || null)
|
|
1753
|
+
|| (fp.processStepId || null) !== (op.processStepId || null)
|
|
1754
|
+
|| (fp.epicId || null) !== (op.epicId || null)) {
|
|
1755
|
+
patch.position = {
|
|
1756
|
+
releaseId: fp.releaseId || null,
|
|
1757
|
+
processStepId: fp.processStepId || null,
|
|
1758
|
+
epicId: fp.epicId || null,
|
|
1759
|
+
sortOrder: (op.sortOrder || 0)
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
// SM-152: only include acceptanceCriteria if it actually changed vs the
|
|
1763
|
+
// original. Otherwise a save that touched some OTHER field would ship our
|
|
1764
|
+
// (possibly stale) local AC list and clobber an external AC edit. Compare
|
|
1765
|
+
// by content+order (ignore item ids — new local rows get random ids).
|
|
1766
|
+
const acSig = (lst) => JSON.stringify((lst || []).map(a => ({ text: a.text || "", completed: !!a.completed })));
|
|
1767
|
+
if (acSig(form.acceptanceCriteria) !== acSig(original.acceptanceCriteria)) {
|
|
1768
|
+
patch.acceptanceCriteria = form.acceptanceCriteria;
|
|
1769
|
+
}
|
|
1770
|
+
// SM-120: labels only when actually changed. (Was unconditional, which made
|
|
1771
|
+
// the in-place auto-commit fire on every interaction. The modal Save path is
|
|
1772
|
+
// unaffected — it gates on hasNonStatusChanges, which still sees a real
|
|
1773
|
+
// labels change.)
|
|
1774
|
+
if (JSON.stringify(form.labels || []) !== JSON.stringify(original.labels || [])) {
|
|
1775
|
+
patch.labels = form.labels;
|
|
1776
|
+
}
|
|
1777
|
+
// E19-followup: DoR/DoD-Items als per-Ticket-Patch. Wenn collectChecklistItems
|
|
1778
|
+
// null zurückgibt (Section gar nicht gerendert, z.B. gate off), keinen Patch
|
|
1779
|
+
// setzen — der Server bewahrt die existierenden Items.
|
|
1780
|
+
if (form.definitionOfReady && Array.isArray(form.definitionOfReady.items)) {
|
|
1781
|
+
if (!checklistsEqual(form.definitionOfReady.items,
|
|
1782
|
+
(original.definitionOfReady && original.definitionOfReady.items) || [])) {
|
|
1783
|
+
patch.definitionOfReady = form.definitionOfReady;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
if (form.definitionOfDone && Array.isArray(form.definitionOfDone.items)) {
|
|
1787
|
+
if (!checklistsEqual(form.definitionOfDone.items,
|
|
1788
|
+
(original.definitionOfDone && original.definitionOfDone.items) || [])) {
|
|
1789
|
+
patch.definitionOfDone = form.definitionOfDone;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
// SM-54: test-definition fields. `null` means the section wasn't rendered
|
|
1793
|
+
// → leave existing values alone. An array means "this is the new full list" —
|
|
1794
|
+
// include only when structurally different from the original.
|
|
1795
|
+
if (Array.isArray(form.prerequisites)) {
|
|
1796
|
+
if (!prereqsEqual(form.prerequisites, original.prerequisites || [])) {
|
|
1797
|
+
patch.prerequisites = form.prerequisites;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
if (Array.isArray(form.steps)) {
|
|
1801
|
+
if (!testStepsEqual(form.steps, original.steps || [])) {
|
|
1802
|
+
patch.steps = form.steps;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// SM-57: test-execution fields.
|
|
1806
|
+
if (Array.isArray(form.executionSteps)) {
|
|
1807
|
+
if (!execStepsEqual(form.executionSteps, original.executionSteps || [])) {
|
|
1808
|
+
patch.executionSteps = form.executionSteps;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// outcomeOverride: undefined = section not rendered (skip). null = clear
|
|
1812
|
+
// override (write only if currently non-null). string = set explicit value.
|
|
1813
|
+
if (form.outcomeOverride !== undefined) {
|
|
1814
|
+
const currentOverride = (typeof original.outcomeOverride === "string") ? original.outcomeOverride : null;
|
|
1815
|
+
const target = (form.outcomeOverride === null) ? null : form.outcomeOverride;
|
|
1816
|
+
if (currentOverride !== target) {
|
|
1817
|
+
// core.updateTicket maps null/`"auto"` → null clear; explicit value → set.
|
|
1818
|
+
patch.outcomeOverride = (target === null) ? "auto" : target;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return patch;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function execStepsEqual(a, b) {
|
|
1825
|
+
if (a.length !== b.length) return false;
|
|
1826
|
+
for (let i = 0; i < a.length; i++) {
|
|
1827
|
+
if (a[i].id !== b[i].id) return false;
|
|
1828
|
+
if ((a[i].actualResult || "") !== (b[i].actualResult || "")) return false;
|
|
1829
|
+
if ((a[i].status || "pending") !== (b[i].status || "pending")) return false;
|
|
1830
|
+
if ((a[i].note || "") !== (b[i].note || "")) return false;
|
|
1831
|
+
}
|
|
1832
|
+
return true;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function prereqsEqual(a, b) {
|
|
1836
|
+
if (a.length !== b.length) return false;
|
|
1837
|
+
for (let i = 0; i < a.length; i++) {
|
|
1838
|
+
if (a[i].id !== b[i].id) return false;
|
|
1839
|
+
if ((a[i].label || "") !== (b[i].label || "")) return false;
|
|
1840
|
+
if ((a[i].required !== false) !== (b[i].required !== false)) return false;
|
|
1841
|
+
if (!!a[i].checked !== !!b[i].checked) return false;
|
|
1842
|
+
}
|
|
1843
|
+
return true;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function testStepsEqual(a, b) {
|
|
1847
|
+
if (a.length !== b.length) return false;
|
|
1848
|
+
for (let i = 0; i < a.length; i++) {
|
|
1849
|
+
if (a[i].id !== b[i].id) return false;
|
|
1850
|
+
if ((a[i].step || "") !== (b[i].step || "")) return false;
|
|
1851
|
+
if ((a[i].data || "") !== (b[i].data || "")) return false;
|
|
1852
|
+
if ((a[i].expectedResult || "") !== (b[i].expectedResult || "")) return false;
|
|
1853
|
+
}
|
|
1854
|
+
return true;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function checklistsEqual(a, b) {
|
|
1858
|
+
if (a.length !== b.length) return false;
|
|
1859
|
+
for (let i = 0; i < a.length; i++) {
|
|
1860
|
+
if (a[i].id !== b[i].id) return false;
|
|
1861
|
+
if ((a[i].label || "") !== (b[i].label || "")) return false;
|
|
1862
|
+
if ((a[i].required !== false) !== (b[i].required !== false)) return false;
|
|
1863
|
+
if (!!a[i].checked !== !!b[i].checked) return false;
|
|
1864
|
+
}
|
|
1865
|
+
return true;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Build a draft ticket for Create-Mode given a type + position context.
|
|
1870
|
+
* Returns a plain ticket-shaped object (no id, no ticketKey — server
|
|
1871
|
+
* assigns those on POST).
|
|
1872
|
+
*/
|
|
1873
|
+
function buildDraftTicket(type, draftCtx) {
|
|
1874
|
+
const dc = draftCtx || {};
|
|
1875
|
+
return {
|
|
1876
|
+
id: null,
|
|
1877
|
+
ticketKey: "",
|
|
1878
|
+
type: type,
|
|
1879
|
+
title: "",
|
|
1880
|
+
description: "",
|
|
1881
|
+
status: dc.status || "backlog",
|
|
1882
|
+
position: {
|
|
1883
|
+
releaseId: dc.releaseId || null,
|
|
1884
|
+
processStepId: dc.processStepId || null,
|
|
1885
|
+
epicId: dc.epicId || null,
|
|
1886
|
+
sortOrder: 0
|
|
1887
|
+
},
|
|
1888
|
+
acceptanceCriteria: [],
|
|
1889
|
+
definitionOfReady: { items: [] },
|
|
1890
|
+
definitionOfDone: { items: [] },
|
|
1891
|
+
labels: [],
|
|
1892
|
+
links: [],
|
|
1893
|
+
comments: []
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// ---- SM-120 (B5): in-place persistence -------------------------------
|
|
1898
|
+
//
|
|
1899
|
+
// Wire a host so every field change commits a Store-op directly — no Save
|
|
1900
|
+
// button. Discrete controls (select/checkbox) commit immediately; text
|
|
1901
|
+
// fields commit on focus-out; list-sections (AC/DoR/DoD/prereqs/steps) commit
|
|
1902
|
+
// via the returned `markDirty` (debounced, so per-keystroke typing coalesces
|
|
1903
|
+
// into one undo entry). Status routes through `changeStatusGated` so the
|
|
1904
|
+
// DoR/DoD gate fires; a 422 reverts the picker and reports via onStatusError,
|
|
1905
|
+
// leaving the page open. The description is intentionally excluded — it owns
|
|
1906
|
+
// its inline ✓/✗ (SM-157).
|
|
1907
|
+
//
|
|
1908
|
+
// opts: { store, ticketId, actor?, onStatusError?(msgOrNull), flashStatus?,
|
|
1909
|
+
// debounceMs?, win? }. Returns { markDirty, detach }.
|
|
1910
|
+
function installInPlaceCommit(host, opts) {
|
|
1911
|
+
opts = opts || {};
|
|
1912
|
+
const store = opts.store;
|
|
1913
|
+
const ticketId = opts.ticketId;
|
|
1914
|
+
const actor = opts.actor || LOCAL_ACTOR;
|
|
1915
|
+
const win = opts.win || (typeof window !== "undefined" ? window : null);
|
|
1916
|
+
const setT = (win && win.setTimeout) || (typeof setTimeout === "function" ? setTimeout : null);
|
|
1917
|
+
const clrT = (win && win.clearTimeout) || (typeof clearTimeout === "function" ? clearTimeout : null);
|
|
1918
|
+
const debounceMs = typeof opts.debounceMs === "number" ? opts.debounceMs : 450;
|
|
1919
|
+
const onStatusError = typeof opts.onStatusError === "function" ? opts.onStatusError : function () {};
|
|
1920
|
+
let timer = null;
|
|
1921
|
+
|
|
1922
|
+
function curTicket() {
|
|
1923
|
+
const snap = store.get();
|
|
1924
|
+
return (snap.tickets || []).find(t => t.id === ticketId) || null;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function commitFields() {
|
|
1928
|
+
const cur = curTicket();
|
|
1929
|
+
if (!cur) return;
|
|
1930
|
+
const form = collectFormState(host);
|
|
1931
|
+
// The description self-commits via its inline ✓/✗; never clobber it here.
|
|
1932
|
+
form.description = cur.description;
|
|
1933
|
+
// Status is handled by the dedicated gated path — strip from the diff.
|
|
1934
|
+
form.status = cur.status;
|
|
1935
|
+
// SM-52: resolve the container epic so the position diff is
|
|
1936
|
+
// apples-to-apples (position.epicId is always null on the stored ticket).
|
|
1937
|
+
const containerId = (typeof core.containerEpicIdOf === "function")
|
|
1938
|
+
? core.containerEpicIdOf(store.get(), ticketId) : null;
|
|
1939
|
+
const curCmp = Object.assign({}, cur, {
|
|
1940
|
+
position: Object.assign({}, cur.position || {}, { epicId: containerId })
|
|
1941
|
+
});
|
|
1942
|
+
const patch = buildUpdatePatch(form, curCmp);
|
|
1943
|
+
if (Object.keys(patch).length === 0) return;
|
|
1944
|
+
try { store.updateTicket(ticketId, patch, actor); }
|
|
1945
|
+
catch (e) { if (opts.flashStatus) opts.flashStatus("save failed: " + (e && e.message || e), { kind: "error" }); }
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function schedule() {
|
|
1949
|
+
if (!setT) { commitFields(); return; }
|
|
1950
|
+
if (timer && clrT) clrT(timer);
|
|
1951
|
+
timer = setT(commitFields, debounceMs);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function commitStatus(sel) {
|
|
1955
|
+
const cur = curTicket();
|
|
1956
|
+
if (!cur) return;
|
|
1957
|
+
const next = sel.value;
|
|
1958
|
+
if (!next || next === cur.status) return;
|
|
1959
|
+
try {
|
|
1960
|
+
store.changeStatusGated(ticketId, next, actor);
|
|
1961
|
+
onStatusError(null);
|
|
1962
|
+
} catch (e) {
|
|
1963
|
+
sel.value = cur.status; // revert the picker; stay on the page
|
|
1964
|
+
const missing = (e && e.missing) ? e.missing.map(m => m.label || m.id).join(", ") : "";
|
|
1965
|
+
const kind = (e && e.kind) ? e.kind : "";
|
|
1966
|
+
onStatusError(kind ? (kind + " gate blocks this transition. Missing: " + missing)
|
|
1967
|
+
: ((e && e.message) || "transition blocked"));
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function keyOf(node) {
|
|
1972
|
+
return node && node.getAttribute ? node.getAttribute(CONSTANTS.LIVE_SYNC_ATTR) : null;
|
|
1973
|
+
}
|
|
1974
|
+
function onChange(ev) {
|
|
1975
|
+
const t = ev.target;
|
|
1976
|
+
if (keyOf(t) === "status") { commitStatus(t); return; }
|
|
1977
|
+
commitFields(); // discrete control → immediate
|
|
1978
|
+
}
|
|
1979
|
+
function onFocusOut(ev) {
|
|
1980
|
+
const k = keyOf(ev.target);
|
|
1981
|
+
if (k === "description" || k === "status") return; // own handlers
|
|
1982
|
+
commitFields(); // text field blur → immediate
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
host.addEventListener("change", onChange);
|
|
1986
|
+
host.addEventListener("focusout", onFocusOut);
|
|
1987
|
+
|
|
1988
|
+
return {
|
|
1989
|
+
markDirty: schedule,
|
|
1990
|
+
detach: function () {
|
|
1991
|
+
host.removeEventListener("change", onChange);
|
|
1992
|
+
host.removeEventListener("focusout", onFocusOut);
|
|
1993
|
+
if (timer && clrT) clrT(timer);
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// SM-185: Attachments section — a dropzone + list, bound to a ticket. Files
|
|
1999
|
+
// are reference docs (PRDs, designs); they ALWAYS belong to a ticket (no
|
|
2000
|
+
// free-floating attachments). Uses ctx helpers (HTTP-only): listAttachments /
|
|
2001
|
+
// uploadAttachment / deleteAttachment / attachmentHref / flashStatus.
|
|
2002
|
+
function formatBytes(n) {
|
|
2003
|
+
if (typeof n !== "number") return "";
|
|
2004
|
+
if (n < 1024) return n + " B";
|
|
2005
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
2006
|
+
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
|
2007
|
+
}
|
|
2008
|
+
function buildAttachmentsSection(ticketId, ctx) {
|
|
2009
|
+
const section = el("section", { class: "tm-attachments-section" });
|
|
2010
|
+
section.appendChild(el("h4", { class: "tm-section-title", text: "Attachments" }));
|
|
2011
|
+
const drop = el("div", { class: "tm-attach-dropzone", text: "Drop a file here or click to attach" });
|
|
2012
|
+
const fileInput = el("input", { type: "file", class: "tm-attach-input", style: { display: "none" } });
|
|
2013
|
+
const listEl = el("ul", { class: "tm-attach-list" });
|
|
2014
|
+
section.appendChild(drop);
|
|
2015
|
+
section.appendChild(fileInput);
|
|
2016
|
+
section.appendChild(listEl);
|
|
2017
|
+
|
|
2018
|
+
function renderList(items) {
|
|
2019
|
+
listEl.innerHTML = "";
|
|
2020
|
+
if (!items || !items.length) {
|
|
2021
|
+
listEl.appendChild(el("li", { class: "tm-attach-empty", text: "No attachments yet." }));
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
for (const a of items) {
|
|
2025
|
+
const li = el("li", { class: "tm-attach-item", dataset: { attachmentId: a.id } });
|
|
2026
|
+
const link = el("a", { class: "tm-attach-name", href: ctx.attachmentHref(a.id), text: a.filename, title: "Download" });
|
|
2027
|
+
link.setAttribute("download", a.filename);
|
|
2028
|
+
li.appendChild(link);
|
|
2029
|
+
li.appendChild(el("span", { class: "tm-attach-size", text: formatBytes(a.size) }));
|
|
2030
|
+
const del = el("button", { class: "tm-attach-del", type: "button", title: "Remove attachment", text: "✕" });
|
|
2031
|
+
del.addEventListener("click", async (ev) => {
|
|
2032
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
2033
|
+
try {
|
|
2034
|
+
await ctx.deleteAttachment(a.id);
|
|
2035
|
+
await refresh();
|
|
2036
|
+
if (ctx.flashStatus) ctx.flashStatus("attachment removed", { kind: "ok" });
|
|
2037
|
+
} catch (e) {
|
|
2038
|
+
if (ctx.flashStatus) ctx.flashStatus(e.message || "delete failed", { kind: "error" });
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
li.appendChild(del);
|
|
2042
|
+
listEl.appendChild(li);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
async function refresh() {
|
|
2046
|
+
try { renderList(await ctx.listAttachments(ticketId)); }
|
|
2047
|
+
catch (_) { renderList([]); }
|
|
2048
|
+
}
|
|
2049
|
+
async function upload(file) {
|
|
2050
|
+
if (!file) return;
|
|
2051
|
+
if (ctx.flashStatus) ctx.flashStatus("uploading " + file.name + " …", { kind: "info" });
|
|
2052
|
+
try {
|
|
2053
|
+
await ctx.uploadAttachment(ticketId, file);
|
|
2054
|
+
await refresh();
|
|
2055
|
+
if (ctx.flashStatus) ctx.flashStatus("attached " + file.name, { kind: "ok" });
|
|
2056
|
+
} catch (e) {
|
|
2057
|
+
if (ctx.flashStatus) ctx.flashStatus(e.message || "upload failed", { kind: "error" });
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
drop.addEventListener("click", () => fileInput.click());
|
|
2061
|
+
fileInput.addEventListener("change", () => {
|
|
2062
|
+
if (fileInput.files && fileInput.files[0]) upload(fileInput.files[0]);
|
|
2063
|
+
fileInput.value = "";
|
|
2064
|
+
});
|
|
2065
|
+
drop.addEventListener("dragover", (ev) => { ev.preventDefault(); drop.classList.add("tm-attach-dropzone-over"); });
|
|
2066
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("tm-attach-dropzone-over"));
|
|
2067
|
+
drop.addEventListener("drop", (ev) => {
|
|
2068
|
+
ev.preventDefault();
|
|
2069
|
+
drop.classList.remove("tm-attach-dropzone-over");
|
|
2070
|
+
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
|
|
2071
|
+
if (f) upload(f);
|
|
2072
|
+
});
|
|
2073
|
+
refresh();
|
|
2074
|
+
return section;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
return {
|
|
2078
|
+
CONSTANTS, LOCAL_ACTOR, DRAG_TYPE_AC, DRAG_TYPE_CHECK,
|
|
2079
|
+
buildAttachmentsSection,
|
|
2080
|
+
DRAG_TYPE_PREREQ, DRAG_TYPE_TEST_STEP, MAX_TEST_STEP_TA_HEIGHT_PX, el,
|
|
2081
|
+
buildRow, buildErrorBanner, cssEscape, autoGrowTextarea,
|
|
2082
|
+
buildTitleField, buildTypeField, buildStatusField, buildPositionFields,
|
|
2083
|
+
buildDescriptionField, buildChecklistSection, buildCheckRow, buildAcceptanceCriteriaSection,
|
|
2084
|
+
buildAcRow, buildPrerequisitesSection, buildPrereqRow, buildStepsSection,
|
|
2085
|
+
buildTestStepRow, buildLabelsField, resolveTestDefinition, buildTestExecutionHeader,
|
|
2086
|
+
buildExecutionMetadata, buildExecutionStepsSection, buildExecutionStepRow, buildOutcomeSection,
|
|
2087
|
+
lookupLinkType, findBackwardLinks, buildLinkPill, buildTargetRef,
|
|
2088
|
+
buildLinksSection, renderLinksSectionInto, buildInlineAddLinkForm, syncFieldFromTicket,
|
|
2089
|
+
rebuildAcInPlace, rebuildPrereqsInPlace, rebuildTestStepsInPlace, diffSyncFieldKeys,
|
|
2090
|
+
pulseChangedFields, rebuildChecklistInPlace, buildDraftTicket, collectFormState,
|
|
2091
|
+
collectExecutionSteps, collectOutcomeOverride, collectPrerequisites, collectTestSteps,
|
|
2092
|
+
collectChecklistItems, buildUpdatePatch, execStepsEqual, prereqsEqual,
|
|
2093
|
+
testStepsEqual, checklistsEqual, installInPlaceCommit
|
|
2094
|
+
};
|
|
2095
|
+
}));
|