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,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket-Detail-Modal — schwebt über der Seite (E10).
|
|
3
|
+
*
|
|
4
|
+
* Rendert ein editierbares Detail-Modal für ein Ticket. Felder:
|
|
5
|
+
* Title, Type, Status, Release, ProcessStep, Epic, Description,
|
|
6
|
+
* Acceptance-Criteria, DoR-Checklist, DoD-Checklist, Labels.
|
|
7
|
+
*
|
|
8
|
+
* Pattern: nutzt `showModal` aus `ui-shell.js` als Host und baut den
|
|
9
|
+
* Body via DOM-Manipulation im `onMount`-Callback (rich form statt
|
|
10
|
+
* HTML-String, weil dynamische Inputs + Live-Sync nötig sind).
|
|
11
|
+
*
|
|
12
|
+
* Live-Sync: solange das Modal offen ist, abonniert es Store-Commits.
|
|
13
|
+
* Auf jedes Commit wird das Ticket frisch aus dem Store gelesen und die
|
|
14
|
+
* Felder aktualisiert — AUSSER dem Feld, in dem der User gerade tippt
|
|
15
|
+
* (`document.activeElement`-Guard), sodass tippender Input nicht
|
|
16
|
+
* überschrieben wird.
|
|
17
|
+
*
|
|
18
|
+
* Persistenz: Save-Button feuert PUT /tickets/:tid für die "normalen"
|
|
19
|
+
* Felder. Status-Wechsel feuert separat POST /tickets/:tid/status
|
|
20
|
+
* (damit DoR/DoD-Gates auf dem Server greifen — bei 422 wird die
|
|
21
|
+
* Missing-Liste im Modal als Fehlerbanner angezeigt). DoR/DoD-Items
|
|
22
|
+
* werden INLINE bei Click via POST /tickets/:tid/dor/:itemId/{check,uncheck}
|
|
23
|
+
* persistiert (kein Save nötig — sofortiger Effekt).
|
|
24
|
+
*
|
|
25
|
+
* Mount API:
|
|
26
|
+
* openTicketModal(ctx, ticketId, opts) → { close }
|
|
27
|
+
* ctx.store ProjectStore-Instanz
|
|
28
|
+
* ctx.projectId string — für die HTTP-Pfade
|
|
29
|
+
* ctx.adapter adapter mit .name === "Http" oder Local
|
|
30
|
+
* ctx.httpReq(method, path, body) — async, throws on non-2xx (siehe main.js)
|
|
31
|
+
* ctx.reloadStore() — async, GET + applyRemote (nach Status/Item-Ops)
|
|
32
|
+
* ctx.applySnapshot(snap) — appliziert direkt zurückgegebenen Snapshot
|
|
33
|
+
* ctx.flashStatus(msg, opts?) — User-Feedback
|
|
34
|
+
* opts.onDelete(ticketId) — optional: separater Delete-Pfad (Soft-Delete via DELETE-Endpoint)
|
|
35
|
+
*/
|
|
36
|
+
(function (root, factory) {
|
|
37
|
+
if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"), require("./ticket-form.js"));
|
|
38
|
+
else (root.STORYMAP = root.STORYMAP || {}).rendererTicketModal = factory(
|
|
39
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
40
|
+
root.STORYMAP && root.STORYMAP.dnd,
|
|
41
|
+
root.STORYMAP && root.STORYMAP.ticketForm
|
|
42
|
+
);
|
|
43
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd, ticketForm) {
|
|
44
|
+
"use strict";
|
|
45
|
+
|
|
46
|
+
if (!core) throw new Error("renderer-ticket-modal: core module missing");
|
|
47
|
+
if (!dnd) throw new Error("renderer-ticket-modal: dnd module missing");
|
|
48
|
+
if (!ticketForm) throw new Error("renderer-ticket-modal: ticket-form module missing");
|
|
49
|
+
|
|
50
|
+
// SM-116 (Epic B / B1): the form builders + state helpers live in the shared
|
|
51
|
+
// ticket-form.js so the full-page editor (B2+) reuses them. This module is now
|
|
52
|
+
// a thin host — it pulls the builders into scope under their original names so
|
|
53
|
+
// every call site below is unchanged, and keeps only the modal-specific
|
|
54
|
+
// orchestration (showModal lifecycle, create/edit save, type-picker, starter).
|
|
55
|
+
const {
|
|
56
|
+
CONSTANTS, LOCAL_ACTOR, el, buildErrorBanner,
|
|
57
|
+
buildTitleField, buildTypeField, buildStatusField, buildPositionFields,
|
|
58
|
+
buildDescriptionField, buildChecklistSection, buildAcceptanceCriteriaSection, buildPrerequisitesSection,
|
|
59
|
+
buildStepsSection, buildLabelsField, buildTestExecutionHeader, buildExecutionMetadata,
|
|
60
|
+
buildExecutionStepsSection, buildOutcomeSection, buildLinksSection, syncFieldFromTicket,
|
|
61
|
+
pulseChangedFields, buildDraftTicket, collectFormState, buildUpdatePatch,
|
|
62
|
+
installInPlaceCommit, buildAttachmentsSection
|
|
63
|
+
} = ticketForm;
|
|
64
|
+
|
|
65
|
+
// SM-244: build the "Cancel ticket" action (or null). Cancel ≠ Delete —
|
|
66
|
+
// amber, not red; keeps the ticket visible with its history. Hidden for spec
|
|
67
|
+
// types (own lifecycle) and already-cancelled tickets. Cancelling an epic with
|
|
68
|
+
// open stories asks for confirmation (the cascade) before committing.
|
|
69
|
+
function maybeCancelAction(ctx, initial, ticketId) {
|
|
70
|
+
if (!initial) return null;
|
|
71
|
+
const isSpec = !core.isBoardWorkItem(initial.type) && initial.type !== "epic";
|
|
72
|
+
if (isSpec) return null;
|
|
73
|
+
const snap0 = ctx.store.get();
|
|
74
|
+
if (core.statusCategoryOf(snap0.project, initial.status) === "cancelled") return null;
|
|
75
|
+
// SM-244: an epic that is already DONE (its shipped work dominates the
|
|
76
|
+
// roll-up) can't be meaningfully cancelled — cancelling its (zero) open
|
|
77
|
+
// stories leaves it done. Hide the action instead of offering a no-op.
|
|
78
|
+
if (initial.type === "epic" && core.statusCategoryOf(snap0.project, initial.status) === "done") return null;
|
|
79
|
+
|
|
80
|
+
function doCancel() {
|
|
81
|
+
try {
|
|
82
|
+
ctx.store.cancelTicket(ticketId, LOCAL_ACTOR);
|
|
83
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket cancelled", { kind: "ok" });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "cancel failed", { kind: "error" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [{
|
|
90
|
+
label: "Cancel ticket",
|
|
91
|
+
className: "warn",
|
|
92
|
+
onClick: () => {
|
|
93
|
+
const fresh = ctx.store.get();
|
|
94
|
+
if (initial.type === "epic") {
|
|
95
|
+
const st = core.epicChildStats(fresh, ticketId);
|
|
96
|
+
const open = st.doing + st.todo; // non-terminal contained stories
|
|
97
|
+
// Resolve uiShell the same way openTicketModal does (ctx → window
|
|
98
|
+
// fallback). Without it, degrade gracefully to a direct cancel.
|
|
99
|
+
const shell = ctx.uiShell
|
|
100
|
+
|| (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell);
|
|
101
|
+
// SM-244: ALWAYS confirm an epic cancel (even with no contained stories)
|
|
102
|
+
// so the action gives clear feedback. The message names the cascade.
|
|
103
|
+
if (shell && typeof shell.showModal === "function") {
|
|
104
|
+
const sub = open > 0
|
|
105
|
+
? "This also cancels " + open + " open " + (open === 1 ? "story" : "stories") + " contained in the epic."
|
|
106
|
+
: "This epic has no open contained stories — only the epic is cancelled.";
|
|
107
|
+
shell.showModal({
|
|
108
|
+
title: "Cancel epic?",
|
|
109
|
+
sub: sub,
|
|
110
|
+
// SM-278: these open a NEW modal into the single #modal-host. They
|
|
111
|
+
// MUST return false so showModal does not run its post-onClick
|
|
112
|
+
// close() — which does host.innerHTML="" and would destroy the very
|
|
113
|
+
// modal we just opened (the root cause of "epic cancel does nothing").
|
|
114
|
+
actions: [
|
|
115
|
+
{ label: "Back", onClick: () => { openTicketModal(ctx, ticketId, {}); return false; } },
|
|
116
|
+
{ label: "Cancel epic", className: "warn", onClick: () => doCancel() }
|
|
117
|
+
]
|
|
118
|
+
});
|
|
119
|
+
// SM-278: return false so the outer action handler does NOT call
|
|
120
|
+
// close() and wipe the confirm modal we just opened into the host.
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
doCancel();
|
|
125
|
+
}
|
|
126
|
+
}];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Mount API ------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Open the ticket-detail modal for a ticket id. Returns { close }.
|
|
133
|
+
*
|
|
134
|
+
* The modal lifecycle:
|
|
135
|
+
* open → onMount fires → wire fields + subscribe to store
|
|
136
|
+
* close → unsubscribe + remove DOM (showModal handles DOM teardown)
|
|
137
|
+
*
|
|
138
|
+
* For Esc / click-outside / Cancel → close fn fires the showModal close.
|
|
139
|
+
* For Save → POST/PUT, await, then close on success (or stay open + show
|
|
140
|
+
* error banner on validation failure).
|
|
141
|
+
* For Delete → DELETE endpoint, await, then close.
|
|
142
|
+
*/
|
|
143
|
+
function openTicketModal(ctx, ticketId, opts) {
|
|
144
|
+
opts = opts || {};
|
|
145
|
+
if (!ctx || !ctx.store) throw new Error("openTicketModal: ctx.store missing");
|
|
146
|
+
const showModal = (ctx.uiShell && ctx.uiShell.showModal)
|
|
147
|
+
|| (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
|
|
148
|
+
if (typeof showModal !== "function") throw new Error("openTicketModal: showModal helper missing on uiShell");
|
|
149
|
+
|
|
150
|
+
// Create-Mode: ticketId === null. opts.draft = { type, releaseId, processStepId, epicId }.
|
|
151
|
+
const isCreate = (ticketId == null);
|
|
152
|
+
|
|
153
|
+
function readTicket() {
|
|
154
|
+
const snap = ctx.store.get();
|
|
155
|
+
return (snap.tickets || []).find(t => t.id === ticketId) || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const initial = isCreate
|
|
159
|
+
? buildDraftTicket((opts.draft && opts.draft.type) || "user-story", opts.draft || {})
|
|
160
|
+
: readTicket();
|
|
161
|
+
if (!initial) {
|
|
162
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket not found: " + ticketId, { kind: "error" });
|
|
163
|
+
return { close: () => {} };
|
|
164
|
+
}
|
|
165
|
+
// Effective config for this ticket type (resolves data-driven defaults).
|
|
166
|
+
const project = ctx.store.get().project || {};
|
|
167
|
+
const typeConfig = (typeof core.getEntityTypeConfig === "function")
|
|
168
|
+
? core.getEntityTypeConfig(project, initial.type)
|
|
169
|
+
: { showAcceptanceCriteria: true, showDefinitionOfReady: true, showDefinitionOfDone: true, allowParentEpic: initial.type !== "epic", showRelease: true, showProcessStep: true };
|
|
170
|
+
|
|
171
|
+
const refs = { markDirty: () => {} }; // set after onMount fires
|
|
172
|
+
const actions = isCreate
|
|
173
|
+
? [
|
|
174
|
+
{ label: "Cancel", onClick: () => {} },
|
|
175
|
+
{ label: "Create", primary: true, onClick: async (modal) => createOnSave(ctx, modal, initial, opts) }
|
|
176
|
+
]
|
|
177
|
+
: [
|
|
178
|
+
{ label: "Delete", destructive: true, onClick: async () => {
|
|
179
|
+
// SM-73: Delete used to rely on opts.onDelete being plumbed
|
|
180
|
+
// by every caller — Kanban and Dependencies didn't pass it,
|
|
181
|
+
// so the button silently no-op'd. Centralise the soft-delete
|
|
182
|
+
// here so it works regardless of how the modal was opened.
|
|
183
|
+
// opts.onDelete still wins when a caller wants a custom path.
|
|
184
|
+
try {
|
|
185
|
+
if (typeof opts.onDelete === "function") {
|
|
186
|
+
await opts.onDelete(ticketId);
|
|
187
|
+
} else if (ctx && ctx.store && typeof ctx.store.softDeleteTicket === "function") {
|
|
188
|
+
ctx.store.softDeleteTicket(ticketId, LOCAL_ACTOR);
|
|
189
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket deleted", { kind: "ok" });
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (ctx && ctx.flashStatus) ctx.flashStatus(err.message || "delete failed", { kind: "error" });
|
|
193
|
+
console.error("[ticket-modal] delete failed:", err);
|
|
194
|
+
return false; // keep the modal open so the user sees the error
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
// SM-244: Cancel ticket — a deliberate non-implementation. NOT red
|
|
199
|
+
// (Delete stays red); cancel keeps the ticket visible with history.
|
|
200
|
+
// Hidden for spec types (own lifecycle) and already-cancelled tickets.
|
|
201
|
+
...(maybeCancelAction(ctx, initial, ticketId) || []),
|
|
202
|
+
// SM-120/B5b: no Save button — every field commits in place (see
|
|
203
|
+
// installInPlaceCommit below). Just a Close action.
|
|
204
|
+
{ label: "Close", onClick: () => {} }
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const handle = showModal({
|
|
208
|
+
title: isCreate
|
|
209
|
+
? "New " + (initial.type || "ticket")
|
|
210
|
+
: (initial.ticketKey ? initial.ticketKey + " — " : "") + (initial.title || "(no title)"),
|
|
211
|
+
bodyHTML: '<div class="tm-body-host"></div>',
|
|
212
|
+
actions: actions,
|
|
213
|
+
onMount: (modal, close) => {
|
|
214
|
+
const host = modal.querySelector(".tm-body-host");
|
|
215
|
+
const snapshot = ctx.store.get();
|
|
216
|
+
const projectInner = snapshot.project || {};
|
|
217
|
+
const ticketTypes = (projectInner.ticketTypes && projectInner.ticketTypes.length)
|
|
218
|
+
? projectInner.ticketTypes : core.DEFAULT_TICKET_TYPES;
|
|
219
|
+
// SM-244: the status dropdown must reflect the PROJECT workflow (which
|
|
220
|
+
// includes cancelled), not the hardcoded 5-status default — otherwise a
|
|
221
|
+
// cancelled ticket has no matching option and falls back to "backlog",
|
|
222
|
+
// and there is no way to reopen it. Reopen = pick an earlier status
|
|
223
|
+
// (ungated reverse move); cancel = pick cancelled (gate-free transition).
|
|
224
|
+
const statuses = (typeof core.getWorkflowForType === "function")
|
|
225
|
+
? core.getWorkflowForType(projectInner, initial.type).statuses.map(s => (typeof s === "string" ? s : s.id))
|
|
226
|
+
: core.DEFAULT_STATUSES;
|
|
227
|
+
|
|
228
|
+
// SM-122/B7: turn the modal title into a link to the full-page editor
|
|
229
|
+
// (edit-mode only — the full page needs an existing ticket). Plain click
|
|
230
|
+
// navigates; right/middle-click opens a new tab — a real <a>.
|
|
231
|
+
if (!isCreate && ctx.projectId && ticketId) {
|
|
232
|
+
const h2 = modal.querySelector("h2");
|
|
233
|
+
if (h2) {
|
|
234
|
+
// SM-158: link by the human ticket-KEY (SM-140) when available, so
|
|
235
|
+
// the URL is memorable/type-able (the editor resolves key→id).
|
|
236
|
+
const keyForLink = initial.ticketKey || ticketId;
|
|
237
|
+
const href = "/editor?projectId=" + encodeURIComponent(ctx.projectId)
|
|
238
|
+
+ "&ticketId=" + encodeURIComponent(keyForLink);
|
|
239
|
+
const link = el("a", { class: "tm-fullpage-link", href: href,
|
|
240
|
+
title: "Open this ticket in the full-page editor" });
|
|
241
|
+
link.textContent = h2.textContent;
|
|
242
|
+
h2.textContent = "";
|
|
243
|
+
h2.appendChild(link);
|
|
244
|
+
h2.appendChild(el("span", { class: "tm-fullpage-icon", title: "Open full view", text: " ↗" }));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// SM-120/B5b: in-place commit controller (installed after the body is
|
|
249
|
+
// built, edit-mode only). The section refs below call inplace.markDirty
|
|
250
|
+
// so add/remove/reorder/toggle in AC/DoR/DoD/prereqs/steps auto-commits.
|
|
251
|
+
let inplace = null;
|
|
252
|
+
|
|
253
|
+
host.appendChild(buildErrorBanner());
|
|
254
|
+
host.appendChild(buildTitleField(initial));
|
|
255
|
+
host.appendChild(buildTypeField(initial, ticketTypes));
|
|
256
|
+
host.appendChild(buildStatusField(initial, statuses, snapshot));
|
|
257
|
+
for (const row of buildPositionFields(initial, snapshot, typeConfig)) host.appendChild(row);
|
|
258
|
+
// SM-157: in edit-mode the description self-commits via inline ✓/✗ (and
|
|
259
|
+
// on focus-out), so free-text edits aren't lost if the user forgets the
|
|
260
|
+
// Save button. Create-mode collects the description on Create instead.
|
|
261
|
+
host.appendChild(buildDescriptionField(initial, isCreate ? {} : {
|
|
262
|
+
onCommit: (text) => ctx.store.updateTicket(ticketId, { description: text }, LOCAL_ACTOR)
|
|
263
|
+
}));
|
|
264
|
+
// SM-57: Test-Execution header pill + metadata strip. Only relevant in
|
|
265
|
+
// edit-mode — create-mode for test-execution goes through the dedicated
|
|
266
|
+
// starter dialog (openTestExecutionStarter), not this modal.
|
|
267
|
+
if (!isCreate && initial.type === "test-execution") {
|
|
268
|
+
host.appendChild(buildTestExecutionHeader(initial, snapshot, Object.assign({}, ctx, { openTicketModal })));
|
|
269
|
+
host.appendChild(buildExecutionMetadata(initial));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// SM-54: Prereqs + Steps + AC are all type-driven and re-rendered on
|
|
273
|
+
// type change in Create-mode. Encapsulated in a single helper so a
|
|
274
|
+
// Type switch can re-evaluate visibility uniformly.
|
|
275
|
+
let prereqsDirty = false, stepsDirty = false, acDirty = false;
|
|
276
|
+
function renderTypeDrivenSections(opts) {
|
|
277
|
+
const reseed = !!(opts && opts.reseed);
|
|
278
|
+
// Strip prior renders before re-mounting.
|
|
279
|
+
host.querySelectorAll('section.tm-ac-section').forEach(n => n.remove());
|
|
280
|
+
host.querySelectorAll('section.tm-prereqs').forEach(n => n.remove());
|
|
281
|
+
host.querySelectorAll('section.tm-test-steps').forEach(n => n.remove());
|
|
282
|
+
// Re-resolve typeConfig from the CURRENT form type so the visibility
|
|
283
|
+
// decisions follow the type the user has chosen, not the initial type.
|
|
284
|
+
const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]')
|
|
285
|
+
|| { value: initial.type }).value || initial.type;
|
|
286
|
+
const liveTypeConfig = (typeof core.getEntityTypeConfig === "function")
|
|
287
|
+
? core.getEntityTypeConfig(projectInner, currentType)
|
|
288
|
+
: typeConfig;
|
|
289
|
+
// SM-119: the description is a contentEditable div now (was a textarea),
|
|
290
|
+
// so match by the sync-key attribute, not the tag.
|
|
291
|
+
const descNode = host.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="description"]');
|
|
292
|
+
const descRow = descNode ? descNode.closest(".tm-row") : null;
|
|
293
|
+
const anchor = descRow ? descRow.nextSibling : null;
|
|
294
|
+
// Anchor everything below the description, preserving append order:
|
|
295
|
+
// Prereqs (if shown) → Steps (if shown) → AC (if shown).
|
|
296
|
+
function appendBelowDesc(node) {
|
|
297
|
+
if (anchor) host.insertBefore(node, anchor);
|
|
298
|
+
else host.appendChild(node);
|
|
299
|
+
}
|
|
300
|
+
if (liveTypeConfig.showPrerequisites) {
|
|
301
|
+
const prereqRefs = { markDirty: () => { prereqsDirty = true; if (inplace) inplace.markDirty(); } };
|
|
302
|
+
// In create-mode we start with an empty list; in edit-mode use the
|
|
303
|
+
// ticket's existing prerequisites. The user fills the list in by
|
|
304
|
+
// adding rows ("+ Add Prerequisite").
|
|
305
|
+
const seedTicket = isCreate ? { prerequisites: [] } : initial;
|
|
306
|
+
appendBelowDesc(buildPrerequisitesSection(seedTicket, prereqRefs));
|
|
307
|
+
}
|
|
308
|
+
if (liveTypeConfig.showSteps) {
|
|
309
|
+
const stepRefs = { markDirty: () => { stepsDirty = true; if (inplace) inplace.markDirty(); } };
|
|
310
|
+
const seedTicket = isCreate ? { steps: [] } : initial;
|
|
311
|
+
appendBelowDesc(buildStepsSection(seedTicket, stepRefs));
|
|
312
|
+
}
|
|
313
|
+
// SM-57: Execution-step + Outcome sections for type='test-execution'.
|
|
314
|
+
// In create-mode they're not rendered — the dedicated starter dialog
|
|
315
|
+
// (openTestExecutionStarter) clones the steps + sets refDef, so this
|
|
316
|
+
// modal only sees the finished execution.
|
|
317
|
+
if (!isCreate && liveTypeConfig.showExecutionSteps) {
|
|
318
|
+
const execRefs = { markDirty: () => { if (inplace) inplace.markDirty(); } };
|
|
319
|
+
appendBelowDesc(buildExecutionStepsSection(initial, execRefs));
|
|
320
|
+
}
|
|
321
|
+
if (!isCreate && liveTypeConfig.showTestOutcome) {
|
|
322
|
+
const outcomeRefs = { markDirty: () => { if (inplace) inplace.markDirty(); } };
|
|
323
|
+
appendBelowDesc(buildOutcomeSection(initial, outcomeRefs));
|
|
324
|
+
}
|
|
325
|
+
if (liveTypeConfig.showAcceptanceCriteria) {
|
|
326
|
+
const acRefs = { markDirty: () => { acDirty = true; if (inplace) inplace.markDirty(); } };
|
|
327
|
+
appendBelowDesc(buildAcceptanceCriteriaSection(initial, acRefs));
|
|
328
|
+
}
|
|
329
|
+
if (reseed) { prereqsDirty = false; stepsDirty = false; acDirty = false; }
|
|
330
|
+
}
|
|
331
|
+
renderTypeDrivenSections({ reseed: true });
|
|
332
|
+
|
|
333
|
+
// DoR + DoD: vollwertiger per-Ticket-Editor (E19-followup) —
|
|
334
|
+
// EDIT mode → vorhandene frozen Items werden ediertbar gerendert;
|
|
335
|
+
// Save persistiert via store.updateTicket({ definitionOfReady, ... }).
|
|
336
|
+
// CREATE mode → Items werden aus `resolveDefinitions(projectInner.defs,
|
|
337
|
+
// type)` vorgeseedet, der User kann sie editieren bevor er auf
|
|
338
|
+
// Create klickt. Beim Type-Wechsel im Modal wird nur die Section
|
|
339
|
+
// reseeded, wenn der User noch nichts daran geändert hat (Flag
|
|
340
|
+
// `dorDirty`/`dodDirty`).
|
|
341
|
+
let dorDirty = false, dodDirty = false;
|
|
342
|
+
function renderDorDodSections(opts) {
|
|
343
|
+
const reseed = !!(opts && opts.reseed);
|
|
344
|
+
// Remove any prior sections (re-render on type change or first mount).
|
|
345
|
+
host.querySelectorAll('section.tm-checklist').forEach(n => n.remove());
|
|
346
|
+
// Re-resolve typeConfig from the CURRENT form type.
|
|
347
|
+
const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]') || { value: initial.type }).value || initial.type;
|
|
348
|
+
const liveTypeConfig = (typeof core.getEntityTypeConfig === "function")
|
|
349
|
+
? core.getEntityTypeConfig(projectInner, currentType)
|
|
350
|
+
: typeConfig;
|
|
351
|
+
let dorItems, dodItems;
|
|
352
|
+
if (isCreate) {
|
|
353
|
+
const resolved = (typeof core.resolveDefinitions === "function")
|
|
354
|
+
? core.resolveDefinitions(projectInner.definitions, currentType)
|
|
355
|
+
: { ready: [], done: [] };
|
|
356
|
+
dorItems = resolved.ready.map(it => Object.assign({}, it, { checked: false }));
|
|
357
|
+
dodItems = resolved.done.map(it => Object.assign({}, it, { checked: false }));
|
|
358
|
+
} else {
|
|
359
|
+
dorItems = (initial.definitionOfReady || { items: [] }).items;
|
|
360
|
+
dodItems = (initial.definitionOfDone || { items: [] }).items;
|
|
361
|
+
}
|
|
362
|
+
const dorRefs = { markDirty: () => { dorDirty = true; if (inplace) inplace.markDirty(); } };
|
|
363
|
+
const dodRefs = { markDirty: () => { dodDirty = true; if (inplace) inplace.markDirty(); } };
|
|
364
|
+
if (liveTypeConfig.showDefinitionOfReady) {
|
|
365
|
+
host.insertBefore(buildChecklistSection("Definition of Ready", dorItems, "definitionOfReady", dorRefs), labelsRow);
|
|
366
|
+
}
|
|
367
|
+
if (liveTypeConfig.showDefinitionOfDone) {
|
|
368
|
+
host.insertBefore(buildChecklistSection("Definition of Done", dodItems, "definitionOfDone", dodRefs), labelsRow);
|
|
369
|
+
}
|
|
370
|
+
if (reseed) { dorDirty = false; dodDirty = false; }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// SM-238: rebuild the Status control on a type change so it swaps between
|
|
374
|
+
// the editable <select> (work items) and the read-only derived pill +
|
|
375
|
+
// breakdown (epics). Preserves the currently displayed status value.
|
|
376
|
+
function renderStatusField() {
|
|
377
|
+
const statusEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="status"]');
|
|
378
|
+
const row = statusEl && statusEl.closest ? statusEl.closest(".tm-row") : null;
|
|
379
|
+
if (!row || !row.parentNode) return;
|
|
380
|
+
const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]') || { value: initial.type }).value || initial.type;
|
|
381
|
+
const shownStatus = (statusEl.value != null && statusEl.tagName === "SELECT")
|
|
382
|
+
? statusEl.value : (statusEl.textContent || initial.status);
|
|
383
|
+
const freshTicket = Object.assign({}, initial, { type: currentType, status: shownStatus });
|
|
384
|
+
const snap = (ctx.store && typeof ctx.store.get === "function") ? ctx.store.get() : snapshot;
|
|
385
|
+
row.parentNode.replaceChild(buildStatusField(freshTicket, statuses, snap), row);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const labelsRow = buildLabelsField(initial);
|
|
389
|
+
host.appendChild(labelsRow);
|
|
390
|
+
renderDorDodSections({ reseed: true });
|
|
391
|
+
|
|
392
|
+
// SM-48: Links-Section. Append last (after DoR/DoD + Labels).
|
|
393
|
+
// In Create-mode the ticket has no id yet, so backward-links are
|
|
394
|
+
// empty and the + Add link button is hidden — the section still
|
|
395
|
+
// renders for visual parity.
|
|
396
|
+
if (typeConfig.showLinks !== false) {
|
|
397
|
+
host.appendChild(buildLinksSection(initial, snapshot, Object.assign({}, ctx, { uiShell: ctx.uiShell })));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// SM-185: Attachments — ticket-scoped only, edit-mode only (needs a
|
|
401
|
+
// ticket id), and only when the HTTP adapter is active (attachments are
|
|
402
|
+
// a REST feature; no server → no attachments). No project-level UI.
|
|
403
|
+
if (!isCreate && ticketId && ctx.attachmentsEnabled) {
|
|
404
|
+
host.appendChild(buildAttachmentsSection(ticketId, ctx));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Type-Wechsel in Create-Mode reseedet die Items aus dem neuen
|
|
408
|
+
// Type — aber NUR wenn der User die jeweilige Liste noch nicht
|
|
409
|
+
// angefasst hat (sonst würde sein Edit verloren gehen).
|
|
410
|
+
if (isCreate) {
|
|
411
|
+
const typeEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]');
|
|
412
|
+
if (typeEl) typeEl.addEventListener("change", () => {
|
|
413
|
+
renderStatusField(); // SM-238: swap select ↔ derived pill
|
|
414
|
+
// Re-render the type-driven sections (Prereqs, Steps, AC) — but
|
|
415
|
+
// preserve any user edits via the dirty flags. Currently we only
|
|
416
|
+
// skip the full re-render when a section is dirty AND visibility
|
|
417
|
+
// hasn't changed; for simplicity, if any of them is dirty, leave
|
|
418
|
+
// the existing sections as-is.
|
|
419
|
+
if (!prereqsDirty && !stepsDirty && !acDirty) {
|
|
420
|
+
renderTypeDrivenSections({ reseed: true });
|
|
421
|
+
}
|
|
422
|
+
if (!dorDirty && !dodDirty) {
|
|
423
|
+
renderDorDodSections({ reseed: true });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Live-sync + in-place persistence: only in EDIT mode (Create collects
|
|
429
|
+
// on the Create button — no ticket-id to commit against yet).
|
|
430
|
+
if (!isCreate) {
|
|
431
|
+
// SM-120/B5b: wire in-place commit on the body host. Discrete controls
|
|
432
|
+
// commit on change, text fields on blur, sections via the refs above;
|
|
433
|
+
// status routes through the gate and a 422 lands in the error banner.
|
|
434
|
+
inplace = installInPlaceCommit(host, {
|
|
435
|
+
store: ctx.store, ticketId: ticketId, actor: LOCAL_ACTOR,
|
|
436
|
+
flashStatus: ctx.flashStatus,
|
|
437
|
+
onStatusError: (msg) => {
|
|
438
|
+
const b = host.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="_error_banner"]');
|
|
439
|
+
if (!b) return;
|
|
440
|
+
if (msg) { b.style.display = "block"; b.textContent = msg; }
|
|
441
|
+
else { b.style.display = "none"; b.textContent = ""; }
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// SM-238: edit-mode type change (e.g. story → epic) swaps the status
|
|
445
|
+
// control and re-gates DoR/DoD. Fires on the type select directly,
|
|
446
|
+
// BEFORE the host-delegated in-place commit, reading the new type from
|
|
447
|
+
// the select value (the commit then persists it via store.updateTicket).
|
|
448
|
+
const editTypeEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]');
|
|
449
|
+
if (editTypeEl) editTypeEl.addEventListener("change", () => {
|
|
450
|
+
renderStatusField();
|
|
451
|
+
renderDorDodSections();
|
|
452
|
+
});
|
|
453
|
+
// SM-5: prevTicket snapshot lets us diff sync-keyed fields per
|
|
454
|
+
// commit and pulse exactly the ones the upstream changed. Only
|
|
455
|
+
// pulses when reason === "applyRemote" (external edit); a local
|
|
456
|
+
// commit gives the user direct feedback through their own typing.
|
|
457
|
+
let prevTicket = readTicket();
|
|
458
|
+
const unsubscribe = ctx.store.subscribe((snap, reason) => {
|
|
459
|
+
const fresh = readTicket();
|
|
460
|
+
if (!fresh) return;
|
|
461
|
+
syncFieldFromTicket(modal, fresh, ctx.store.get());
|
|
462
|
+
if (reason === "applyRemote") pulseChangedFields(modal, prevTicket, fresh);
|
|
463
|
+
prevTicket = fresh;
|
|
464
|
+
});
|
|
465
|
+
observeRemoval(modal, () => { unsubscribe(); if (inplace) inplace.detach(); });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return handle;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Watch for the modal's removal from DOM. When it disappears, fire onGone
|
|
475
|
+
* (used for unsubscribing without intercepting every close-path).
|
|
476
|
+
*/
|
|
477
|
+
function observeRemoval(modalEl, onGone) {
|
|
478
|
+
if (typeof MutationObserver === "undefined") return;
|
|
479
|
+
const host = modalEl.parentNode && modalEl.parentNode.parentNode; // modal-host > overlay > modal
|
|
480
|
+
if (!host) return;
|
|
481
|
+
const mo = new MutationObserver(() => {
|
|
482
|
+
if (!host.contains(modalEl)) {
|
|
483
|
+
mo.disconnect();
|
|
484
|
+
try { onGone(); } catch (_) { /* ignore */ }
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
mo.observe(host, { childList: true, subtree: true });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// SM-120/B5b: the edit-mode Save path (editOnSave/hasNonStatusChanges/
|
|
491
|
+
// putTicket/postStatus) is gone — every field commits in place via
|
|
492
|
+
// installInPlaceCommit. Only the create-mode collect-on-Create remains.
|
|
493
|
+
|
|
494
|
+
/** Create action — POSTs a new ticket with the form's fields. */
|
|
495
|
+
async function createOnSave(ctx, modal, draftInitial, opts) {
|
|
496
|
+
const banner = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="_error_banner"]');
|
|
497
|
+
try {
|
|
498
|
+
const form = collectFormState(modal);
|
|
499
|
+
if (!form.title || !form.title.trim()) {
|
|
500
|
+
if (banner) {
|
|
501
|
+
banner.style.display = "block";
|
|
502
|
+
banner.textContent = "Title is required.";
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
const effectiveType = form.type || draftInitial.type;
|
|
507
|
+
// SM-70: Pending links live on draftInitial.links (mutated by the
|
|
508
|
+
// inline add-form). Required-target gate for test-definition is
|
|
509
|
+
// enforced HERE at create-time so the user gets immediate feedback
|
|
510
|
+
// (and never lands a half-broken ticket that can't be mark_ready'd).
|
|
511
|
+
const pendingLinks = Array.isArray(draftInitial.links) ? draftInitial.links : [];
|
|
512
|
+
if (effectiveType === "test-definition") {
|
|
513
|
+
const hasTarget = pendingLinks.some(l => l.linkTypeId === "tests");
|
|
514
|
+
if (!hasTarget) {
|
|
515
|
+
if (banner) {
|
|
516
|
+
banner.style.display = "block";
|
|
517
|
+
banner.textContent = "A test-definition must link to at least one ticket it tests. Add a 'tests' link in the Links section before saving.";
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const body = {
|
|
523
|
+
type: effectiveType,
|
|
524
|
+
title: form.title,
|
|
525
|
+
description: form.description || "",
|
|
526
|
+
position: {
|
|
527
|
+
releaseId: form.position.releaseId || draftInitial.position.releaseId || null,
|
|
528
|
+
processStepId: form.position.processStepId || draftInitial.position.processStepId || null,
|
|
529
|
+
epicId: form.position.epicId || draftInitial.position.epicId || null,
|
|
530
|
+
sortOrder: draftInitial.position.sortOrder || 0
|
|
531
|
+
},
|
|
532
|
+
acceptanceCriteria: form.acceptanceCriteria || [],
|
|
533
|
+
labels: form.labels || []
|
|
534
|
+
};
|
|
535
|
+
// SM-70: forward the pending links to the server (strip the synthetic
|
|
536
|
+
// pending id; the server normalises and assigns a real one).
|
|
537
|
+
if (pendingLinks.length) {
|
|
538
|
+
body.links = pendingLinks.map(l => ({
|
|
539
|
+
linkTypeId: l.linkTypeId,
|
|
540
|
+
targetTicketId: l.targetTicketId,
|
|
541
|
+
createdBy: LOCAL_ACTOR
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
// E19-followup: per-Ticket Items aus dem Editor an Create durchreichen.
|
|
545
|
+
// Wenn die Section gar nicht gerendert war (Gate off), bleibt der Server
|
|
546
|
+
// beim Default-Freeze (oder leeren Items).
|
|
547
|
+
if (form.definitionOfReady && Array.isArray(form.definitionOfReady.items)) {
|
|
548
|
+
body.definitionOfReady = form.definitionOfReady;
|
|
549
|
+
}
|
|
550
|
+
if (form.definitionOfDone && Array.isArray(form.definitionOfDone.items)) {
|
|
551
|
+
body.definitionOfDone = form.definitionOfDone;
|
|
552
|
+
}
|
|
553
|
+
// SM-54: pass test-definition fields through to create. `null` means
|
|
554
|
+
// the section wasn't rendered (type isn't test-definition or gate off)
|
|
555
|
+
// → server leaves the field at its empty default.
|
|
556
|
+
if (Array.isArray(form.prerequisites)) body.prerequisites = form.prerequisites;
|
|
557
|
+
if (Array.isArray(form.steps)) body.steps = form.steps;
|
|
558
|
+
await postCreateTicket(ctx, body);
|
|
559
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket created", { kind: "ok" });
|
|
560
|
+
if (typeof opts.onCreated === "function") opts.onCreated();
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error("[ticket-modal] create failed:", err);
|
|
563
|
+
if (banner) {
|
|
564
|
+
banner.style.display = "block";
|
|
565
|
+
banner.textContent = err.message || "create failed";
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// E18.D: alle Persistenz-Shims kollabieren zu einem reinen Store-Mutate.
|
|
572
|
+
// Der Save-Subscriber in main.js debounced den Snapshot-PUT an den Adapter.
|
|
573
|
+
// Der Status-Wechsel läuft über `changeStatusGated`, das die DoR/DoD-Gates
|
|
574
|
+
// VOR dem Commit prüft (snapshot-PUT validiert nicht — by design).
|
|
575
|
+
// (LOCAL_ACTOR is declared near the top of the factory body so the
|
|
576
|
+
// SM-48 Links-section helpers — which run BEFORE the Persistenz-Shims —
|
|
577
|
+
// can reference it too.)
|
|
578
|
+
|
|
579
|
+
async function postCreateTicket(ctx, body) {
|
|
580
|
+
ctx.store.createTicket(body, LOCAL_ACTOR);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Show a type-picker modal first; on selection, opens the full ticket
|
|
585
|
+
* modal in CREATE mode with the chosen type. Same modal shape for every
|
|
586
|
+
* type — the type-config layer governs which sections are shown.
|
|
587
|
+
*
|
|
588
|
+
* @param {object} ctx — same as openTicketModal
|
|
589
|
+
* @param {object} draftCtx — { releaseId?, processStepId?, epicId? } — position context
|
|
590
|
+
* @param {object} [opts] — { onCreated }
|
|
591
|
+
*/
|
|
592
|
+
function openTypePickerThenCreate(ctx, draftCtx, opts) {
|
|
593
|
+
opts = opts || {};
|
|
594
|
+
const showModal = (ctx.uiShell && ctx.uiShell.showModal)
|
|
595
|
+
|| (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
|
|
596
|
+
if (typeof showModal !== "function") throw new Error("openTypePickerThenCreate: showModal missing on uiShell");
|
|
597
|
+
const project = ctx.store.get().project || {};
|
|
598
|
+
// SM-196: the "+ Add Item" picker excludes only the SPEC types
|
|
599
|
+
// (requirement/spec-module — authored through the requirements flow). Epics
|
|
600
|
+
// ARE offerable here: they're the story-map containers, created from a cell/
|
|
601
|
+
// release/backlog like any other card. (Bug fix: isBoardWorkItem also
|
|
602
|
+
// excludes epic, which wrongly hid it from the picker.)
|
|
603
|
+
const types = ((project.ticketTypes && project.ticketTypes.length)
|
|
604
|
+
? project.ticketTypes : core.DEFAULT_TICKET_TYPES)
|
|
605
|
+
.filter(t => t === "epic" || core.isBoardWorkItem(t));
|
|
606
|
+
|
|
607
|
+
showModal({
|
|
608
|
+
title: "New ticket — pick type",
|
|
609
|
+
bodyHTML: '<div class="tm-type-picker"></div>',
|
|
610
|
+
actions: [
|
|
611
|
+
{ label: "Cancel", onClick: () => {} }
|
|
612
|
+
],
|
|
613
|
+
onMount: (modal, close) => {
|
|
614
|
+
const host = modal.querySelector(".tm-type-picker");
|
|
615
|
+
for (const type of types) {
|
|
616
|
+
const btn = el("button", { class: "tm-type-pick-btn", type: "button" });
|
|
617
|
+
btn.appendChild(el("span", { class: "tm-type-pick-name", text: type }));
|
|
618
|
+
btn.addEventListener("click", () => {
|
|
619
|
+
close();
|
|
620
|
+
// Defer to next tick so the picker DOM teardown is complete
|
|
621
|
+
// before showModal swaps in the create-modal (single modal-host).
|
|
622
|
+
Promise.resolve().then(() => {
|
|
623
|
+
// SM-71: test-execution gets a specialised starter dialog —
|
|
624
|
+
// step-cloning + the executes-link are wired via
|
|
625
|
+
// core.ops.startTestExecution, so the generic detail-modal
|
|
626
|
+
// create flow doesn't apply.
|
|
627
|
+
if (type === "test-execution") {
|
|
628
|
+
openTestExecutionStarter(ctx, draftCtx, opts);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
openTicketModal(ctx, null, {
|
|
632
|
+
// The PICKED type must win — draftCtx may carry a default type
|
|
633
|
+
// (e.g. the epic "+" passes type:"user-story") that must not
|
|
634
|
+
// override the user's explicit choice in the picker.
|
|
635
|
+
draft: Object.assign({}, draftCtx || {}, { type: type }),
|
|
636
|
+
onCreated: opts.onCreated
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
host.appendChild(btn);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* SM-71: Dedicated starter dialog for test-execution. Required field is
|
|
648
|
+
* the test-definition to clone steps from. Optional: env tag. On Start
|
|
649
|
+
* the store-op `startTestExecution` runs — that handles step-cloning,
|
|
650
|
+
* the executes-link, and ticket-counter bump. After save the new
|
|
651
|
+
* execution ticket is opened in the detail modal so the user can
|
|
652
|
+
* immediately record per-step actuals.
|
|
653
|
+
*/
|
|
654
|
+
function openTestExecutionStarter(ctx, draftCtx, opts) {
|
|
655
|
+
opts = opts || {};
|
|
656
|
+
const showModal = (ctx.uiShell && ctx.uiShell.showModal)
|
|
657
|
+
|| (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
|
|
658
|
+
if (typeof showModal !== "function") throw new Error("openTestExecutionStarter: showModal missing on uiShell");
|
|
659
|
+
|
|
660
|
+
const snap = ctx.store.get();
|
|
661
|
+
const allDefs = (snap.tickets || []).filter(t => !t.isDeleted && t.type === "test-definition");
|
|
662
|
+
|
|
663
|
+
let selectedDefId = allDefs.length ? allDefs[0].id : null;
|
|
664
|
+
|
|
665
|
+
const startAction = {
|
|
666
|
+
label: "Start", primary: true,
|
|
667
|
+
onClick: async (modal) => {
|
|
668
|
+
const banner = modal.querySelector(".tm-test-exec-banner");
|
|
669
|
+
if (!selectedDefId) {
|
|
670
|
+
if (banner) {
|
|
671
|
+
banner.style.display = "block";
|
|
672
|
+
banner.textContent = "Pick a test-definition before starting.";
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
const envInput = modal.querySelector(".tm-test-exec-env");
|
|
677
|
+
const env = envInput ? (envInput.value || "").trim() : "";
|
|
678
|
+
try {
|
|
679
|
+
ctx.store.startTestExecution(selectedDefId, { env: env || undefined }, LOCAL_ACTOR);
|
|
680
|
+
if (ctx.flashStatus) ctx.flashStatus("test-execution started", { kind: "ok" });
|
|
681
|
+
// Re-read snapshot to find the just-pushed execution ticket
|
|
682
|
+
// (highest-keyed test-execution referencing the chosen definition).
|
|
683
|
+
const after = ctx.store.get();
|
|
684
|
+
const newExec = (after.tickets || []).slice().reverse()
|
|
685
|
+
.find(t => t.type === "test-execution" && t.referencedTestDefinitionId === selectedDefId);
|
|
686
|
+
if (newExec) {
|
|
687
|
+
Promise.resolve().then(() => openTicketModal(ctx, newExec.id, {
|
|
688
|
+
onDelete: opts.onDelete
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
if (typeof opts.onCreated === "function") opts.onCreated();
|
|
692
|
+
} catch (err) {
|
|
693
|
+
if (banner) {
|
|
694
|
+
banner.style.display = "block";
|
|
695
|
+
banner.textContent = err.message || "start failed";
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
showModal({
|
|
703
|
+
title: "Start test execution",
|
|
704
|
+
bodyHTML: '<div class="tm-test-exec-host"></div>',
|
|
705
|
+
actions: [
|
|
706
|
+
{ label: "Cancel", onClick: () => {} },
|
|
707
|
+
startAction
|
|
708
|
+
],
|
|
709
|
+
onMount: (modal, close) => {
|
|
710
|
+
const host = modal.querySelector(".tm-test-exec-host");
|
|
711
|
+
const banner = el("div", { class: "tm-test-exec-banner", style: { display: "none" } });
|
|
712
|
+
host.appendChild(banner);
|
|
713
|
+
|
|
714
|
+
if (!allDefs.length) {
|
|
715
|
+
const empty = el("div", { class: "tm-test-exec-empty",
|
|
716
|
+
text: "No test-definitions exist in this project yet. Create one first, then come back here to record a run." });
|
|
717
|
+
host.appendChild(empty);
|
|
718
|
+
// Disable Start by blanking selectedDefId; the action handler bails.
|
|
719
|
+
selectedDefId = null;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Definition picker.
|
|
724
|
+
const defRow = el("div", { class: "tm-row" });
|
|
725
|
+
defRow.appendChild(el("label", { class: "tm-label", text: "Test-Definition" }));
|
|
726
|
+
const defSel = el("select", { class: "tm-test-exec-def-select" });
|
|
727
|
+
for (const def of allDefs) {
|
|
728
|
+
const optEl = el("option", { value: def.id,
|
|
729
|
+
text: (def.ticketKey || def.id) + " — " + (def.title || "") });
|
|
730
|
+
defSel.appendChild(optEl);
|
|
731
|
+
}
|
|
732
|
+
defSel.value = selectedDefId;
|
|
733
|
+
defSel.addEventListener("change", () => { selectedDefId = defSel.value; });
|
|
734
|
+
defRow.appendChild(defSel);
|
|
735
|
+
host.appendChild(defRow);
|
|
736
|
+
|
|
737
|
+
// Optional env field.
|
|
738
|
+
const envRow = el("div", { class: "tm-row" });
|
|
739
|
+
envRow.appendChild(el("label", { class: "tm-label", text: "Environment (optional)" }));
|
|
740
|
+
const envInput = el("input", { type: "text", class: "tm-test-exec-env",
|
|
741
|
+
placeholder: "e.g. staging, prod, local-chrome" });
|
|
742
|
+
envRow.appendChild(envInput);
|
|
743
|
+
host.appendChild(envRow);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
CONSTANTS,
|
|
750
|
+
openTicketModal,
|
|
751
|
+
openTypePickerThenCreate,
|
|
752
|
+
openTestExecutionStarter,
|
|
753
|
+
// exposed for testing — shared builders/state come from ticket-form,
|
|
754
|
+
// createOnSave stays here (host-only). SM-278: maybeCancelAction so the
|
|
755
|
+
// epic-confirm "return false" contract is pinned.
|
|
756
|
+
_internals: Object.assign({}, ticketForm, { createOnSave, maybeCancelAction })
|
|
757
|
+
};
|
|
758
|
+
}));
|