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,1864 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings view (E21.D — skeleton).
|
|
3
|
+
*
|
|
4
|
+
* Third view alongside Story Map and Kanban. Editors for Workflow (E21.E
|
|
5
|
+
* status editor, E21.F transition editor) and Board (E21.G kanban-column
|
|
6
|
+
* mapping) plug into this shell. This skeleton renders a section
|
|
7
|
+
* scaffold + empty bodies; following etappes fill the bodies.
|
|
8
|
+
*
|
|
9
|
+
* The view is NOT a modal — it owns its host area, gets mounted/unmounted
|
|
10
|
+
* by main.js's view-toggle (E21.D wiring), and stays in sync with the
|
|
11
|
+
* store via subscribe + rerender (same pattern as Kanban).
|
|
12
|
+
*
|
|
13
|
+
* UMD-wrapped so the same source works in the browser AND can be
|
|
14
|
+
* require()-d in Node tests.
|
|
15
|
+
*/
|
|
16
|
+
(function (root, factory) {
|
|
17
|
+
if (typeof module === "object" && module.exports) {
|
|
18
|
+
module.exports = factory(require("./core.js"), require("./dnd.js"));
|
|
19
|
+
} else {
|
|
20
|
+
(root.STORYMAP = root.STORYMAP || {}).viewSettings =
|
|
21
|
+
factory(root.STORYMAP.core, root.STORYMAP.dnd);
|
|
22
|
+
}
|
|
23
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd) {
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
|
|
27
|
+
|
|
28
|
+
// SM-2 — DoR/DoD editor constants (ported from dialog-definitions-settings).
|
|
29
|
+
const DDS_DRAG_TYPE = "vs-defs-item";
|
|
30
|
+
const DDS_ITEM_ID_PREFIX = "vs-def-";
|
|
31
|
+
function freshDefItemId() {
|
|
32
|
+
return DDS_ITEM_ID_PREFIX + Math.random().toString(36).slice(2, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// E21.E: Auto-id generator for new statuses. Kebab-case "new-status" with
|
|
36
|
+
// a short random suffix so identical names don't clash.
|
|
37
|
+
function freshStatusId(existing) {
|
|
38
|
+
let i = 1;
|
|
39
|
+
let candidate = "new-status";
|
|
40
|
+
const ids = new Set((existing || []).map(s => s.id));
|
|
41
|
+
while (ids.has(candidate)) {
|
|
42
|
+
i += 1;
|
|
43
|
+
candidate = "new-status-" + i;
|
|
44
|
+
}
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- DOM helpers ---------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function el(tag, attrs, ...children) {
|
|
51
|
+
const node = document.createElement(tag);
|
|
52
|
+
if (attrs) {
|
|
53
|
+
for (const k of Object.keys(attrs)) {
|
|
54
|
+
if (k === "class") node.className = attrs[k];
|
|
55
|
+
else if (k === "dataset") {
|
|
56
|
+
for (const dk of Object.keys(attrs.dataset)) node.dataset[dk] = attrs.dataset[dk];
|
|
57
|
+
}
|
|
58
|
+
else if (k === "html") node.innerHTML = attrs[k];
|
|
59
|
+
else if (k === "text") node.textContent = attrs[k];
|
|
60
|
+
else if (k === "style" && typeof attrs.style === "object") Object.assign(node.style, attrs.style);
|
|
61
|
+
else node.setAttribute(k, attrs[k]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const c of children) {
|
|
65
|
+
if (c == null) continue;
|
|
66
|
+
if (typeof c === "string") node.appendChild(document.createTextNode(c));
|
|
67
|
+
else node.appendChild(c);
|
|
68
|
+
}
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- Sections ------------------------------------------------------
|
|
73
|
+
//
|
|
74
|
+
// Each section is an isolated render function so future etappes can
|
|
75
|
+
// swap one out without touching the shell. The contract:
|
|
76
|
+
// renderXxx(project, ctx) → DOM element
|
|
77
|
+
// ctx exposes `store`, `flashStatus`, etc. — the same shape main.js
|
|
78
|
+
// builds for ticket-modal and dialogs.
|
|
79
|
+
|
|
80
|
+
// ---- E21.E status editor -----------------------------------------
|
|
81
|
+
//
|
|
82
|
+
// Pure reorder helper — exported so tests don't have to drive the dnd
|
|
83
|
+
// module to validate the order math.
|
|
84
|
+
function reorderStatuses(allStatuses, sourceId, targetId, before) {
|
|
85
|
+
if (sourceId === targetId) return allStatuses.slice();
|
|
86
|
+
const fromIdx = allStatuses.findIndex(s => s.id === sourceId);
|
|
87
|
+
if (fromIdx < 0) return allStatuses.slice();
|
|
88
|
+
const next = allStatuses.slice();
|
|
89
|
+
const [moved] = next.splice(fromIdx, 1);
|
|
90
|
+
let insertAt = next.findIndex(s => s.id === targetId);
|
|
91
|
+
if (insertAt < 0) insertAt = next.length;
|
|
92
|
+
if (!before) insertAt += 1;
|
|
93
|
+
next.splice(insertAt, 0, moved);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//
|
|
98
|
+
// Live-apply pattern: discrete actions (Add, Delete, Reorder, category-
|
|
99
|
+
// change) dispatch updateProject immediately; the name input commits on
|
|
100
|
+
// blur (avoids a commit per keystroke). External commits re-render the
|
|
101
|
+
// section, but the actively edited input is skipped — focus-guard so the
|
|
102
|
+
// user doesn't lose their caret mid-edit (mirrors the ticket-modal
|
|
103
|
+
// pattern).
|
|
104
|
+
|
|
105
|
+
function commitStatuses(ctx, nextStatuses) {
|
|
106
|
+
if (!ctx || !ctx.store) return;
|
|
107
|
+
try {
|
|
108
|
+
const cur = ctx.store.get().project.workflow || {};
|
|
109
|
+
const nextWf = Object.assign({}, cur, { statuses: nextStatuses });
|
|
110
|
+
ctx.store.updateProject({ workflow: nextWf }, LOCAL_ACTOR);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderStatusRow(status, ctx, allStatuses, idx) {
|
|
117
|
+
const row = el("div", {
|
|
118
|
+
class: "vs-status-row",
|
|
119
|
+
dataset: { statusId: status.id }
|
|
120
|
+
});
|
|
121
|
+
const grip = el("span", { class: "vs-grip", text: "⋮⋮", title: "Drag to reorder" });
|
|
122
|
+
const nameInput = el("input", {
|
|
123
|
+
type: "text",
|
|
124
|
+
class: "vs-status-name",
|
|
125
|
+
value: status.name
|
|
126
|
+
});
|
|
127
|
+
nameInput.addEventListener("blur", () => {
|
|
128
|
+
const v = String(nameInput.value || "").trim();
|
|
129
|
+
if (!v || v === status.name) {
|
|
130
|
+
nameInput.value = status.name; // revert on empty / unchanged
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const next = allStatuses.slice();
|
|
134
|
+
next[idx] = Object.assign({}, status, { name: v });
|
|
135
|
+
commitStatuses(ctx, next);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const categorySelect = el("select", { class: "vs-status-category" });
|
|
139
|
+
for (const cat of (core.STATUS_CATEGORIES || ["todo", "doing", "blocked", "done"])) {
|
|
140
|
+
const opt = el("option", { value: cat, text: cat });
|
|
141
|
+
if (cat === status.category) opt.selected = true;
|
|
142
|
+
categorySelect.appendChild(opt);
|
|
143
|
+
}
|
|
144
|
+
categorySelect.addEventListener("change", () => {
|
|
145
|
+
const next = allStatuses.slice();
|
|
146
|
+
next[idx] = Object.assign({}, status, { category: categorySelect.value });
|
|
147
|
+
commitStatuses(ctx, next);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const delBtn = el("button", {
|
|
151
|
+
class: "vs-status-delete",
|
|
152
|
+
title: "Delete status",
|
|
153
|
+
text: "×"
|
|
154
|
+
});
|
|
155
|
+
delBtn.addEventListener("click", (ev) => {
|
|
156
|
+
ev.stopPropagation();
|
|
157
|
+
// Soft warning if the status is currently in use — but allow the
|
|
158
|
+
// delete; normalizeWorkflow will leave the ticket's status string in
|
|
159
|
+
// place (it just no longer maps to a workflow entry). The user can
|
|
160
|
+
// re-add the status if needed.
|
|
161
|
+
const snap = ctx.store ? ctx.store.get() : null;
|
|
162
|
+
const inUse = snap && (snap.tickets || []).some(t => !t.isDeleted && t.status === status.id);
|
|
163
|
+
if (inUse && typeof confirm === "function") {
|
|
164
|
+
if (!confirm(`Status "${status.name}" is still used by tickets. Delete anyway?`)) return;
|
|
165
|
+
}
|
|
166
|
+
const next = allStatuses.filter((_, i) => i !== idx);
|
|
167
|
+
commitStatuses(ctx, next);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
row.appendChild(grip);
|
|
171
|
+
row.appendChild(nameInput);
|
|
172
|
+
row.appendChild(categorySelect);
|
|
173
|
+
row.appendChild(delBtn);
|
|
174
|
+
|
|
175
|
+
// E21.E reorder: row is both drag-source and drop-target. onDrop
|
|
176
|
+
// rebuilds the order array and commits — focus-guard in renderInto
|
|
177
|
+
// ensures the rerender doesn't fight an active edit on another row.
|
|
178
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
179
|
+
dnd.enableDraggable(row, { dragType: "vs-status", dragId: status.id });
|
|
180
|
+
dnd.enableDropTarget(row, {
|
|
181
|
+
accepts: ["vs-status"],
|
|
182
|
+
onEnter: (e) => e.classList.add("vs-status-drop-target"),
|
|
183
|
+
onLeave: (e) => e.classList.remove("vs-status-drop-target"),
|
|
184
|
+
onDrop: ({ id, event }) => {
|
|
185
|
+
row.classList.remove("vs-status-drop-target");
|
|
186
|
+
if (id === status.id) return;
|
|
187
|
+
const rect = row.getBoundingClientRect();
|
|
188
|
+
const before = (event && typeof event.clientY === "number")
|
|
189
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
190
|
+
const next = reorderStatuses(allStatuses, id, status.id, before);
|
|
191
|
+
commitStatuses(ctx, next);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return row;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderStatusEditor(workflow, ctx) {
|
|
199
|
+
const statuses = (workflow && workflow.statuses) || [];
|
|
200
|
+
const wrap = el("div", { class: "vs-status-editor" });
|
|
201
|
+
const rowsHost = el("div", { class: "vs-status-rows" });
|
|
202
|
+
statuses.forEach((s, idx) => {
|
|
203
|
+
rowsHost.appendChild(renderStatusRow(s, ctx, statuses, idx));
|
|
204
|
+
});
|
|
205
|
+
wrap.appendChild(rowsHost);
|
|
206
|
+
const add = el("button", {
|
|
207
|
+
class: "vs-status-add",
|
|
208
|
+
title: "Add a new status",
|
|
209
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Status</span>'
|
|
210
|
+
});
|
|
211
|
+
add.addEventListener("click", (ev) => {
|
|
212
|
+
ev.stopPropagation();
|
|
213
|
+
const id = freshStatusId(statuses);
|
|
214
|
+
const next = statuses.concat([{ id: id, name: "New Status", category: "doing" }]);
|
|
215
|
+
commitStatuses(ctx, next);
|
|
216
|
+
});
|
|
217
|
+
wrap.appendChild(add);
|
|
218
|
+
return wrap;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- E23.C transition reorder + auto-sort -----------------------
|
|
222
|
+
//
|
|
223
|
+
// Two flavors: explicit DnD-driven reorder (pure helper, mirrors the
|
|
224
|
+
// status one) AND auto-sort so transitions follow the status order after
|
|
225
|
+
// any status reorder. User wants both: "Transitions sollten in der
|
|
226
|
+
// gleichen Reihenfolge wie die Statuses erscheinen, wenn der Nutzer die
|
|
227
|
+
// Statuses umsortiert hat."
|
|
228
|
+
|
|
229
|
+
function reorderTransitions(allTransitions, sourceId, targetId, before) {
|
|
230
|
+
if (sourceId === targetId) return allTransitions.slice();
|
|
231
|
+
const fromIdx = allTransitions.findIndex(t => t.id === sourceId);
|
|
232
|
+
if (fromIdx < 0) return allTransitions.slice();
|
|
233
|
+
const next = allTransitions.slice();
|
|
234
|
+
const [moved] = next.splice(fromIdx, 1);
|
|
235
|
+
let insertAt = next.findIndex(t => t.id === targetId);
|
|
236
|
+
if (insertAt < 0) insertAt = next.length;
|
|
237
|
+
if (!before) insertAt += 1;
|
|
238
|
+
next.splice(insertAt, 0, moved);
|
|
239
|
+
return next;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function sortTransitionsByStatusOrder(transitions, statuses) {
|
|
243
|
+
if (!Array.isArray(transitions) || transitions.length === 0) return [];
|
|
244
|
+
const order = new Map();
|
|
245
|
+
(statuses || []).forEach((s, i) => order.set(s.id, i));
|
|
246
|
+
const indexOf = (tr) => order.has(tr.toStatus) ? order.get(tr.toStatus) : Number.MAX_SAFE_INTEGER;
|
|
247
|
+
// Decorate with original index for stable sort, then sort, then strip.
|
|
248
|
+
return transitions
|
|
249
|
+
.map((tr, i) => ({ tr, orderKey: indexOf(tr), origIdx: i }))
|
|
250
|
+
.sort((a, b) => (a.orderKey - b.orderKey) || (a.origIdx - b.origIdx))
|
|
251
|
+
.map(x => x.tr);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- E21.F transition editor ------------------------------------
|
|
255
|
+
//
|
|
256
|
+
// Pattern mirrors the status editor: live-apply on discrete actions, on-
|
|
257
|
+
// blur for the name input. The source-multiselect lives in a sub-host
|
|
258
|
+
// that toggles visibility based on `allowFromAny`.
|
|
259
|
+
|
|
260
|
+
function commitTransitions(ctx, nextTransitions) {
|
|
261
|
+
if (!ctx || !ctx.store) return;
|
|
262
|
+
try {
|
|
263
|
+
const cur = ctx.store.get().project.workflow || {};
|
|
264
|
+
const nextWf = Object.assign({}, cur, { transitions: nextTransitions });
|
|
265
|
+
ctx.store.updateProject({ workflow: nextWf }, LOCAL_ACTOR);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function freshTransitionId(existing) {
|
|
272
|
+
let i = 1;
|
|
273
|
+
let candidate = "tr-" + i;
|
|
274
|
+
const ids = new Set((existing || []).map(t => t.id));
|
|
275
|
+
while (ids.has(candidate)) { i += 1; candidate = "tr-" + i; }
|
|
276
|
+
return candidate;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Build a patched transition by replacing one field. Pure for testability.
|
|
280
|
+
function patchTransitionAt(allTransitions, idx, field, value) {
|
|
281
|
+
const next = allTransitions.slice();
|
|
282
|
+
next[idx] = Object.assign({}, next[idx], { [field]: value });
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderTransitionRow(tr, ctx, allStatuses, allTransitions, idx) {
|
|
287
|
+
const row = el("div", {
|
|
288
|
+
class: "vs-transition-row",
|
|
289
|
+
dataset: { transitionId: tr.id }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Header line: grip + name + delete (delete on the right).
|
|
293
|
+
const header = el("div", { class: "vs-transition-header" });
|
|
294
|
+
const grip = el("span", { class: "vs-grip", text: "⋮⋮", title: "Drag to reorder" });
|
|
295
|
+
const nameInput = el("input", {
|
|
296
|
+
type: "text",
|
|
297
|
+
class: "vs-transition-name",
|
|
298
|
+
value: tr.name,
|
|
299
|
+
placeholder: "Transition name"
|
|
300
|
+
});
|
|
301
|
+
nameInput.addEventListener("blur", () => {
|
|
302
|
+
const v = String(nameInput.value || "").trim();
|
|
303
|
+
if (!v || v === tr.name) { nameInput.value = tr.name; return; }
|
|
304
|
+
commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "name", v));
|
|
305
|
+
});
|
|
306
|
+
const delBtn = el("button", {
|
|
307
|
+
class: "vs-transition-delete",
|
|
308
|
+
title: "Delete transition",
|
|
309
|
+
text: "×"
|
|
310
|
+
});
|
|
311
|
+
delBtn.addEventListener("click", (ev) => {
|
|
312
|
+
ev.stopPropagation();
|
|
313
|
+
const next = allTransitions.filter((_, i) => i !== idx);
|
|
314
|
+
commitTransitions(ctx, next);
|
|
315
|
+
});
|
|
316
|
+
header.appendChild(grip);
|
|
317
|
+
header.appendChild(nameInput);
|
|
318
|
+
header.appendChild(delBtn);
|
|
319
|
+
|
|
320
|
+
// Body line: target + gate + allow-from-any toggle.
|
|
321
|
+
const body = el("div", { class: "vs-transition-body" });
|
|
322
|
+
const arrow = el("span", { class: "vs-transition-arrow", text: "→" });
|
|
323
|
+
const targetSel = el("select", { class: "vs-transition-target", title: "Target status" });
|
|
324
|
+
for (const s of allStatuses) {
|
|
325
|
+
const opt = el("option", { value: s.id, text: s.name || s.id });
|
|
326
|
+
if (s.id === tr.toStatus) opt.selected = true;
|
|
327
|
+
targetSel.appendChild(opt);
|
|
328
|
+
}
|
|
329
|
+
targetSel.addEventListener("change", () => {
|
|
330
|
+
commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "toStatus", targetSel.value));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const gateLabel = el("span", { class: "vs-transition-gate-label", text: "Gate:" });
|
|
334
|
+
const gateSel = el("select", { class: "vs-transition-gate", title: "Gate to enforce" });
|
|
335
|
+
for (const g of ["", "DoR", "DoD"]) {
|
|
336
|
+
const opt = el("option", { value: g, text: g || "None" });
|
|
337
|
+
if ((tr.requireGate || "") === g) opt.selected = true;
|
|
338
|
+
gateSel.appendChild(opt);
|
|
339
|
+
}
|
|
340
|
+
gateSel.addEventListener("change", () => {
|
|
341
|
+
const v = gateSel.value === "" ? null : gateSel.value;
|
|
342
|
+
commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "requireGate", v));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const anyWrap = el("label", { class: "vs-transition-any-wrap", title: "Allow from any source status" });
|
|
346
|
+
const anyCb = el("input", { type: "checkbox", class: "vs-transition-any" });
|
|
347
|
+
anyCb.checked = !!tr.allowFromAny;
|
|
348
|
+
anyCb.addEventListener("change", () => {
|
|
349
|
+
commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "allowFromAny", anyCb.checked));
|
|
350
|
+
});
|
|
351
|
+
anyWrap.appendChild(anyCb);
|
|
352
|
+
anyWrap.appendChild(document.createTextNode(" From any"));
|
|
353
|
+
|
|
354
|
+
body.appendChild(arrow);
|
|
355
|
+
body.appendChild(targetSel);
|
|
356
|
+
body.appendChild(gateLabel);
|
|
357
|
+
body.appendChild(gateSel);
|
|
358
|
+
body.appendChild(anyWrap);
|
|
359
|
+
|
|
360
|
+
// Sources sub-section: one checkbox per status. Visible only when the
|
|
361
|
+
// transition is NOT allowFromAny.
|
|
362
|
+
const sources = el("div", {
|
|
363
|
+
class: "vs-transition-sources",
|
|
364
|
+
dataset: { hint: "Pick source statuses" }
|
|
365
|
+
});
|
|
366
|
+
sources.hidden = !!tr.allowFromAny;
|
|
367
|
+
const fromSet = new Set(tr.fromStatuses || []);
|
|
368
|
+
for (const s of allStatuses) {
|
|
369
|
+
const lbl = el("label", { class: "vs-transition-source-item" });
|
|
370
|
+
const cbx = el("input", { type: "checkbox", dataset: { sourceId: s.id } });
|
|
371
|
+
cbx.checked = fromSet.has(s.id);
|
|
372
|
+
cbx.addEventListener("change", () => {
|
|
373
|
+
// Collect every checked source checkbox in this row's source host
|
|
374
|
+
// and commit the resulting array — using the DOM as source-of-truth
|
|
375
|
+
// is robust against fast multi-clicks before a rerender.
|
|
376
|
+
const checked = Array.from(sources.querySelectorAll("input[type='checkbox']"))
|
|
377
|
+
.filter(c => c.checked).map(c => c.dataset.sourceId);
|
|
378
|
+
commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "fromStatuses", checked));
|
|
379
|
+
});
|
|
380
|
+
lbl.appendChild(cbx);
|
|
381
|
+
lbl.appendChild(document.createTextNode(" " + (s.name || s.id)));
|
|
382
|
+
sources.appendChild(lbl);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
row.appendChild(header);
|
|
386
|
+
row.appendChild(body);
|
|
387
|
+
row.appendChild(sources);
|
|
388
|
+
|
|
389
|
+
// E23.C — DnD-Reorder analog to the status editor.
|
|
390
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
391
|
+
dnd.enableDraggable(row, { dragType: "vs-transition", dragId: tr.id });
|
|
392
|
+
dnd.enableDropTarget(row, {
|
|
393
|
+
accepts: ["vs-transition"],
|
|
394
|
+
onEnter: (e) => e.classList.add("vs-transition-drop-target"),
|
|
395
|
+
onLeave: (e) => e.classList.remove("vs-transition-drop-target"),
|
|
396
|
+
onDrop: ({ id, event }) => {
|
|
397
|
+
row.classList.remove("vs-transition-drop-target");
|
|
398
|
+
if (id === tr.id) return;
|
|
399
|
+
const rect = row.getBoundingClientRect();
|
|
400
|
+
const before = (event && typeof event.clientY === "number")
|
|
401
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
402
|
+
const next = reorderTransitions(allTransitions, id, tr.id, before);
|
|
403
|
+
commitTransitions(ctx, next);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return row;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderTransitionEditor(workflow, ctx) {
|
|
411
|
+
const statuses = (workflow && workflow.statuses) || [];
|
|
412
|
+
const rawTransitions = Array.isArray(workflow && workflow.transitions) ? workflow.transitions : [];
|
|
413
|
+
// E23.C: display-time sort by status order. Persisted order in the
|
|
414
|
+
// snapshot is left untouched; render simply projects the visual order
|
|
415
|
+
// that matches the user's mental model ("transitions follow statuses").
|
|
416
|
+
const transitions = sortTransitionsByStatusOrder(rawTransitions, statuses);
|
|
417
|
+
const wrap = el("div", { class: "vs-transition-editor" });
|
|
418
|
+
const rowsHost = el("div", { class: "vs-transition-rows" });
|
|
419
|
+
transitions.forEach((tr, idx) => {
|
|
420
|
+
rowsHost.appendChild(renderTransitionRow(tr, ctx, statuses, transitions, idx));
|
|
421
|
+
});
|
|
422
|
+
wrap.appendChild(rowsHost);
|
|
423
|
+
const add = el("button", {
|
|
424
|
+
class: "vs-transition-add",
|
|
425
|
+
title: "Add a new transition",
|
|
426
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Transition</span>'
|
|
427
|
+
});
|
|
428
|
+
add.addEventListener("click", (ev) => {
|
|
429
|
+
ev.stopPropagation();
|
|
430
|
+
const id = freshTransitionId(transitions);
|
|
431
|
+
// Default target: first status. requireGate=null, allowFromAny=true.
|
|
432
|
+
const firstStatusId = statuses[0] ? statuses[0].id : "";
|
|
433
|
+
const newTr = {
|
|
434
|
+
id: id,
|
|
435
|
+
name: "New Transition",
|
|
436
|
+
fromStatuses: [],
|
|
437
|
+
toStatus: firstStatusId,
|
|
438
|
+
requireGate: null,
|
|
439
|
+
allowFromAny: true
|
|
440
|
+
};
|
|
441
|
+
commitTransitions(ctx, transitions.concat([newTr]));
|
|
442
|
+
});
|
|
443
|
+
wrap.appendChild(add);
|
|
444
|
+
return wrap;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderWorkflowSection(project, ctx) {
|
|
448
|
+
const wf = (project && project.workflow) || core.STORYMAPPER_DEFAULT_WORKFLOW;
|
|
449
|
+
return el("section", { class: "vs-section", dataset: { section: "workflow" } },
|
|
450
|
+
el("header", { class: "vs-section-header" },
|
|
451
|
+
el("h2", { text: "Workflow" }),
|
|
452
|
+
el("p", { class: "vs-section-hint",
|
|
453
|
+
text: "Statuses + transitions between them. Drag to reorder, pick a category, click + to add." })
|
|
454
|
+
),
|
|
455
|
+
el("div", { class: "vs-section-body" },
|
|
456
|
+
el("h3", { class: "vs-subhead", text: "Statuses" }),
|
|
457
|
+
renderStatusEditor(wf, ctx),
|
|
458
|
+
el("h3", { class: "vs-subhead", text: "Transitions" }),
|
|
459
|
+
renderTransitionEditor(wf, ctx)
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---- E21.G board editor -----------------------------------------
|
|
465
|
+
//
|
|
466
|
+
// Edits `project.boards.kanban.columns`. UI: per column a card with name
|
|
467
|
+
// input, status-chip list, and delete button. Above the columns a
|
|
468
|
+
// bucket of "unassigned" status chips (statuses not in any column).
|
|
469
|
+
// Status chips are draggable between buckets — each drop reassigns the
|
|
470
|
+
// status to exactly one column.
|
|
471
|
+
|
|
472
|
+
function commitBoardColumns(ctx, nextColumns) {
|
|
473
|
+
if (!ctx || !ctx.store) return;
|
|
474
|
+
try {
|
|
475
|
+
const cur = ctx.store.get().project.boards || {};
|
|
476
|
+
const nextKanban = Object.assign({}, cur.kanban || {}, { columns: nextColumns });
|
|
477
|
+
const nextBoards = Object.assign({}, cur, { kanban: nextKanban });
|
|
478
|
+
ctx.store.updateProject({ boards: nextBoards }, LOCAL_ACTOR);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function freshColumnId(existing) {
|
|
485
|
+
let i = 1;
|
|
486
|
+
let candidate = "col-" + i;
|
|
487
|
+
const ids = new Set((existing || []).map(c => c.id));
|
|
488
|
+
while (ids.has(candidate)) { i += 1; candidate = "col-" + i; }
|
|
489
|
+
return candidate;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Pure: ensure statusId lives only inside targetColumnId. No-op when the
|
|
493
|
+
// status was already in the target (preserves its existing position).
|
|
494
|
+
function assignStatusToColumn(columns, statusId, targetColumnId) {
|
|
495
|
+
return columns.map(c => {
|
|
496
|
+
if (c.id === targetColumnId) {
|
|
497
|
+
const ids = c.statusIds || [];
|
|
498
|
+
// Already there → keep order untouched (functional no-op).
|
|
499
|
+
if (ids.includes(statusId)) return Object.assign({}, c, { statusIds: ids.slice() });
|
|
500
|
+
return Object.assign({}, c, { statusIds: ids.concat([statusId]) });
|
|
501
|
+
}
|
|
502
|
+
// Non-target: strip the status (no-op if it wasn't here).
|
|
503
|
+
return Object.assign({}, c, {
|
|
504
|
+
statusIds: (c.statusIds || []).filter(s => s !== statusId)
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Pure: status entries from the workflow that are NOT in any column.
|
|
510
|
+
function unassignedStatuses(statuses, columns) {
|
|
511
|
+
const assigned = new Set((columns || []).flatMap(c => c.statusIds || []));
|
|
512
|
+
return (statuses || []).filter(s => !assigned.has(s.id));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function renderStatusChip(status, ctx) {
|
|
516
|
+
const chip = el("span", {
|
|
517
|
+
class: "vs-board-chip",
|
|
518
|
+
dataset: { statusId: status.id, statusCategory: status.category || "doing" },
|
|
519
|
+
text: status.name || status.id
|
|
520
|
+
});
|
|
521
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
522
|
+
dnd.enableDraggable(chip, { dragType: "vs-board-status", dragId: status.id });
|
|
523
|
+
}
|
|
524
|
+
return chip;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renderBoardColumn(col, ctx, statuses, allColumns) {
|
|
528
|
+
const card = el("div", {
|
|
529
|
+
class: "vs-board-column",
|
|
530
|
+
dataset: { columnId: col.id }
|
|
531
|
+
});
|
|
532
|
+
const header = el("div", { class: "vs-board-column-header" });
|
|
533
|
+
const nameInput = el("input", {
|
|
534
|
+
type: "text",
|
|
535
|
+
class: "vs-board-column-name",
|
|
536
|
+
value: col.name || "",
|
|
537
|
+
placeholder: "Column name"
|
|
538
|
+
});
|
|
539
|
+
nameInput.addEventListener("blur", () => {
|
|
540
|
+
const v = String(nameInput.value || "").trim();
|
|
541
|
+
if (!v || v === col.name) { nameInput.value = col.name || ""; return; }
|
|
542
|
+
const next = allColumns.map(c => c.id === col.id ? Object.assign({}, c, { name: v }) : c);
|
|
543
|
+
commitBoardColumns(ctx, next);
|
|
544
|
+
});
|
|
545
|
+
const delBtn = el("button", {
|
|
546
|
+
class: "vs-board-column-delete",
|
|
547
|
+
title: "Delete column (statuses become unassigned)",
|
|
548
|
+
text: "×"
|
|
549
|
+
});
|
|
550
|
+
delBtn.addEventListener("click", (ev) => {
|
|
551
|
+
ev.stopPropagation();
|
|
552
|
+
const next = allColumns.filter(c => c.id !== col.id);
|
|
553
|
+
commitBoardColumns(ctx, next);
|
|
554
|
+
});
|
|
555
|
+
header.appendChild(nameInput);
|
|
556
|
+
header.appendChild(delBtn);
|
|
557
|
+
card.appendChild(header);
|
|
558
|
+
|
|
559
|
+
// Status chips inside the column. We render them in the order they
|
|
560
|
+
// appear in column.statusIds (caller can rearrange via drag in the
|
|
561
|
+
// future; for now order tracks insertion).
|
|
562
|
+
const chipsHost = el("div", { class: "vs-board-chips" });
|
|
563
|
+
const byStatusId = new Map(statuses.map(s => [s.id, s]));
|
|
564
|
+
for (const sid of (col.statusIds || [])) {
|
|
565
|
+
const s = byStatusId.get(sid);
|
|
566
|
+
if (!s) continue; // stale ref — normalize will drop it
|
|
567
|
+
chipsHost.appendChild(renderStatusChip(s, ctx));
|
|
568
|
+
}
|
|
569
|
+
card.appendChild(chipsHost);
|
|
570
|
+
|
|
571
|
+
// Column is a drop target — dropping a chip here reassigns it.
|
|
572
|
+
if (dnd && typeof dnd.enableDropTarget === "function") {
|
|
573
|
+
dnd.enableDropTarget(card, {
|
|
574
|
+
accepts: ["vs-board-status"],
|
|
575
|
+
onEnter: (e) => e.classList.add("vs-board-drop-target"),
|
|
576
|
+
onLeave: (e) => e.classList.remove("vs-board-drop-target"),
|
|
577
|
+
onDrop: ({ id }) => {
|
|
578
|
+
card.classList.remove("vs-board-drop-target");
|
|
579
|
+
const next = assignStatusToColumn(allColumns, id, col.id);
|
|
580
|
+
commitBoardColumns(ctx, next);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return card;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderBoardEditor(project, ctx) {
|
|
588
|
+
const statuses = (project.workflow && project.workflow.statuses) || [];
|
|
589
|
+
const columns = (project.boards && project.boards.kanban && project.boards.kanban.columns) || [];
|
|
590
|
+
const wrap = el("div", { class: "vs-board-editor" });
|
|
591
|
+
|
|
592
|
+
// Unassigned bucket — shows statuses that aren't in any column. Also a
|
|
593
|
+
// drop target: dropping a chip here removes it from all columns.
|
|
594
|
+
const orphans = unassignedStatuses(statuses, columns);
|
|
595
|
+
const bucket = el("div", { class: "vs-board-unassigned" });
|
|
596
|
+
const bucketLabel = el("div", { class: "vs-board-unassigned-label",
|
|
597
|
+
text: "Unassigned statuses" });
|
|
598
|
+
const bucketChips = el("div", { class: "vs-board-chips" });
|
|
599
|
+
for (const s of orphans) bucketChips.appendChild(renderStatusChip(s, ctx));
|
|
600
|
+
if (orphans.length === 0) {
|
|
601
|
+
bucketChips.appendChild(el("span", { class: "vs-board-empty",
|
|
602
|
+
text: "Every status is mapped to a column." }));
|
|
603
|
+
}
|
|
604
|
+
bucket.appendChild(bucketLabel);
|
|
605
|
+
bucket.appendChild(bucketChips);
|
|
606
|
+
// Drop-target: "remove from every column" by mapping to a sentinel id
|
|
607
|
+
// that doesn't exist → assignStatusToColumn just strips it.
|
|
608
|
+
if (dnd && typeof dnd.enableDropTarget === "function") {
|
|
609
|
+
dnd.enableDropTarget(bucket, {
|
|
610
|
+
accepts: ["vs-board-status"],
|
|
611
|
+
onEnter: (e) => e.classList.add("vs-board-drop-target"),
|
|
612
|
+
onLeave: (e) => e.classList.remove("vs-board-drop-target"),
|
|
613
|
+
onDrop: ({ id }) => {
|
|
614
|
+
bucket.classList.remove("vs-board-drop-target");
|
|
615
|
+
// Strip the status from all columns; no target column means
|
|
616
|
+
// nothing is appended → status becomes unassigned.
|
|
617
|
+
const next = columns.map(c => Object.assign({}, c, {
|
|
618
|
+
statusIds: (c.statusIds || []).filter(s => s !== id)
|
|
619
|
+
}));
|
|
620
|
+
commitBoardColumns(ctx, next);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
wrap.appendChild(bucket);
|
|
625
|
+
|
|
626
|
+
// Columns row.
|
|
627
|
+
const colsHost = el("div", { class: "vs-board-columns" });
|
|
628
|
+
for (const c of columns) {
|
|
629
|
+
colsHost.appendChild(renderBoardColumn(c, ctx, statuses, columns));
|
|
630
|
+
}
|
|
631
|
+
wrap.appendChild(colsHost);
|
|
632
|
+
|
|
633
|
+
// Add column.
|
|
634
|
+
const add = el("button", {
|
|
635
|
+
class: "vs-board-add-column",
|
|
636
|
+
title: "Add a new column",
|
|
637
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Column</span>'
|
|
638
|
+
});
|
|
639
|
+
add.addEventListener("click", (ev) => {
|
|
640
|
+
ev.stopPropagation();
|
|
641
|
+
const id = freshColumnId(columns);
|
|
642
|
+
const next = columns.concat([{ id: id, name: "New Column", statusIds: [] }]);
|
|
643
|
+
commitBoardColumns(ctx, next);
|
|
644
|
+
});
|
|
645
|
+
wrap.appendChild(add);
|
|
646
|
+
return wrap;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function renderBoardSection(project, ctx) {
|
|
650
|
+
return el("section", { class: "vs-section", dataset: { section: "board" } },
|
|
651
|
+
el("header", { class: "vs-section-header" },
|
|
652
|
+
el("h2", { text: "Kanban Board" }),
|
|
653
|
+
el("p", { class: "vs-section-hint",
|
|
654
|
+
text: "Group statuses into Kanban columns. Drag a status chip between buckets to remap." })
|
|
655
|
+
),
|
|
656
|
+
el("div", { class: "vs-section-body" },
|
|
657
|
+
renderBoardEditor(project, ctx)
|
|
658
|
+
)
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ---- SM-47: Link-Types editor ----------------------------------------
|
|
663
|
+
//
|
|
664
|
+
// List the project's linkTypes catalogue with inline-editable label and
|
|
665
|
+
// inverseLabel inputs, semantic-picker dropdown, color-picker, and
|
|
666
|
+
// delete-button. Same live-apply pattern as the rest of view-settings:
|
|
667
|
+
// discrete actions (add/remove/semantic-change/color-change) commit
|
|
668
|
+
// immediately; text inputs commit on-blur (caret-stable across the
|
|
669
|
+
// rerender via focus-guard in renderInto).
|
|
670
|
+
|
|
671
|
+
function commitLinkTypes(ctx, nextLinkTypes) {
|
|
672
|
+
if (!ctx || !ctx.store) return;
|
|
673
|
+
try {
|
|
674
|
+
ctx.store.updateProject({ linkTypes: nextLinkTypes }, LOCAL_ACTOR);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function renderLinkTypeRow(lt, ctx, allLinkTypes, idx) {
|
|
681
|
+
const row = el("div", { class: "vs-linktype-row", dataset: { linktypeId: lt.id } });
|
|
682
|
+
|
|
683
|
+
const idDisp = el("span", { class: "vs-linktype-id", text: lt.id, title: "Stable ID (referenced by links — not editable)" });
|
|
684
|
+
|
|
685
|
+
const labelInput = el("input", { type: "text", class: "vs-linktype-label", value: lt.label });
|
|
686
|
+
labelInput.addEventListener("blur", () => {
|
|
687
|
+
const v = String(labelInput.value || "").trim();
|
|
688
|
+
if (!v || v === lt.label) { labelInput.value = lt.label; return; }
|
|
689
|
+
const next = allLinkTypes.slice();
|
|
690
|
+
next[idx] = Object.assign({}, lt, { label: v });
|
|
691
|
+
commitLinkTypes(ctx, next);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const inverseInput = el("input", { type: "text", class: "vs-linktype-inverse", value: lt.inverseLabel });
|
|
695
|
+
inverseInput.addEventListener("blur", () => {
|
|
696
|
+
const v = String(inverseInput.value || "").trim();
|
|
697
|
+
if (!v || v === lt.inverseLabel) { inverseInput.value = lt.inverseLabel; return; }
|
|
698
|
+
const next = allLinkTypes.slice();
|
|
699
|
+
next[idx] = Object.assign({}, lt, { inverseLabel: v });
|
|
700
|
+
commitLinkTypes(ctx, next);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const semSelect = el("select", { class: "vs-linktype-semantic" });
|
|
704
|
+
const semantics = (core.LINK_SEMANTICS || ["precedence", "blocking", "sequence", "containment", "validation", "freeform"]);
|
|
705
|
+
for (const sem of semantics) {
|
|
706
|
+
const opt = el("option", { value: sem, text: sem });
|
|
707
|
+
if (sem === lt.semantic) opt.selected = true;
|
|
708
|
+
semSelect.appendChild(opt);
|
|
709
|
+
}
|
|
710
|
+
semSelect.addEventListener("change", () => {
|
|
711
|
+
const next = allLinkTypes.slice();
|
|
712
|
+
next[idx] = Object.assign({}, lt, { semantic: semSelect.value });
|
|
713
|
+
commitLinkTypes(ctx, next);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const colorInput = el("input", { type: "color", class: "vs-linktype-color", value: lt.color || "#888888" });
|
|
717
|
+
colorInput.addEventListener("change", () => {
|
|
718
|
+
const next = allLinkTypes.slice();
|
|
719
|
+
next[idx] = Object.assign({}, lt, { color: colorInput.value });
|
|
720
|
+
commitLinkTypes(ctx, next);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const delBtn = el("button", { class: "vs-linktype-delete", title: "Delete link type", text: "×" });
|
|
724
|
+
delBtn.addEventListener("click", (ev) => {
|
|
725
|
+
ev.stopPropagation();
|
|
726
|
+
// Confirm if the link-type is currently referenced by any link.
|
|
727
|
+
const snap = ctx.store ? ctx.store.get() : null;
|
|
728
|
+
let inUse = 0;
|
|
729
|
+
if (snap) {
|
|
730
|
+
for (const t of snap.tickets || []) {
|
|
731
|
+
for (const l of t.links || []) if (l.linkTypeId === lt.id) inUse++;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (inUse > 0 && typeof confirm === "function") {
|
|
735
|
+
if (!confirm("Link type \"" + lt.label + "\" is used in " + inUse + " link(s). Delete anyway? The links themselves stay but lose their resolved label.")) return;
|
|
736
|
+
}
|
|
737
|
+
const next = allLinkTypes.filter((_, i) => i !== idx);
|
|
738
|
+
commitLinkTypes(ctx, next);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
row.appendChild(idDisp);
|
|
742
|
+
row.appendChild(labelInput);
|
|
743
|
+
row.appendChild(inverseInput);
|
|
744
|
+
row.appendChild(semSelect);
|
|
745
|
+
row.appendChild(colorInput);
|
|
746
|
+
row.appendChild(delBtn);
|
|
747
|
+
return row;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function renderLinkTypeAddRow(ctx, allLinkTypes) {
|
|
751
|
+
const wrap = el("div", { class: "vs-linktype-add-wrap" });
|
|
752
|
+
const btn = el("button", { class: "btn vs-linktype-add", text: "+ Add Link Type" });
|
|
753
|
+
btn.addEventListener("click", () => {
|
|
754
|
+
// Generate a unique id (custom-N).
|
|
755
|
+
let n = 1, newId = "custom-" + n;
|
|
756
|
+
while (allLinkTypes.some(lt => lt.id === newId)) { n++; newId = "custom-" + n; }
|
|
757
|
+
const next = allLinkTypes.concat([{
|
|
758
|
+
id: newId, label: "Custom " + n, inverseLabel: "Custom " + n, semantic: "freeform"
|
|
759
|
+
}]);
|
|
760
|
+
commitLinkTypes(ctx, next);
|
|
761
|
+
});
|
|
762
|
+
wrap.appendChild(btn);
|
|
763
|
+
return wrap;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function renderLinkTypesEditor(project, ctx) {
|
|
767
|
+
const linkTypes = (project && project.linkTypes) || [];
|
|
768
|
+
const wrap = el("div", { class: "vs-linktypes-editor" });
|
|
769
|
+
// Column headers (above the rows) so labels are self-documenting.
|
|
770
|
+
const head = el("div", { class: "vs-linktype-row vs-linktype-head" });
|
|
771
|
+
head.appendChild(el("span", { class: "vs-linktype-id", text: "ID" }));
|
|
772
|
+
head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Label" }));
|
|
773
|
+
head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Inverse label" }));
|
|
774
|
+
head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Semantic" }));
|
|
775
|
+
head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Color" }));
|
|
776
|
+
head.appendChild(el("span", { class: "vs-linktype-col-label", text: "" }));
|
|
777
|
+
wrap.appendChild(head);
|
|
778
|
+
const rowsHost = el("div", { class: "vs-linktype-rows" });
|
|
779
|
+
linkTypes.forEach((lt, i) => rowsHost.appendChild(renderLinkTypeRow(lt, ctx, linkTypes, i)));
|
|
780
|
+
wrap.appendChild(rowsHost);
|
|
781
|
+
wrap.appendChild(renderLinkTypeAddRow(ctx, linkTypes));
|
|
782
|
+
return wrap;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function renderLinkTypesSection(project, ctx) {
|
|
786
|
+
return el("section", { class: "vs-section", dataset: { section: "links" } },
|
|
787
|
+
el("header", { class: "vs-section-header" },
|
|
788
|
+
el("h2", { text: "Link Types" }),
|
|
789
|
+
el("p", { class: "vs-section-hint",
|
|
790
|
+
text: "Typed relations between tickets (predecessor, blocks, contains, …). The semantic controls runtime behaviour (cycle-check) and inverse-label rendering. Deleting a type doesn't delete its links — they stay but lose the catalogue-resolved label." })
|
|
791
|
+
),
|
|
792
|
+
el("div", { class: "vs-section-body" },
|
|
793
|
+
renderLinkTypesEditor(project, ctx)
|
|
794
|
+
)
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---- SM-7: Ticket-Types editor ---------------------------------------
|
|
799
|
+
//
|
|
800
|
+
// project.ticketTypes is a simple string[] (e.g. ["epic", "user-story",
|
|
801
|
+
// "bug", "technical-task-backend", "technical-task-ui"]). The editor is
|
|
802
|
+
// a sortable list of name inputs with on-blur rename, + Add, ×, and DnD-
|
|
803
|
+
// reorder. Live-apply: every discrete action commits via store.updateProject.
|
|
804
|
+
// normalizeProject reseeds defaults if the list ends up empty, which
|
|
805
|
+
// protects against lock-out — but we still confirm deletion when the user
|
|
806
|
+
// tries to remove a type that's currently in use.
|
|
807
|
+
|
|
808
|
+
function commitTicketTypes(ctx, nextTypes) {
|
|
809
|
+
if (!ctx || !ctx.store) return;
|
|
810
|
+
try {
|
|
811
|
+
ctx.store.updateProject({ ticketTypes: nextTypes }, LOCAL_ACTOR);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function countTicketsOfType(snapshot, typeName) {
|
|
818
|
+
if (!snapshot) return 0;
|
|
819
|
+
let n = 0;
|
|
820
|
+
for (const t of (snapshot.tickets || [])) if (t.type === typeName && !t.isDeleted) n++;
|
|
821
|
+
return n;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function renderTicketTypeRow(typeName, ctx, allTypes, idx) {
|
|
825
|
+
const row = el("div", { class: "vs-tickettype-row", dataset: { typeName: typeName } });
|
|
826
|
+
row.appendChild(el("span", { class: "vs-grip", text: "⋮⋮" }));
|
|
827
|
+
const input = el("input", { type: "text", class: "vs-tickettype-name", value: typeName });
|
|
828
|
+
input.addEventListener("blur", () => {
|
|
829
|
+
const v = String(input.value || "").trim();
|
|
830
|
+
if (!v || v === typeName) { input.value = typeName; return; }
|
|
831
|
+
if (allTypes.indexOf(v) >= 0 && v !== typeName) {
|
|
832
|
+
if (ctx.flashStatus) ctx.flashStatus("type \"" + v + "\" already exists", { kind: "error" });
|
|
833
|
+
input.value = typeName;
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const next = allTypes.slice();
|
|
837
|
+
next[idx] = v;
|
|
838
|
+
commitTicketTypes(ctx, next);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const del = el("button", { class: "vs-tickettype-delete", title: "Delete ticket type", text: "×" });
|
|
842
|
+
del.addEventListener("click", (ev) => {
|
|
843
|
+
ev.stopPropagation();
|
|
844
|
+
const snap = ctx.store ? ctx.store.get() : null;
|
|
845
|
+
const inUse = countTicketsOfType(snap, typeName);
|
|
846
|
+
if (inUse > 0 && typeof confirm === "function") {
|
|
847
|
+
if (!confirm("Ticket type \"" + typeName + "\" is used by " + inUse + " ticket(s). They will keep their type label even after deletion. Delete anyway?")) return;
|
|
848
|
+
}
|
|
849
|
+
const next = allTypes.filter((_, i) => i !== idx);
|
|
850
|
+
commitTicketTypes(ctx, next);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
row.appendChild(input);
|
|
854
|
+
row.appendChild(del);
|
|
855
|
+
return row;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function reorderTicketTypes(types, sourceName, targetName, before) {
|
|
859
|
+
const list = types.slice();
|
|
860
|
+
const srcIdx = list.indexOf(sourceName);
|
|
861
|
+
if (srcIdx < 0) return list;
|
|
862
|
+
const [moved] = list.splice(srcIdx, 1);
|
|
863
|
+
let dstIdx = list.indexOf(targetName);
|
|
864
|
+
if (dstIdx < 0) { list.push(moved); return list; }
|
|
865
|
+
list.splice(before ? dstIdx : dstIdx + 1, 0, moved);
|
|
866
|
+
return list;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function attachTicketTypeRowDnD(row, typeName, allTypes, ctx) {
|
|
870
|
+
if (!dnd) return;
|
|
871
|
+
dnd.enableDraggable(row, { dragType: "vs-tickettype", dragId: typeName });
|
|
872
|
+
dnd.enableDropTarget(row, {
|
|
873
|
+
accepts: ["vs-tickettype"],
|
|
874
|
+
onMove: (info) => {
|
|
875
|
+
if (info.draggedId === typeName) return;
|
|
876
|
+
row.classList.add("vs-tickettype-drop-target");
|
|
877
|
+
},
|
|
878
|
+
onLeave: () => row.classList.remove("vs-tickettype-drop-target"),
|
|
879
|
+
onDrop: (info) => {
|
|
880
|
+
row.classList.remove("vs-tickettype-drop-target");
|
|
881
|
+
if (info.draggedId === typeName) return;
|
|
882
|
+
const rect = row.getBoundingClientRect();
|
|
883
|
+
const before = info.clientY < rect.top + rect.height / 2;
|
|
884
|
+
const next = reorderTicketTypes(allTypes, info.draggedId, typeName, before);
|
|
885
|
+
commitTicketTypes(ctx, next);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function renderTicketTypesEditor(project, ctx) {
|
|
891
|
+
const types = (project && project.ticketTypes) || [];
|
|
892
|
+
const wrap = el("div", { class: "vs-tickettypes-editor" });
|
|
893
|
+
const rowsHost = el("div", { class: "vs-tickettype-rows" });
|
|
894
|
+
types.forEach((name, i) => {
|
|
895
|
+
const row = renderTicketTypeRow(name, ctx, types, i);
|
|
896
|
+
attachTicketTypeRowDnD(row, name, types, ctx);
|
|
897
|
+
rowsHost.appendChild(row);
|
|
898
|
+
});
|
|
899
|
+
wrap.appendChild(rowsHost);
|
|
900
|
+
const addBtn = el("button", { class: "btn vs-tickettype-add", text: "+ Add Ticket Type" });
|
|
901
|
+
addBtn.addEventListener("click", () => {
|
|
902
|
+
let n = 1, name = "custom-type-" + n;
|
|
903
|
+
while (types.indexOf(name) >= 0) { n++; name = "custom-type-" + n; }
|
|
904
|
+
commitTicketTypes(ctx, types.concat([name]));
|
|
905
|
+
});
|
|
906
|
+
wrap.appendChild(addBtn);
|
|
907
|
+
return wrap;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function renderTicketTypesSection(project, ctx) {
|
|
911
|
+
return el("section", { class: "vs-section", dataset: { section: "types" } },
|
|
912
|
+
el("header", { class: "vs-section-header" },
|
|
913
|
+
el("h2", { text: "Ticket Types" }),
|
|
914
|
+
el("p", { class: "vs-section-hint",
|
|
915
|
+
text: "The types tickets can have (epic / user-story / bug / …). Rename on blur. Deleting a type does not retype the tickets that use it. If the list becomes empty, the default set is reseeded automatically." })
|
|
916
|
+
),
|
|
917
|
+
el("div", { class: "vs-section-body" },
|
|
918
|
+
renderTicketTypesEditor(project, ctx)
|
|
919
|
+
)
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ---- SM-9: Type Config editor (entityTypeConfig per ticket-type) -----
|
|
924
|
+
//
|
|
925
|
+
// project.entityTypeConfig maps ticket-type → flags that govern what the
|
|
926
|
+
// ticket-detail modal shows or allows. Defaults: every flag = true except
|
|
927
|
+
// allowParentEpic = false for type === "epic" (epics never nest under
|
|
928
|
+
// an epic). The editor: type-picker dropdown + 7 toggles. Live-apply:
|
|
929
|
+
// each toggle change commits via store.updateProject. The type "epic"
|
|
930
|
+
// is special — its allowParentEpic toggle is always disabled (engine
|
|
931
|
+
// hard-codes false), surfaced as a read-only marker.
|
|
932
|
+
|
|
933
|
+
// Module-state: which type is currently being edited. Resets to first
|
|
934
|
+
// ticketType on every fresh mount.
|
|
935
|
+
let _typeConfigSelectedType = null;
|
|
936
|
+
|
|
937
|
+
const TYPE_CONFIG_FLAGS = [
|
|
938
|
+
{ key: "allowParentEpic", label: "Allow parent epic",
|
|
939
|
+
hint: "Ticket may live under an epic via the contains-link." },
|
|
940
|
+
{ key: "showAcceptanceCriteria", label: "Show acceptance criteria",
|
|
941
|
+
hint: "Section appears in the ticket detail modal." },
|
|
942
|
+
{ key: "showDefinitionOfReady", label: "Show Definition of Ready",
|
|
943
|
+
hint: "DoR checklist section appears in the modal." },
|
|
944
|
+
{ key: "showDefinitionOfDone", label: "Show Definition of Done",
|
|
945
|
+
hint: "DoD checklist section appears in the modal." },
|
|
946
|
+
{ key: "showRelease", label: "Show release picker",
|
|
947
|
+
hint: "Release dropdown appears in the position fields." },
|
|
948
|
+
{ key: "showProcessStep", label: "Show process-step picker",
|
|
949
|
+
hint: "Process-step dropdown appears in the position fields." },
|
|
950
|
+
{ key: "showLinks", label: "Show links section",
|
|
951
|
+
hint: "Links section appears in the modal (forward + backward)." },
|
|
952
|
+
// SM-59 — test-type sections. SM-100: only offered for the matching test
|
|
953
|
+
// type; the engine hard-locks them to false for every other type, so
|
|
954
|
+
// showing the toggle elsewhere would just confuse the user.
|
|
955
|
+
{ key: "showPrerequisites", label: "Show prerequisites",
|
|
956
|
+
hint: "Prerequisites checklist appears in the modal.",
|
|
957
|
+
appliesTo: ["test-definition"] },
|
|
958
|
+
{ key: "showSteps", label: "Show steps editor",
|
|
959
|
+
hint: "3-column step table (Step / Data / Expected).",
|
|
960
|
+
appliesTo: ["test-definition"] },
|
|
961
|
+
{ key: "showExecutionSteps", label: "Show execution steps",
|
|
962
|
+
hint: "6-column run table with actual + status + note.",
|
|
963
|
+
appliesTo: ["test-execution"] },
|
|
964
|
+
{ key: "showTestOutcome", label: "Show outcome section",
|
|
965
|
+
hint: "Derived outcome + manual override picker.",
|
|
966
|
+
appliesTo: ["test-execution"] }
|
|
967
|
+
];
|
|
968
|
+
|
|
969
|
+
function commitTypeConfig(ctx, nextConfig) {
|
|
970
|
+
if (!ctx || !ctx.store) return;
|
|
971
|
+
try {
|
|
972
|
+
ctx.store.updateProject({ entityTypeConfig: nextConfig }, LOCAL_ACTOR);
|
|
973
|
+
} catch (err) {
|
|
974
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function renderTypeConfigEditor(project, ctx) {
|
|
979
|
+
const wrap = el("div", { class: "vs-typeconfig-editor" });
|
|
980
|
+
const types = (project && project.ticketTypes) || [];
|
|
981
|
+
if (!types.length) {
|
|
982
|
+
wrap.appendChild(el("div", { class: "vs-empty",
|
|
983
|
+
text: "No ticket types defined yet — add some in the Ticket Types tab first." }));
|
|
984
|
+
return wrap;
|
|
985
|
+
}
|
|
986
|
+
// Choose the type to edit. Default to the first ticketType; preserve
|
|
987
|
+
// user selection across rerenders so an external commit doesn't drop them.
|
|
988
|
+
if (!_typeConfigSelectedType || types.indexOf(_typeConfigSelectedType) < 0) {
|
|
989
|
+
_typeConfigSelectedType = types[0];
|
|
990
|
+
}
|
|
991
|
+
const currentType = _typeConfigSelectedType;
|
|
992
|
+
|
|
993
|
+
// Type-picker row.
|
|
994
|
+
const pickerRow = el("div", { class: "vs-typeconfig-picker-row" });
|
|
995
|
+
pickerRow.appendChild(el("label", { class: "tm-label", text: "Type" }));
|
|
996
|
+
const picker = el("select", { class: "vs-typeconfig-type-select" });
|
|
997
|
+
for (const t of types) {
|
|
998
|
+
const opt = el("option", { value: t, text: t });
|
|
999
|
+
if (t === currentType) opt.selected = true;
|
|
1000
|
+
picker.appendChild(opt);
|
|
1001
|
+
}
|
|
1002
|
+
picker.addEventListener("change", () => {
|
|
1003
|
+
_typeConfigSelectedType = picker.value;
|
|
1004
|
+
// Re-render the editor to reflect the new selection.
|
|
1005
|
+
const newEditor = renderTypeConfigEditor(project, ctx);
|
|
1006
|
+
wrap.parentNode.replaceChild(newEditor, wrap);
|
|
1007
|
+
});
|
|
1008
|
+
pickerRow.appendChild(picker);
|
|
1009
|
+
wrap.appendChild(pickerRow);
|
|
1010
|
+
|
|
1011
|
+
// Resolve the effective config so defaults render correctly when a type
|
|
1012
|
+
// has no override yet.
|
|
1013
|
+
const effective = (typeof core.getEntityTypeConfig === "function")
|
|
1014
|
+
? core.getEntityTypeConfig(project, currentType)
|
|
1015
|
+
: null;
|
|
1016
|
+
if (!effective) {
|
|
1017
|
+
wrap.appendChild(el("div", { class: "vs-empty", text: "Core helper missing." }));
|
|
1018
|
+
return wrap;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Flag-toggle list.
|
|
1022
|
+
const flagList = el("div", { class: "vs-typeconfig-flags" });
|
|
1023
|
+
for (const flag of TYPE_CONFIG_FLAGS) {
|
|
1024
|
+
// SM-100: test-flags only render for their matching type. The engine
|
|
1025
|
+
// hard-locks them to false elsewhere, so showing the control would
|
|
1026
|
+
// mislead the user into thinking it could be flipped.
|
|
1027
|
+
if (flag.appliesTo && flag.appliesTo.indexOf(currentType) < 0) continue;
|
|
1028
|
+
// Epic-special: allowParentEpic is hard-coded false for type "epic".
|
|
1029
|
+
const isEpicLock = (currentType === "epic" && flag.key === "allowParentEpic");
|
|
1030
|
+
const row = el("div", { class: "vs-typeconfig-flag" + (isEpicLock ? " vs-typeconfig-flag-locked" : ""),
|
|
1031
|
+
dataset: { flagKey: flag.key } });
|
|
1032
|
+
const cb = el("input", { type: "checkbox", class: "vs-typeconfig-flag-input" });
|
|
1033
|
+
cb.checked = effective[flag.key] !== false;
|
|
1034
|
+
if (isEpicLock) cb.disabled = true;
|
|
1035
|
+
cb.addEventListener("change", () => {
|
|
1036
|
+
// Read the current override (if any) so we patch onto it; otherwise
|
|
1037
|
+
// start fresh from the defaults.
|
|
1038
|
+
const allCfg = Object.assign({}, project.entityTypeConfig || {});
|
|
1039
|
+
const typeCfg = Object.assign({}, allCfg[currentType] || {});
|
|
1040
|
+
typeCfg[flag.key] = !!cb.checked;
|
|
1041
|
+
allCfg[currentType] = typeCfg;
|
|
1042
|
+
commitTypeConfig(ctx, allCfg);
|
|
1043
|
+
});
|
|
1044
|
+
const lbl = el("label", { class: "vs-typeconfig-flag-label" });
|
|
1045
|
+
lbl.appendChild(cb);
|
|
1046
|
+
lbl.appendChild(el("span", { class: "vs-typeconfig-flag-text", text: flag.label }));
|
|
1047
|
+
row.appendChild(lbl);
|
|
1048
|
+
if (flag.hint) row.appendChild(el("div", { class: "vs-typeconfig-flag-hint", text: flag.hint }));
|
|
1049
|
+
if (isEpicLock) row.appendChild(el("div", { class: "vs-typeconfig-flag-locked-note",
|
|
1050
|
+
text: "Locked: epics never nest under another epic." }));
|
|
1051
|
+
flagList.appendChild(row);
|
|
1052
|
+
}
|
|
1053
|
+
wrap.appendChild(flagList);
|
|
1054
|
+
return wrap;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function renderTypeConfigSection(project, ctx) {
|
|
1058
|
+
return el("section", { class: "vs-section", dataset: { section: "typeconfig" } },
|
|
1059
|
+
el("header", { class: "vs-section-header" },
|
|
1060
|
+
el("h2", { text: "Type Config" }),
|
|
1061
|
+
el("p", { class: "vs-section-hint",
|
|
1062
|
+
text: "Per ticket-type: which sections appear in the detail modal and whether the type may live under an epic. Pick a type above, toggle the flags — changes apply live to every modal of that type." })
|
|
1063
|
+
),
|
|
1064
|
+
el("div", { class: "vs-section-body" },
|
|
1065
|
+
renderTypeConfigEditor(project, ctx)
|
|
1066
|
+
)
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ---- SM-8: Labels editor ---------------------------------------------
|
|
1071
|
+
//
|
|
1072
|
+
// project.labels is normalized to `[{id, name, color}]`. ticket.labels
|
|
1073
|
+
// remains a string[] of label-names — the modal renders ticket labels
|
|
1074
|
+
// by looking up the catalogue color via name match. Editor: sortable
|
|
1075
|
+
// rows with name-input (on-blur), color-picker (on-change), delete (with
|
|
1076
|
+
// confirm if labels are referenced).
|
|
1077
|
+
|
|
1078
|
+
function commitLabels(ctx, nextLabels) {
|
|
1079
|
+
if (!ctx || !ctx.store) return;
|
|
1080
|
+
try {
|
|
1081
|
+
ctx.store.updateProject({ labels: nextLabels }, LOCAL_ACTOR);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function countLabelUses(snapshot, labelName) {
|
|
1088
|
+
if (!snapshot) return 0;
|
|
1089
|
+
let n = 0;
|
|
1090
|
+
for (const t of (snapshot.tickets || [])) {
|
|
1091
|
+
if (t.isDeleted) continue;
|
|
1092
|
+
const labels = t.labels || [];
|
|
1093
|
+
for (const l of labels) {
|
|
1094
|
+
const lname = typeof l === "string" ? l : (l && l.name) || "";
|
|
1095
|
+
if (lname === labelName) { n++; break; }
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return n;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function renderLabelRow(label, ctx, allLabels, idx) {
|
|
1102
|
+
const row = el("div", { class: "vs-label-row", dataset: { labelId: label.id } });
|
|
1103
|
+
row.appendChild(el("span", { class: "vs-grip", text: "⋮⋮" }));
|
|
1104
|
+
|
|
1105
|
+
const swatch = el("span", { class: "vs-label-swatch" });
|
|
1106
|
+
swatch.style.backgroundColor = label.color || "#999999";
|
|
1107
|
+
|
|
1108
|
+
const nameInput = el("input", { type: "text", class: "vs-label-name", value: label.name });
|
|
1109
|
+
nameInput.addEventListener("blur", () => {
|
|
1110
|
+
const v = String(nameInput.value || "").trim();
|
|
1111
|
+
if (!v || v === label.name) { nameInput.value = label.name; return; }
|
|
1112
|
+
const next = allLabels.slice();
|
|
1113
|
+
next[idx] = Object.assign({}, label, { name: v });
|
|
1114
|
+
commitLabels(ctx, next);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const colorInput = el("input", { type: "color", class: "vs-label-color", value: label.color || "#999999" });
|
|
1118
|
+
colorInput.addEventListener("change", () => {
|
|
1119
|
+
const next = allLabels.slice();
|
|
1120
|
+
next[idx] = Object.assign({}, label, { color: colorInput.value });
|
|
1121
|
+
commitLabels(ctx, next);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const del = el("button", { class: "vs-label-delete", title: "Delete label", text: "×" });
|
|
1125
|
+
del.addEventListener("click", (ev) => {
|
|
1126
|
+
ev.stopPropagation();
|
|
1127
|
+
const snap = ctx.store ? ctx.store.get() : null;
|
|
1128
|
+
const inUse = countLabelUses(snap, label.name);
|
|
1129
|
+
if (inUse > 0 && typeof confirm === "function") {
|
|
1130
|
+
if (!confirm("Label \"" + label.name + "\" is used on " + inUse + " ticket(s). Delete from the catalogue? Tickets keep the label string but lose the color.")) return;
|
|
1131
|
+
}
|
|
1132
|
+
const next = allLabels.filter((_, i) => i !== idx);
|
|
1133
|
+
commitLabels(ctx, next);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
row.appendChild(swatch);
|
|
1137
|
+
row.appendChild(nameInput);
|
|
1138
|
+
row.appendChild(colorInput);
|
|
1139
|
+
row.appendChild(del);
|
|
1140
|
+
return row;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function reorderLabels(labels, sourceId, targetId, before) {
|
|
1144
|
+
const list = labels.slice();
|
|
1145
|
+
const srcIdx = list.findIndex(l => l.id === sourceId);
|
|
1146
|
+
if (srcIdx < 0) return list;
|
|
1147
|
+
const [moved] = list.splice(srcIdx, 1);
|
|
1148
|
+
let dstIdx = list.findIndex(l => l.id === targetId);
|
|
1149
|
+
if (dstIdx < 0) { list.push(moved); return list; }
|
|
1150
|
+
list.splice(before ? dstIdx : dstIdx + 1, 0, moved);
|
|
1151
|
+
return list;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function attachLabelRowDnD(row, label, allLabels, ctx) {
|
|
1155
|
+
if (!dnd) return;
|
|
1156
|
+
dnd.enableDraggable(row, { dragType: "vs-label", dragId: label.id });
|
|
1157
|
+
dnd.enableDropTarget(row, {
|
|
1158
|
+
accepts: ["vs-label"],
|
|
1159
|
+
onMove: (info) => {
|
|
1160
|
+
if (info.draggedId === label.id) return;
|
|
1161
|
+
row.classList.add("vs-label-drop-target");
|
|
1162
|
+
},
|
|
1163
|
+
onLeave: () => row.classList.remove("vs-label-drop-target"),
|
|
1164
|
+
onDrop: (info) => {
|
|
1165
|
+
row.classList.remove("vs-label-drop-target");
|
|
1166
|
+
if (info.draggedId === label.id) return;
|
|
1167
|
+
const rect = row.getBoundingClientRect();
|
|
1168
|
+
const before = info.clientY < rect.top + rect.height / 2;
|
|
1169
|
+
const next = reorderLabels(allLabels, info.draggedId, label.id, before);
|
|
1170
|
+
commitLabels(ctx, next);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function renderLabelsEditor(project, ctx) {
|
|
1176
|
+
const labels = (project && project.labels) || [];
|
|
1177
|
+
const wrap = el("div", { class: "vs-labels-editor" });
|
|
1178
|
+
if (!labels.length) {
|
|
1179
|
+
wrap.appendChild(el("div", { class: "vs-empty",
|
|
1180
|
+
text: "No labels yet. Add one below — they'll become available in the ticket-detail modal's Labels field." }));
|
|
1181
|
+
}
|
|
1182
|
+
const rowsHost = el("div", { class: "vs-label-rows" });
|
|
1183
|
+
labels.forEach((label, i) => {
|
|
1184
|
+
const row = renderLabelRow(label, ctx, labels, i);
|
|
1185
|
+
attachLabelRowDnD(row, label, labels, ctx);
|
|
1186
|
+
rowsHost.appendChild(row);
|
|
1187
|
+
});
|
|
1188
|
+
wrap.appendChild(rowsHost);
|
|
1189
|
+
const addBtn = el("button", { class: "btn vs-label-add", text: "+ Add Label" });
|
|
1190
|
+
addBtn.addEventListener("click", () => {
|
|
1191
|
+
let n = 1, name = "label-" + n;
|
|
1192
|
+
while (labels.some(l => l.name === name)) { n++; name = "label-" + n; }
|
|
1193
|
+
commitLabels(ctx, labels.concat([{ name: name, color: "#999999" }]));
|
|
1194
|
+
});
|
|
1195
|
+
wrap.appendChild(addBtn);
|
|
1196
|
+
return wrap;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function renderLabelsSection(project, ctx) {
|
|
1200
|
+
return el("section", { class: "vs-section", dataset: { section: "labels" } },
|
|
1201
|
+
el("header", { class: "vs-section-header" },
|
|
1202
|
+
el("h2", { text: "Labels" }),
|
|
1203
|
+
el("p", { class: "vs-section-hint",
|
|
1204
|
+
text: "Catalogue of labels (name + color) that can be applied to tickets. Tickets reference labels by name — renaming here does not retroactively update existing tickets." })
|
|
1205
|
+
),
|
|
1206
|
+
el("div", { class: "vs-section-body" },
|
|
1207
|
+
renderLabelsEditor(project, ctx)
|
|
1208
|
+
)
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// ---- SM-2: DoR/DoD definitions editor ----------------------------
|
|
1213
|
+
//
|
|
1214
|
+
// Ported from the deprecated dialog-definitions-settings.js (removed in SM-3).
|
|
1215
|
+
// Same UI shape: global DoR/DoD blocks + per-ticket-type configuration
|
|
1216
|
+
// (enable toggle, mode-picker inherit/append/override, items list).
|
|
1217
|
+
//
|
|
1218
|
+
// Live-apply pattern matching the rest of view-settings: each discrete
|
|
1219
|
+
// user action (add/remove/reorder/required-toggle/mode-change/enable-
|
|
1220
|
+
// toggle/label-blur) re-reads the entire section's DOM via collectFormState
|
|
1221
|
+
// and commits a single store.updateProject({definitions, workflow,
|
|
1222
|
+
// entityTypeConfig}) — the workflow+entityTypeConfig changes piggyback so
|
|
1223
|
+
// the gate-enabled toggle stays in lockstep with the ticket-modal's
|
|
1224
|
+
// showDefinitionOfReady/Done visibility flags.
|
|
1225
|
+
//
|
|
1226
|
+
// Pure helpers (readFormState, collectFormState) are exported for tests.
|
|
1227
|
+
|
|
1228
|
+
function cssEscape(s) {
|
|
1229
|
+
if (typeof window !== "undefined" && window.CSS && typeof window.CSS.escape === "function") {
|
|
1230
|
+
return window.CSS.escape(s);
|
|
1231
|
+
}
|
|
1232
|
+
return String(s || "").replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function cloneDefItem(it) {
|
|
1236
|
+
return {
|
|
1237
|
+
id: typeof it.id === "string" && it.id ? it.id : freshDefItemId(),
|
|
1238
|
+
label: typeof it.label === "string" ? it.label : "",
|
|
1239
|
+
required: it.required !== false
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Per-(type, gate) state: enabled flag is derived from the workflow's
|
|
1245
|
+
* `transitions[].requireGate`; mode + items come from `definitions.<gate>
|
|
1246
|
+
* .byType[type]` (missing → inherit).
|
|
1247
|
+
*/
|
|
1248
|
+
function readGateState(project, type, statusKey, gateLabel) {
|
|
1249
|
+
const wf = core.getWorkflowForType(project, type);
|
|
1250
|
+
const trans = Array.isArray(wf.transitions) ? wf.transitions : [];
|
|
1251
|
+
const match = trans.find(t => t.toStatus === statusKey);
|
|
1252
|
+
const enabled = !!(match && match.requireGate === gateLabel);
|
|
1253
|
+
const defsBlock = (statusKey === "ready")
|
|
1254
|
+
? (project.definitions && project.definitions.ready)
|
|
1255
|
+
: (project.definitions && project.definitions.done);
|
|
1256
|
+
const perType = defsBlock && defsBlock.byType && defsBlock.byType[type];
|
|
1257
|
+
let mode = "inherit", items = [];
|
|
1258
|
+
if (perType) {
|
|
1259
|
+
if (Array.isArray(perType.overridden)) { mode = "override"; items = perType.overridden.map(cloneDefItem); }
|
|
1260
|
+
else if (Array.isArray(perType.appended)) { mode = "append"; items = perType.appended.map(cloneDefItem); }
|
|
1261
|
+
}
|
|
1262
|
+
return { enabled, mode, items };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function readFormState(project) {
|
|
1266
|
+
const types = {};
|
|
1267
|
+
(project.ticketTypes || []).forEach((t) => {
|
|
1268
|
+
types[t] = {
|
|
1269
|
+
dor: readGateState(project, t, "ready", "DoR"),
|
|
1270
|
+
dod: readGateState(project, t, "done", "DoD")
|
|
1271
|
+
};
|
|
1272
|
+
});
|
|
1273
|
+
return {
|
|
1274
|
+
globalDor: ((project.definitions && project.definitions.ready && project.definitions.ready.global) || []).map(cloneDefItem),
|
|
1275
|
+
globalDod: ((project.definitions && project.definitions.done && project.definitions.done.global) || []).map(cloneDefItem),
|
|
1276
|
+
types: types
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function readItemsFrom(host) {
|
|
1281
|
+
if (!host) return [];
|
|
1282
|
+
const rows = host.querySelectorAll(".vs-def-item-row");
|
|
1283
|
+
const out = [];
|
|
1284
|
+
rows.forEach((row) => {
|
|
1285
|
+
const labelInput = row.querySelector("input[type='text']");
|
|
1286
|
+
const checkbox = row.querySelector("input[type='checkbox']");
|
|
1287
|
+
const label = (labelInput && labelInput.value || "").trim();
|
|
1288
|
+
if (!label) return; // drop empty rows
|
|
1289
|
+
out.push({
|
|
1290
|
+
id: row.dataset.itemId || freshDefItemId(),
|
|
1291
|
+
label: label,
|
|
1292
|
+
required: !!(checkbox && checkbox.checked)
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Read the section's full DOM into a patch `{ definitions, workflow,
|
|
1300
|
+
* entityTypeConfig }`. Workflow's `byType[type].transitions` for `ready`
|
|
1301
|
+
* and `done` are replaced; other transitions in the array are preserved.
|
|
1302
|
+
*/
|
|
1303
|
+
function collectFormState(sectionEl, project) {
|
|
1304
|
+
const gDor = readItemsFrom(sectionEl.querySelector("[data-vs-def-section='global-dor']"));
|
|
1305
|
+
const gDod = readItemsFrom(sectionEl.querySelector("[data-vs-def-section='global-dod']"));
|
|
1306
|
+
|
|
1307
|
+
// SM-38: preserve byType entries for types whose blocks are NOT in the
|
|
1308
|
+
// DOM (the dropdown-based UI renders one type at a time). Without
|
|
1309
|
+
// pre-seeding, switching the dropdown would silently delete the
|
|
1310
|
+
// per-type definitions of every other type on the next commit.
|
|
1311
|
+
const baseDefs = project.definitions || {};
|
|
1312
|
+
const baseDefsReadyByType = (baseDefs.ready && baseDefs.ready.byType) || {};
|
|
1313
|
+
const baseDefsDoneByType = (baseDefs.done && baseDefs.done.byType) || {};
|
|
1314
|
+
const newDefs = {
|
|
1315
|
+
ready: { global: gDor, byType: Object.assign({}, baseDefsReadyByType) },
|
|
1316
|
+
done: { global: gDod, byType: Object.assign({}, baseDefsDoneByType) }
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
const baseWf = project.workflow || {};
|
|
1320
|
+
const newWf = {
|
|
1321
|
+
statuses: Array.isArray(baseWf.statuses) ? baseWf.statuses.slice() : undefined,
|
|
1322
|
+
transitions: Array.isArray(baseWf.transitions) ? baseWf.transitions.slice() : baseWf.transitions,
|
|
1323
|
+
byType: Object.assign({}, baseWf.byType || {})
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
const baseEtc = project.entityTypeConfig || {};
|
|
1327
|
+
const newEtc = Object.assign({}, baseEtc);
|
|
1328
|
+
|
|
1329
|
+
// SM-38: iterate the DOM blocks present (could be just one in dropdown
|
|
1330
|
+
// mode, or all of them in legacy/test scenarios). Pull the type from
|
|
1331
|
+
// dataset rather than from project.ticketTypes.
|
|
1332
|
+
const blocks = sectionEl.querySelectorAll(".vs-def-type-block[data-vs-def-type]");
|
|
1333
|
+
blocks.forEach((block) => {
|
|
1334
|
+
const type = block.dataset.vsDefType;
|
|
1335
|
+
if (!type) return;
|
|
1336
|
+
const dorEnabled = !!(block.querySelector("[data-vs-def-enabled='dor']") || {}).checked;
|
|
1337
|
+
const dodEnabled = !!(block.querySelector("[data-vs-def-enabled='dod']") || {}).checked;
|
|
1338
|
+
|
|
1339
|
+
const prevByType = newWf.byType[type] || {};
|
|
1340
|
+
const prevTrans = Array.isArray(prevByType.transitions) ? prevByType.transitions : [];
|
|
1341
|
+
const otherTrans = prevTrans.filter(t => t.toStatus !== "ready" && t.toStatus !== "done");
|
|
1342
|
+
newWf.byType[type] = Object.assign({}, prevByType, {
|
|
1343
|
+
transitions: otherTrans.concat([
|
|
1344
|
+
{ id: "to-ready", name: "→ Ready", fromStatuses: [], toStatus: "ready",
|
|
1345
|
+
requireGate: dorEnabled ? "DoR" : null, allowFromAny: true },
|
|
1346
|
+
{ id: "to-done", name: "→ Done", fromStatuses: [], toStatus: "done",
|
|
1347
|
+
requireGate: dodEnabled ? "DoD" : null, allowFromAny: true }
|
|
1348
|
+
])
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
// SM-38 follow-up: DO NOT couple entityTypeConfig.show* to the Required
|
|
1352
|
+
// toggle. Whether the DoR/DoD section appears in the ticket modal is a
|
|
1353
|
+
// separate concern from whether the gate is enforced on transitions.
|
|
1354
|
+
// newEtc[type] stays at its pre-existing value (default true), so per-
|
|
1355
|
+
// type DoR/DoD checklists remain visible as human reminders even when
|
|
1356
|
+
// not gate-enforced. Users who want to hide the section can set
|
|
1357
|
+
// entityTypeConfig.showDefinitionOf* via project_update.
|
|
1358
|
+
|
|
1359
|
+
// For the RENDERED type, the byType entry must reflect the current UI
|
|
1360
|
+
// state — including "deleted" when mode=inherit. The pre-seed from
|
|
1361
|
+
// baseDefs handles non-rendered types; here we explicitly drop /
|
|
1362
|
+
// override entries for this visible type, INDEPENDENTLY of whether
|
|
1363
|
+
// the gate is enforced ("Required" off + items present = stored
|
|
1364
|
+
// reminder items the modal shows but no gate fires).
|
|
1365
|
+
const dorMode = (block.querySelector("[data-vs-def-mode='dor']") || { value: "inherit" }).value;
|
|
1366
|
+
if (dorMode !== "inherit") {
|
|
1367
|
+
const items = readItemsFrom(block.querySelector("[data-vs-def-items='dor']"));
|
|
1368
|
+
newDefs.ready.byType[type] = (dorMode === "override") ? { overridden: items } : { appended: items };
|
|
1369
|
+
} else {
|
|
1370
|
+
delete newDefs.ready.byType[type];
|
|
1371
|
+
}
|
|
1372
|
+
const dodMode = (block.querySelector("[data-vs-def-mode='dod']") || { value: "inherit" }).value;
|
|
1373
|
+
if (dodMode !== "inherit") {
|
|
1374
|
+
const items = readItemsFrom(block.querySelector("[data-vs-def-items='dod']"));
|
|
1375
|
+
newDefs.done.byType[type] = (dodMode === "override") ? { overridden: items } : { appended: items };
|
|
1376
|
+
} else {
|
|
1377
|
+
delete newDefs.done.byType[type];
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
return { definitions: newDefs, workflow: newWf, entityTypeConfig: newEtc };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Live-apply commit: read the section DOM, build the patch, dispatch
|
|
1386
|
+
* updateProject. Errors surface via flashStatus.
|
|
1387
|
+
*/
|
|
1388
|
+
function commitDefinitions(anyDescendantEl, ctx) {
|
|
1389
|
+
if (!ctx || !ctx.store) return;
|
|
1390
|
+
try {
|
|
1391
|
+
// SM-40: DoR + DoD now live in two separate .vs-section elements.
|
|
1392
|
+
// collectFormState needs the .vs-root container so it can iterate
|
|
1393
|
+
// .vs-def-type-block + .vs-def-section across both sections. Resolve
|
|
1394
|
+
// .vs-root by walking up from whichever descendant was passed.
|
|
1395
|
+
const rootEl = anyDescendantEl && anyDescendantEl.closest
|
|
1396
|
+
? (anyDescendantEl.closest(".vs-root") || anyDescendantEl)
|
|
1397
|
+
: anyDescendantEl;
|
|
1398
|
+
const cur = ctx.store.get().project;
|
|
1399
|
+
const patch = collectFormState(rootEl, cur);
|
|
1400
|
+
ctx.store.updateProject(patch, LOCAL_ACTOR);
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "settings save failed", { kind: "error" });
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ---- DOM builders ------------------------------------------------
|
|
1407
|
+
|
|
1408
|
+
function renderDefItemRow(item, listHost, sectionEl, ctx) {
|
|
1409
|
+
const row = el("div", { class: "vs-def-item-row" });
|
|
1410
|
+
row.dataset.itemId = item.id;
|
|
1411
|
+
const grip = el("span", { class: "vs-def-item-grip", title: "Drag to reorder", text: "⋮⋮" });
|
|
1412
|
+
const reqLabel = el("label", { class: "vs-def-item-required-wrap", title: "Required to pass the gate" });
|
|
1413
|
+
const reqInput = el("input", { type: "checkbox" });
|
|
1414
|
+
if (item.required) reqInput.setAttribute("checked", "checked");
|
|
1415
|
+
reqInput.addEventListener("change", () => commitDefinitions(sectionEl, ctx));
|
|
1416
|
+
const reqHint = el("span", { class: "vs-def-item-required-hint", text: "required" });
|
|
1417
|
+
reqLabel.appendChild(reqInput); reqLabel.appendChild(reqHint);
|
|
1418
|
+
const labelInput = el("input", {
|
|
1419
|
+
type: "text", class: "vs-def-item-label",
|
|
1420
|
+
value: item.label || "", placeholder: "Item label"
|
|
1421
|
+
});
|
|
1422
|
+
labelInput.addEventListener("blur", () => commitDefinitions(sectionEl, ctx));
|
|
1423
|
+
const remove = el("button", {
|
|
1424
|
+
class: "vs-def-item-remove btn", type: "button",
|
|
1425
|
+
title: "Remove item", text: "×"
|
|
1426
|
+
});
|
|
1427
|
+
remove.addEventListener("click", () => {
|
|
1428
|
+
row.remove();
|
|
1429
|
+
commitDefinitions(sectionEl, ctx);
|
|
1430
|
+
});
|
|
1431
|
+
row.appendChild(grip);
|
|
1432
|
+
row.appendChild(reqLabel);
|
|
1433
|
+
row.appendChild(labelInput);
|
|
1434
|
+
row.appendChild(remove);
|
|
1435
|
+
|
|
1436
|
+
if (dnd && typeof dnd.enableDraggable === "function") {
|
|
1437
|
+
const listId = listHost.dataset.listId;
|
|
1438
|
+
dnd.enableDraggable(row, { dragType: DDS_DRAG_TYPE + ":" + listId, dragId: item.id });
|
|
1439
|
+
dnd.enableDropTarget(row, {
|
|
1440
|
+
accepts: [DDS_DRAG_TYPE + ":" + listId],
|
|
1441
|
+
onEnter: (e) => e.classList.add("vs-def-item-drop-target"),
|
|
1442
|
+
onLeave: (e) => e.classList.remove("vs-def-item-drop-target"),
|
|
1443
|
+
onDrop: ({ id, event }) => {
|
|
1444
|
+
row.classList.remove("vs-def-item-drop-target");
|
|
1445
|
+
const list = row.parentNode;
|
|
1446
|
+
if (!list) return;
|
|
1447
|
+
const dragged = list.querySelector('.vs-def-item-row[data-item-id="' + cssEscape(id) + '"]');
|
|
1448
|
+
if (!dragged || dragged === row) return;
|
|
1449
|
+
const rect = row.getBoundingClientRect();
|
|
1450
|
+
const before = (event && typeof event.clientY === "number")
|
|
1451
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
1452
|
+
if (before) list.insertBefore(dragged, row);
|
|
1453
|
+
else list.insertBefore(dragged, row.nextSibling);
|
|
1454
|
+
commitDefinitions(sectionEl, ctx);
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
return row;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function renderDefItemsList(host, items, listId, sectionEl, ctx) {
|
|
1462
|
+
host.dataset.listId = listId;
|
|
1463
|
+
host.classList.add("vs-def-items-list");
|
|
1464
|
+
items.forEach((it) => host.appendChild(renderDefItemRow(it, host, sectionEl, ctx)));
|
|
1465
|
+
const addBtn = el("button", {
|
|
1466
|
+
class: "btn vs-def-add-item", type: "button", text: "+ Add Item"
|
|
1467
|
+
});
|
|
1468
|
+
addBtn.addEventListener("click", () => {
|
|
1469
|
+
const row = renderDefItemRow({ id: freshDefItemId(), label: "", required: true }, host, sectionEl, ctx);
|
|
1470
|
+
host.insertBefore(row, addBtn);
|
|
1471
|
+
const input = row.querySelector("input[type='text']");
|
|
1472
|
+
if (input) input.focus();
|
|
1473
|
+
// No commit yet — empty label rows are dropped by readItemsFrom anyway.
|
|
1474
|
+
// The commit will fire on label blur once the user types something.
|
|
1475
|
+
});
|
|
1476
|
+
host.appendChild(addBtn);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function renderGlobalDefSection(host, label, sectionKey, items, sectionEl, ctx) {
|
|
1480
|
+
const wrap = el("section", { class: "vs-def-block" });
|
|
1481
|
+
wrap.dataset.vsDefSection = sectionKey;
|
|
1482
|
+
wrap.appendChild(el("h4", { class: "vs-def-block-title", text: label }));
|
|
1483
|
+
const list = el("div");
|
|
1484
|
+
wrap.appendChild(list);
|
|
1485
|
+
renderDefItemsList(list, items, sectionKey, sectionEl, ctx);
|
|
1486
|
+
host.appendChild(wrap);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function renderGateBlock(typeBlock, gateLabel, gateKey, state, sectionEl, ctx) {
|
|
1490
|
+
const wrap = el("div", { class: "vs-def-gate-block" });
|
|
1491
|
+
wrap.dataset.vsDefGate = gateKey;
|
|
1492
|
+
const header = el("div", { class: "vs-def-gate-header" });
|
|
1493
|
+
const enabled = el("input", { type: "checkbox" });
|
|
1494
|
+
enabled.dataset.vsDefEnabled = gateKey;
|
|
1495
|
+
if (state.enabled) enabled.setAttribute("checked", "checked");
|
|
1496
|
+
const enabledLabel = el("label", { class: "vs-def-gate-toggle" });
|
|
1497
|
+
enabledLabel.appendChild(enabled);
|
|
1498
|
+
// SM-38 follow-up: the toggle was previously labelled "DoR required for
|
|
1499
|
+
// this type" and gated the visibility of the entire body — that conflated
|
|
1500
|
+
// two concerns. "Required" now means ONLY gate enforcement (must check
|
|
1501
|
+
// off required items before the transition). The items list itself stays
|
|
1502
|
+
// editable regardless, so humans get a checklist reminder even when the
|
|
1503
|
+
// gate isn't enforced.
|
|
1504
|
+
enabledLabel.appendChild(el("span", { text: " Enforce " + gateLabel + " gate (block transition until required items checked)" }));
|
|
1505
|
+
header.appendChild(enabledLabel);
|
|
1506
|
+
wrap.appendChild(header);
|
|
1507
|
+
|
|
1508
|
+
// Body is always visible — items list is editable whether or not the gate
|
|
1509
|
+
// is enforced.
|
|
1510
|
+
const body = el("div", { class: "vs-def-gate-body" });
|
|
1511
|
+
const modeRow = el("div", { class: "vs-def-mode-row" });
|
|
1512
|
+
modeRow.appendChild(el("span", { class: "vs-def-mode-label", text: "Items" }));
|
|
1513
|
+
const sel = el("select");
|
|
1514
|
+
sel.dataset.vsDefMode = gateKey;
|
|
1515
|
+
for (const [val, lbl] of [
|
|
1516
|
+
["inherit", "Inherit global"],
|
|
1517
|
+
["append", "Append to global"],
|
|
1518
|
+
["override", "Override global"]
|
|
1519
|
+
]) {
|
|
1520
|
+
const opt = el("option", { value: val, text: lbl });
|
|
1521
|
+
if (val === state.mode) opt.setAttribute("selected", "selected");
|
|
1522
|
+
sel.appendChild(opt);
|
|
1523
|
+
}
|
|
1524
|
+
modeRow.appendChild(sel);
|
|
1525
|
+
body.appendChild(modeRow);
|
|
1526
|
+
|
|
1527
|
+
// SM-38 follow-up: items list is ALWAYS visible when the gate is enabled
|
|
1528
|
+
// (previously hidden when mode=inherit, which made the per-type +Add Item
|
|
1529
|
+
// UI hard to discover — users assumed per-type was on/off only). In
|
|
1530
|
+
// inherit mode the items are stored but not applied; switching mode to
|
|
1531
|
+
// append or override activates them. A hint explains this.
|
|
1532
|
+
const list = el("div");
|
|
1533
|
+
list.dataset.vsDefItems = gateKey;
|
|
1534
|
+
renderDefItemsList(list, state.items, "type-" + typeBlock.dataset.vsDefType + "-" + gateKey, sectionEl, ctx);
|
|
1535
|
+
body.appendChild(list);
|
|
1536
|
+
const hint = el("p", { class: "vs-def-mode-hint",
|
|
1537
|
+
text: "Inherit: per-type items are stored but ignored. Append: added to globals. Override: replaces globals." });
|
|
1538
|
+
body.appendChild(hint);
|
|
1539
|
+
|
|
1540
|
+
sel.addEventListener("change", () => {
|
|
1541
|
+
commitDefinitions(sectionEl, ctx);
|
|
1542
|
+
});
|
|
1543
|
+
enabled.addEventListener("change", () => {
|
|
1544
|
+
commitDefinitions(sectionEl, ctx);
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
wrap.appendChild(body);
|
|
1548
|
+
typeBlock.appendChild(wrap);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function renderTypeBlock(host, type, typeState, rootEl, ctx, gateKey) {
|
|
1552
|
+
const block = el("section", { class: "vs-def-type-block" });
|
|
1553
|
+
block.dataset.vsDefType = type;
|
|
1554
|
+
block.appendChild(el("h4", { class: "vs-def-type-title", text: type }));
|
|
1555
|
+
// SM-40: gateKey filters which gate(s) this block renders. When omitted
|
|
1556
|
+
// (legacy callers), both gates are rendered (no behaviour change for
|
|
1557
|
+
// any code still passing only 5 args). When passed as "dor" or "dod",
|
|
1558
|
+
// only that gate's block is rendered — used by the split DoR/DoD
|
|
1559
|
+
// sections.
|
|
1560
|
+
if (!gateKey || gateKey === "dor") {
|
|
1561
|
+
renderGateBlock(block, "Definition of Ready", "dor", typeState.dor, rootEl, ctx);
|
|
1562
|
+
}
|
|
1563
|
+
if (!gateKey || gateKey === "dod") {
|
|
1564
|
+
renderGateBlock(block, "Definition of Done", "dod", typeState.dod, rootEl, ctx);
|
|
1565
|
+
}
|
|
1566
|
+
host.appendChild(block);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* SM-40: helper for a single-gate Definitions section. `gateKey` ∈ {"dor","dod"};
|
|
1571
|
+
* `sectionLabel`/`globalLabel` are display strings; `globalKey` = "global-dor"
|
|
1572
|
+
* or "global-dod" (matches the readItemsFrom selector); `globalItems` is the
|
|
1573
|
+
* state slice for that gate's globals. Renders one .vs-section with the
|
|
1574
|
+
* single-gate globals + the type-picker + a single-gate per-type block.
|
|
1575
|
+
*/
|
|
1576
|
+
function renderSingleGateSection(project, ctx, rootEl, opts) {
|
|
1577
|
+
const { gateKey, sectionId, sectionLabel, globalLabel, globalKey, globalItems, typeStateKey } = opts;
|
|
1578
|
+
const section = el("section", { class: "vs-section vs-def-root", dataset: { section: sectionId } });
|
|
1579
|
+
section.appendChild(el("header", { class: "vs-section-header" },
|
|
1580
|
+
el("h2", { text: sectionLabel }),
|
|
1581
|
+
el("p", { class: "vs-section-hint",
|
|
1582
|
+
text: "Checklist gating ticket transitions for this stage. Globals apply to every ticket type; per-type configuration can append to or override them." })
|
|
1583
|
+
));
|
|
1584
|
+
const body = el("div", { class: "vs-section-body" });
|
|
1585
|
+
section.appendChild(body);
|
|
1586
|
+
|
|
1587
|
+
const state = readFormState(project);
|
|
1588
|
+
|
|
1589
|
+
const globals = el("div", { class: "vs-def-globals" });
|
|
1590
|
+
globals.appendChild(el("h3", { class: "vs-subhead", text: "Global items" }));
|
|
1591
|
+
renderGlobalDefSection(globals, globalLabel, globalKey, globalItems, rootEl, ctx);
|
|
1592
|
+
body.appendChild(globals);
|
|
1593
|
+
|
|
1594
|
+
// SM-38: per-type configuration is now a Type-Picker + single edit-block.
|
|
1595
|
+
// Switching the picker re-renders the block for the newly-selected type
|
|
1596
|
+
// (UI-only, no commit). Edits inside the block still live-apply via
|
|
1597
|
+
// commitDefinitions, which now preserves byType-entries for types whose
|
|
1598
|
+
// block is NOT in the DOM (see collectFormState).
|
|
1599
|
+
if ((project.ticketTypes || []).length > 0) {
|
|
1600
|
+
body.appendChild(el("h3", { class: "vs-subhead", text: "Per-type configuration" }));
|
|
1601
|
+
const perType = el("div", { class: "vs-def-per-type" });
|
|
1602
|
+
|
|
1603
|
+
const pickerRow = el("div", { class: "vs-def-type-picker-row" });
|
|
1604
|
+
pickerRow.appendChild(el("label", { class: "vs-def-type-picker-label", text: "Configure for type:" }));
|
|
1605
|
+
const picker = el("select", { class: "vs-def-type-picker" });
|
|
1606
|
+
(project.ticketTypes || []).forEach((t) => {
|
|
1607
|
+
picker.appendChild(el("option", { value: t, text: t }));
|
|
1608
|
+
});
|
|
1609
|
+
pickerRow.appendChild(picker);
|
|
1610
|
+
perType.appendChild(pickerRow);
|
|
1611
|
+
|
|
1612
|
+
const blockHost = el("div", { class: "vs-def-type-host" });
|
|
1613
|
+
perType.appendChild(blockHost);
|
|
1614
|
+
|
|
1615
|
+
function renderBlockFor(type) {
|
|
1616
|
+
blockHost.innerHTML = "";
|
|
1617
|
+
renderTypeBlock(
|
|
1618
|
+
blockHost, type,
|
|
1619
|
+
state.types[type] || { dor: { enabled: false, mode: "inherit", items: [] },
|
|
1620
|
+
dod: { enabled: false, mode: "inherit", items: [] } },
|
|
1621
|
+
rootEl, ctx, gateKey
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// Default selection = first ticketType. Switching the picker is a
|
|
1626
|
+
// pure UI re-render; no commit fires.
|
|
1627
|
+
const firstType = project.ticketTypes[0];
|
|
1628
|
+
picker.value = firstType;
|
|
1629
|
+
renderBlockFor(firstType);
|
|
1630
|
+
|
|
1631
|
+
picker.addEventListener("change", () => {
|
|
1632
|
+
renderBlockFor(picker.value);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
body.appendChild(perType);
|
|
1636
|
+
}
|
|
1637
|
+
return section;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function renderDorSection(project, ctx, rootEl) {
|
|
1641
|
+
const state = readFormState(project);
|
|
1642
|
+
return renderSingleGateSection(project, ctx, rootEl, {
|
|
1643
|
+
gateKey: "dor",
|
|
1644
|
+
sectionId: "dor",
|
|
1645
|
+
sectionLabel: "Definition of Ready",
|
|
1646
|
+
globalLabel: "Global Definition of Ready items",
|
|
1647
|
+
globalKey: "global-dor",
|
|
1648
|
+
globalItems: state.globalDor
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function renderDodSection(project, ctx, rootEl) {
|
|
1653
|
+
const state = readFormState(project);
|
|
1654
|
+
return renderSingleGateSection(project, ctx, rootEl, {
|
|
1655
|
+
gateKey: "dod",
|
|
1656
|
+
sectionId: "dod",
|
|
1657
|
+
sectionLabel: "Definition of Done",
|
|
1658
|
+
globalLabel: "Global Definition of Done items",
|
|
1659
|
+
globalKey: "global-dod",
|
|
1660
|
+
globalItems: state.globalDod
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ---- Mount / render -----------------------------------------------
|
|
1665
|
+
|
|
1666
|
+
// SM-41: module-state for the active tab. Reset to "workflow" on every
|
|
1667
|
+
// fresh mount (no persist across open/close — bewusste User-Entscheidung).
|
|
1668
|
+
// Survives renderInto re-runs (live-sync), so external commits don't snap
|
|
1669
|
+
// the user back to Workflow.
|
|
1670
|
+
// ---------------------------------------------------------------------------
|
|
1671
|
+
// SM-107 — read-only "Rules per type" (derived from the catalog)
|
|
1672
|
+
// ---------------------------------------------------------------------------
|
|
1673
|
+
|
|
1674
|
+
// Pure: walk the transition-rule catalog and report, per ticket type, which
|
|
1675
|
+
// rules are enabled (enabledFor true) vs N/A. Read-only derivation from
|
|
1676
|
+
// core.TRANSITION_RULES + the project's entityTypeConfig — no persistence.
|
|
1677
|
+
// This is the UI face of the SM-102 lockstep invariant: a rule shows as N/A
|
|
1678
|
+
// exactly when its backing field is configured off for that type.
|
|
1679
|
+
function activeRulesForType(type, project) {
|
|
1680
|
+
const catalog = (core && core.TRANSITION_RULES) || {};
|
|
1681
|
+
return Object.keys(catalog).map((id) => {
|
|
1682
|
+
const rule = catalog[id];
|
|
1683
|
+
const enabled = typeof rule.enabledFor === "function"
|
|
1684
|
+
? !!rule.enabledFor(type, project || {})
|
|
1685
|
+
: true;
|
|
1686
|
+
return { id: id, label: (rule && rule.label) || id, enabled: enabled };
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function renderRulesSection(project, ctx) {
|
|
1691
|
+
const types = (project && Array.isArray(project.ticketTypes) && project.ticketTypes.length)
|
|
1692
|
+
? project.ticketTypes
|
|
1693
|
+
: (core.DEFAULT_TICKET_TYPES || []);
|
|
1694
|
+
const bodyChildren = [];
|
|
1695
|
+
for (const type of types) {
|
|
1696
|
+
const block = el("div", { class: "vs-rules-type", dataset: { type: type } });
|
|
1697
|
+
block.appendChild(el("h3", { class: "vs-subhead", text: type }));
|
|
1698
|
+
const list = el("ul", { class: "vs-rules-list" });
|
|
1699
|
+
for (const r of activeRulesForType(type, project)) {
|
|
1700
|
+
const li = el("li", {
|
|
1701
|
+
class: "vs-rules-item" + (r.enabled ? " active" : " inactive"),
|
|
1702
|
+
dataset: { ruleId: r.id, enabled: r.enabled ? "1" : "0" }
|
|
1703
|
+
});
|
|
1704
|
+
li.appendChild(el("span", { class: "vs-rules-state", text: r.enabled ? "✓" : "—" }));
|
|
1705
|
+
li.appendChild(el("code", { class: "vs-rules-id selectable", text: r.id }));
|
|
1706
|
+
li.appendChild(el("span", { class: "vs-rules-label", text: r.label }));
|
|
1707
|
+
if (!r.enabled) li.appendChild(el("span", { class: "vs-rules-na", text: "N/A for this type" }));
|
|
1708
|
+
list.appendChild(li);
|
|
1709
|
+
}
|
|
1710
|
+
block.appendChild(list);
|
|
1711
|
+
bodyChildren.push(block);
|
|
1712
|
+
}
|
|
1713
|
+
return el("section", { class: "vs-section", dataset: { section: "rules" } },
|
|
1714
|
+
el("header", { class: "vs-section-header" },
|
|
1715
|
+
el("h2", { text: "Rules" }),
|
|
1716
|
+
el("p", { class: "vs-section-hint",
|
|
1717
|
+
text: "Read-only. Which transition-gate rules apply to each ticket type, derived from the rule catalog + this project's type config. A rule shows N/A when its backing field (e.g. DoD section) is hidden for that type — the SM-102 lockstep invariant made visible." })
|
|
1718
|
+
),
|
|
1719
|
+
el("div", { class: "vs-section-body" }, ...bodyChildren));
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
let _activeTab = "workflow";
|
|
1723
|
+
|
|
1724
|
+
const SETTINGS_TABS = [
|
|
1725
|
+
{ id: "workflow", label: "Workflow" },
|
|
1726
|
+
{ id: "dor", label: "Definition of Ready" },
|
|
1727
|
+
{ id: "dod", label: "Definition of Done" },
|
|
1728
|
+
{ id: "board", label: "Kanban Board" },
|
|
1729
|
+
{ id: "links", label: "Link Types" },
|
|
1730
|
+
{ id: "types", label: "Ticket Types" },
|
|
1731
|
+
{ id: "typeconfig", label: "Type Config" },
|
|
1732
|
+
{ id: "labels", label: "Labels" },
|
|
1733
|
+
{ id: "rules", label: "Rules" }
|
|
1734
|
+
];
|
|
1735
|
+
|
|
1736
|
+
function renderSettingsTabBar(activeId, onSelect) {
|
|
1737
|
+
const bar = el("div", { class: "vs-tab-bar", role: "tablist" });
|
|
1738
|
+
for (const tab of SETTINGS_TABS) {
|
|
1739
|
+
const btn = el("button", {
|
|
1740
|
+
class: "vs-tab-btn" + (tab.id === activeId ? " active" : ""),
|
|
1741
|
+
type: "button", role: "tab",
|
|
1742
|
+
text: tab.label
|
|
1743
|
+
});
|
|
1744
|
+
btn.dataset.vsTab = tab.id;
|
|
1745
|
+
btn.setAttribute("aria-selected", tab.id === activeId ? "true" : "false");
|
|
1746
|
+
btn.addEventListener("click", () => onSelect(tab.id));
|
|
1747
|
+
bar.appendChild(btn);
|
|
1748
|
+
}
|
|
1749
|
+
return bar;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function setActiveTab(rootEl, tabId) {
|
|
1753
|
+
_activeTab = tabId;
|
|
1754
|
+
const sections = rootEl.querySelectorAll(":scope > .vs-section");
|
|
1755
|
+
sections.forEach((s) => {
|
|
1756
|
+
if (s.dataset.section === tabId) s.removeAttribute("hidden");
|
|
1757
|
+
else s.setAttribute("hidden", "");
|
|
1758
|
+
});
|
|
1759
|
+
const buttons = rootEl.querySelectorAll(".vs-tab-bar > .vs-tab-btn");
|
|
1760
|
+
buttons.forEach((b) => {
|
|
1761
|
+
const active = b.dataset.vsTab === tabId;
|
|
1762
|
+
b.classList.toggle("active", active);
|
|
1763
|
+
b.setAttribute("aria-selected", active ? "true" : "false");
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function renderInto(host, store, ctx) {
|
|
1768
|
+
const snap = store.get();
|
|
1769
|
+
const project = (snap && snap.project) || null;
|
|
1770
|
+
// Focus-Guard: if the user is currently typing in a name input inside
|
|
1771
|
+
// the view, skip a full rerender — otherwise they lose caret position
|
|
1772
|
+
// and the in-flight edit. The blur-commit handler will trigger the
|
|
1773
|
+
// next external commit anyway, which then DOES re-render.
|
|
1774
|
+
const active = document.activeElement;
|
|
1775
|
+
if (active && host.contains(active) && active.matches
|
|
1776
|
+
&& active.matches("input.vs-status-name, input.vs-transition-name, input.vs-board-column-name, input.vs-def-item-label, input.vs-linktype-label, input.vs-linktype-inverse, input.vs-tickettype-name, input.vs-label-name")) {
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
// Renderers and event handlers expect ctx.store on the ctx object;
|
|
1780
|
+
// mount() takes store as a separate parameter, so merge it here so
|
|
1781
|
+
// we don't sprinkle store-threading through every helper.
|
|
1782
|
+
const fullCtx = Object.assign({}, ctx || {}, { store: store });
|
|
1783
|
+
const root = el("div", { class: "vs-root" });
|
|
1784
|
+
if (!project) {
|
|
1785
|
+
root.appendChild(el("p", { class: "vs-empty",
|
|
1786
|
+
text: "Load a project to configure its workflow and board." }));
|
|
1787
|
+
} else {
|
|
1788
|
+
// SM-41: tab-bar first, then the four sections. Only the section
|
|
1789
|
+
// matching _activeTab is visible; the others are hidden via the
|
|
1790
|
+
// hidden attribute. Tab-click toggles via setActiveTab WITHOUT
|
|
1791
|
+
// re-running renderInto (DOM-identity + DnD-Registrierungen bleiben).
|
|
1792
|
+
root.appendChild(renderSettingsTabBar(_activeTab, (tabId) => setActiveTab(root, tabId)));
|
|
1793
|
+
root.appendChild(renderWorkflowSection(project, fullCtx));
|
|
1794
|
+
root.appendChild(renderDorSection(project, fullCtx, root));
|
|
1795
|
+
root.appendChild(renderDodSection(project, fullCtx, root));
|
|
1796
|
+
root.appendChild(renderBoardSection(project, fullCtx));
|
|
1797
|
+
root.appendChild(renderLinkTypesSection(project, fullCtx));
|
|
1798
|
+
root.appendChild(renderTicketTypesSection(project, fullCtx));
|
|
1799
|
+
root.appendChild(renderTypeConfigSection(project, fullCtx));
|
|
1800
|
+
root.appendChild(renderLabelsSection(project, fullCtx));
|
|
1801
|
+
root.appendChild(renderRulesSection(project, fullCtx));
|
|
1802
|
+
// Apply the initial visibility AFTER all sections are appended so
|
|
1803
|
+
// setActiveTab can find them via :scope > .vs-section.
|
|
1804
|
+
setActiveTab(root, _activeTab);
|
|
1805
|
+
}
|
|
1806
|
+
host.innerHTML = "";
|
|
1807
|
+
host.appendChild(root);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Mount the settings view into `host`. Returns `{unmount}`. Subscribes
|
|
1812
|
+
* to the store so external commits re-render the sections (E21.E/F/G
|
|
1813
|
+
* editors rely on this for live-sync). When no store is provided yet
|
|
1814
|
+
* (no project loaded), renders the empty state once.
|
|
1815
|
+
*/
|
|
1816
|
+
function mount(host, store, ctx) {
|
|
1817
|
+
ctx = ctx || {};
|
|
1818
|
+
// SM-41: fresh mount resets the active tab to "workflow". Closing and
|
|
1819
|
+
// re-opening the Settings overlay (Project ▸ Settings…) returns the
|
|
1820
|
+
// user to the first tab — no persist. Live-sync re-runs of renderInto
|
|
1821
|
+
// do NOT pass through here, so they keep the user's current tab.
|
|
1822
|
+
_activeTab = "workflow";
|
|
1823
|
+
// SM-9: also reset the per-type editor's selection so a fresh open
|
|
1824
|
+
// always lands on the first ticket-type.
|
|
1825
|
+
_typeConfigSelectedType = null;
|
|
1826
|
+
if (!store) {
|
|
1827
|
+
host.innerHTML = "";
|
|
1828
|
+
host.appendChild(el("div", { class: "vs-root" },
|
|
1829
|
+
el("p", { class: "vs-empty",
|
|
1830
|
+
text: "Load a project to configure its workflow and board." })));
|
|
1831
|
+
return { unmount: () => { host.innerHTML = ""; } };
|
|
1832
|
+
}
|
|
1833
|
+
function rerender() { renderInto(host, store, ctx); }
|
|
1834
|
+
rerender();
|
|
1835
|
+
const unsub = store.subscribe(rerender);
|
|
1836
|
+
return {
|
|
1837
|
+
unmount: () => {
|
|
1838
|
+
try { unsub(); } catch (_) { /* ignore */ }
|
|
1839
|
+
host.innerHTML = "";
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
return {
|
|
1845
|
+
mount,
|
|
1846
|
+
reorderStatuses,
|
|
1847
|
+
reorderTransitions,
|
|
1848
|
+
sortTransitionsByStatusOrder,
|
|
1849
|
+
assignStatusToColumn,
|
|
1850
|
+
unassignedStatuses,
|
|
1851
|
+
// SM-2 — DoR/DoD editor exports
|
|
1852
|
+
readFormState,
|
|
1853
|
+
collectFormState,
|
|
1854
|
+
// SM-40 split — two separate single-gate section renderers replace
|
|
1855
|
+
// the old combined renderDefinitionsSection.
|
|
1856
|
+
renderDorSection,
|
|
1857
|
+
renderDodSection,
|
|
1858
|
+
// SM-7 + SM-8 — pure reorder helpers exposed for unit testing
|
|
1859
|
+
reorderTicketTypes,
|
|
1860
|
+
reorderLabels,
|
|
1861
|
+
// SM-107 — read-only rules-per-type derivation
|
|
1862
|
+
activeRulesForType
|
|
1863
|
+
};
|
|
1864
|
+
}));
|