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,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderer-ticket-editor.js — Vollseiten-Ticket-Editor (Epic B / B2, SM-117).
|
|
3
|
+
*
|
|
4
|
+
* Bookmarkbare Seite `/editor?projectId=&ticketId=`. Lädt das Projekt in einen
|
|
5
|
+
* ProjectStore und rendert EIN Ticket type-aware mit DENSELBEN Buildern wie das
|
|
6
|
+
* Detail-Modal (frontend/js/ticket-form.js) — keine Builder-Duplikation. B2
|
|
7
|
+
* liefert Laden + type-aware Sektionen + /ws-Live-Sync; die In-Place-Persistenz
|
|
8
|
+
* (kein Save-Button) folgt in B5, daher ist `markDirty` hier noch ein No-Op.
|
|
9
|
+
*
|
|
10
|
+
* UMD-gewickelt: factory(core, ticketForm). `mount(host, store, ctx)` ist rein
|
|
11
|
+
* (jsdom-testbar); `bootstrap()` macht das I/O (pickAdapter → load → store →
|
|
12
|
+
* mount → WS) und wird von ticket-editor.html auf DOMContentLoaded gerufen.
|
|
13
|
+
*/
|
|
14
|
+
(function (root, factory) {
|
|
15
|
+
if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./ticket-form.js"));
|
|
16
|
+
else (root.STORYMAP = root.STORYMAP || {}).rendererTicketEditor = factory(
|
|
17
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
18
|
+
root.STORYMAP && root.STORYMAP.ticketForm
|
|
19
|
+
);
|
|
20
|
+
}(typeof self !== "undefined" ? self : this, function (core, ticketForm) {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
if (!core) throw new Error("renderer-ticket-editor: core module missing");
|
|
24
|
+
if (!ticketForm) throw new Error("renderer-ticket-editor: ticket-form module missing");
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
el, buildErrorBanner, buildTitleField, buildTypeField, buildStatusField,
|
|
28
|
+
buildPositionFields, buildDescriptionField, buildLabelsField,
|
|
29
|
+
buildAcceptanceCriteriaSection, buildChecklistSection,
|
|
30
|
+
buildPrerequisitesSection, buildStepsSection,
|
|
31
|
+
buildExecutionStepsSection, buildOutcomeSection,
|
|
32
|
+
buildTestExecutionHeader, buildExecutionMetadata,
|
|
33
|
+
buildLinksSection, buildAttachmentsSection, syncFieldFromTicket, LOCAL_ACTOR
|
|
34
|
+
} = ticketForm;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the editor query string into { projectId, ticketId }. Accepts a raw
|
|
38
|
+
* search string ("?a=b") or a full href. Missing keys come back as "".
|
|
39
|
+
*/
|
|
40
|
+
function parseEditorQuery(search) {
|
|
41
|
+
let qs = String(search || "");
|
|
42
|
+
const qIdx = qs.indexOf("?");
|
|
43
|
+
if (qIdx >= 0) qs = qs.slice(qIdx + 1);
|
|
44
|
+
const hIdx = qs.indexOf("#");
|
|
45
|
+
if (hIdx >= 0) qs = qs.slice(0, hIdx);
|
|
46
|
+
const params = new URLSearchParams(qs);
|
|
47
|
+
return { projectId: params.get("projectId") || "", ticketId: params.get("ticketId") || "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the effective type-config for a ticket (data-driven section
|
|
52
|
+
* visibility), with the same all-true defaults the modal uses as a fallback
|
|
53
|
+
* when core.getEntityTypeConfig isn't available.
|
|
54
|
+
*/
|
|
55
|
+
function typeConfigFor(project, type) {
|
|
56
|
+
if (typeof core.getEntityTypeConfig === "function") return core.getEntityTypeConfig(project, type);
|
|
57
|
+
return {
|
|
58
|
+
showAcceptanceCriteria: true, showDefinitionOfReady: true, showDefinitionOfDone: true,
|
|
59
|
+
allowParentEpic: type !== "epic", showRelease: true, showProcessStep: true,
|
|
60
|
+
showLinks: true
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render the ticket's fields into `host` using the shared ticket-form
|
|
66
|
+
* builders, in the same order the modal uses. Returns nothing; the caller
|
|
67
|
+
* owns `host`. `ctx` carries { projectId, store, flashStatus?, uiShell? } and
|
|
68
|
+
* is threaded into buildLinksSection (which needs store + flashStatus).
|
|
69
|
+
*
|
|
70
|
+
* Edit-mode only — the editor always opens an EXISTING ticket. Persistence is
|
|
71
|
+
* deferred to B5, so the builders get a no-op markDirty for now.
|
|
72
|
+
*/
|
|
73
|
+
function renderTicketInto(host, ticket, snapshot, ctx) {
|
|
74
|
+
const project = (snapshot && snapshot.project) || {};
|
|
75
|
+
const typeConfig = typeConfigFor(project, ticket.type);
|
|
76
|
+
const ticketTypes = (project.ticketTypes && project.ticketTypes.length)
|
|
77
|
+
? project.ticketTypes : core.DEFAULT_TICKET_TYPES;
|
|
78
|
+
// SM-244: status options from the project workflow (incl. cancelled), so a
|
|
79
|
+
// cancelled ticket shows "cancelled" and can be reopened by picking an
|
|
80
|
+
// earlier status — not the hardcoded 5-status default that hid cancelled.
|
|
81
|
+
const statuses = (typeof core.getWorkflowForType === "function")
|
|
82
|
+
? core.getWorkflowForType(project, ticket.type).statuses.map(s => (typeof s === "string" ? s : s.id))
|
|
83
|
+
: core.DEFAULT_STATUSES;
|
|
84
|
+
// SM-120: the in-place controller (installed by mount) drives section commits
|
|
85
|
+
// through this shared refs.markDirty. Set after build — the builders read the
|
|
86
|
+
// property at event time, so a later assignment takes effect.
|
|
87
|
+
const refs = ctx.refs || { markDirty: function () {} };
|
|
88
|
+
|
|
89
|
+
// SM-118: content-first two-pane layout. The error banner spans full width
|
|
90
|
+
// at the top; below it `.te-main` (the content — title hero, description,
|
|
91
|
+
// checklists/steps) takes the room and `.te-side` holds the meta fields
|
|
92
|
+
// (type/status/position/labels/links).
|
|
93
|
+
host.appendChild(buildErrorBanner());
|
|
94
|
+
const layout = el("div", { class: "te-layout" });
|
|
95
|
+
const main = el("div", { class: "te-main" });
|
|
96
|
+
const side = el("aside", { class: "te-side" });
|
|
97
|
+
layout.appendChild(main);
|
|
98
|
+
layout.appendChild(side);
|
|
99
|
+
host.appendChild(layout);
|
|
100
|
+
|
|
101
|
+
// --- Main column: title hero + description + content sections ---
|
|
102
|
+
// SM-158: multiline title hero — wraps across the full width (no clipping).
|
|
103
|
+
const titleRow = buildTitleField(ticket, { multiline: true });
|
|
104
|
+
titleRow.classList.add("te-title-row"); // CSS renders this large + label-less
|
|
105
|
+
main.appendChild(titleRow);
|
|
106
|
+
// SM-157: the description self-commits via inline ✓/✗ (and focus-out).
|
|
107
|
+
main.appendChild(buildDescriptionField(ticket, ctx.onDescriptionCommit
|
|
108
|
+
? { onCommit: ctx.onDescriptionCommit } : {}));
|
|
109
|
+
|
|
110
|
+
if (ticket.type === "test-execution") {
|
|
111
|
+
main.appendChild(buildTestExecutionHeader(ticket, snapshot, ctx));
|
|
112
|
+
main.appendChild(buildExecutionMetadata(ticket));
|
|
113
|
+
}
|
|
114
|
+
if (typeConfig.showPrerequisites) main.appendChild(buildPrerequisitesSection(ticket, refs));
|
|
115
|
+
if (typeConfig.showSteps) main.appendChild(buildStepsSection(ticket, refs));
|
|
116
|
+
if (typeConfig.showExecutionSteps) main.appendChild(buildExecutionStepsSection(ticket, refs));
|
|
117
|
+
if (typeConfig.showTestOutcome) main.appendChild(buildOutcomeSection(ticket, refs));
|
|
118
|
+
if (typeConfig.showAcceptanceCriteria) main.appendChild(buildAcceptanceCriteriaSection(ticket, refs));
|
|
119
|
+
if (typeConfig.showDefinitionOfReady) {
|
|
120
|
+
main.appendChild(buildChecklistSection("Definition of Ready",
|
|
121
|
+
(ticket.definitionOfReady || { items: [] }).items, "definitionOfReady", refs));
|
|
122
|
+
}
|
|
123
|
+
if (typeConfig.showDefinitionOfDone) {
|
|
124
|
+
main.appendChild(buildChecklistSection("Definition of Done",
|
|
125
|
+
(ticket.definitionOfDone || { items: [] }).items, "definitionOfDone", refs));
|
|
126
|
+
}
|
|
127
|
+
// SM-160: Links belong with the content (below the description), not in the
|
|
128
|
+
// compact meta sidebar — they're a list of related tickets, not a control.
|
|
129
|
+
if (typeConfig.showLinks !== false) {
|
|
130
|
+
main.appendChild(buildLinksSection(ticket, snapshot, ctx));
|
|
131
|
+
}
|
|
132
|
+
// SM-185: ticket-scoped attachments (same component as the modal). Only with
|
|
133
|
+
// the HTTP adapter (REST feature); the editor always loads an existing
|
|
134
|
+
// ticket so there's always a ticket id.
|
|
135
|
+
if (ctx.attachmentsEnabled && ctx.ticketId) {
|
|
136
|
+
main.appendChild(buildAttachmentsSection(ctx.ticketId, ctx));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Sidebar: compact meta fields only ---
|
|
140
|
+
side.appendChild(buildTypeField(ticket, ticketTypes));
|
|
141
|
+
side.appendChild(buildStatusField(ticket, statuses, snapshot));
|
|
142
|
+
for (const row of buildPositionFields(ticket, snapshot, typeConfig)) side.appendChild(row);
|
|
143
|
+
side.appendChild(buildLabelsField(ticket));
|
|
144
|
+
// SM-244: editor action row — Delete (red) + Cancel ticket (amber), parity
|
|
145
|
+
// with the modal which has both.
|
|
146
|
+
const actionsRow = el("div", { class: "te-side-actions" });
|
|
147
|
+
const delBtn = buildDeleteButton(ctx, ticket);
|
|
148
|
+
const cancelBtn = maybeCancelButton(ctx, ticket);
|
|
149
|
+
if (delBtn) actionsRow.appendChild(delBtn);
|
|
150
|
+
if (cancelBtn) actionsRow.appendChild(cancelBtn);
|
|
151
|
+
if (actionsRow.children.length) side.appendChild(actionsRow);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// SM-244: full-page editor Delete button (red, destructive) — parity with the
|
|
155
|
+
// modal. Confirms first (uiShell modal when available, else window.confirm),
|
|
156
|
+
// soft-deletes, then navigates back if possible. Delete HIDES the ticket; the
|
|
157
|
+
// amber Cancel keeps it visible (scope reduction).
|
|
158
|
+
function buildDeleteButton(ctx, ticket) {
|
|
159
|
+
if (!ticket || !ctx || !ctx.store) return null;
|
|
160
|
+
const ticketId = ctx.ticketId;
|
|
161
|
+
const btn = el("button", { class: "btn destructive te-delete-action", type: "button", text: "Delete" });
|
|
162
|
+
function doDelete() {
|
|
163
|
+
try {
|
|
164
|
+
ctx.store.softDeleteTicket(ticketId, LOCAL_ACTOR);
|
|
165
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket deleted", { kind: "ok" });
|
|
166
|
+
if (typeof window !== "undefined" && window.history && window.history.length > 1) window.history.back();
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (ctx.flashStatus) ctx.flashStatus((e && e.message) || "delete failed", { kind: "error" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
btn.addEventListener("click", () => {
|
|
172
|
+
const msg = "This hides the ticket. Use Cancel to drop scope but keep it visible.";
|
|
173
|
+
if (ctx.uiShell && typeof ctx.uiShell.showModal === "function") {
|
|
174
|
+
ctx.uiShell.showModal({
|
|
175
|
+
title: "Delete ticket?", sub: msg,
|
|
176
|
+
actions: [
|
|
177
|
+
{ label: "Back", onClick: () => {} },
|
|
178
|
+
{ label: "Delete", destructive: true, onClick: () => doDelete() }
|
|
179
|
+
]
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function"
|
|
184
|
+
&& !window.confirm("Delete this ticket? " + msg)) return;
|
|
185
|
+
doDelete();
|
|
186
|
+
});
|
|
187
|
+
return btn;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// SM-244: build the editor's "Cancel ticket" button (or null). Mirrors the
|
|
191
|
+
// modal: amber, hidden for spec types + already-cancelled tickets, asks to
|
|
192
|
+
// confirm an epic cascade (via uiShell.showModal when available, else
|
|
193
|
+
// window.confirm) before committing through store.cancelTicket.
|
|
194
|
+
function maybeCancelButton(ctx, ticket) {
|
|
195
|
+
if (!ticket || !ctx || !ctx.store) return null;
|
|
196
|
+
const isSpec = !core.isBoardWorkItem(ticket.type) && ticket.type !== "epic";
|
|
197
|
+
if (isSpec) return null;
|
|
198
|
+
const snap = ctx.store.get();
|
|
199
|
+
if (core.statusCategoryOf(snap.project, ticket.status) === "cancelled") return null;
|
|
200
|
+
// SM-244: a done epic can't be meaningfully cancelled (roll-up keeps it
|
|
201
|
+
// done) — hide the action rather than offer a no-op.
|
|
202
|
+
if (ticket.type === "epic" && core.statusCategoryOf(snap.project, ticket.status) === "done") return null;
|
|
203
|
+
const ticketId = ctx.ticketId;
|
|
204
|
+
const btn = el("button", { class: "btn warn te-cancel-action", type: "button", text: "Cancel ticket" });
|
|
205
|
+
function doCancel() {
|
|
206
|
+
try {
|
|
207
|
+
ctx.store.cancelTicket(ticketId, LOCAL_ACTOR);
|
|
208
|
+
if (ctx.flashStatus) ctx.flashStatus("ticket cancelled", { kind: "ok" });
|
|
209
|
+
// SM-244: close the full-page editor after cancelling (parity with the
|
|
210
|
+
// modal, which closes on cancel) by navigating back.
|
|
211
|
+
if (typeof window !== "undefined" && window.history && window.history.length > 1) window.history.back();
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if (ctx.flashStatus) ctx.flashStatus((e && e.message) || "cancel failed", { kind: "error" });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
btn.addEventListener("click", () => {
|
|
217
|
+
const fresh = ctx.store.get();
|
|
218
|
+
if (ticket.type === "epic") {
|
|
219
|
+
const st = core.epicChildStats(fresh, ticketId);
|
|
220
|
+
const open = st.doing + st.todo;
|
|
221
|
+
// SM-244: always confirm an epic cancel (feedback), naming the cascade.
|
|
222
|
+
const msg = open > 0
|
|
223
|
+
? "This also cancels " + open + " open " + (open === 1 ? "story" : "stories") + " contained in the epic."
|
|
224
|
+
: "This epic has no open contained stories — only the epic is cancelled.";
|
|
225
|
+
if (ctx.uiShell && typeof ctx.uiShell.showModal === "function") {
|
|
226
|
+
ctx.uiShell.showModal({
|
|
227
|
+
title: "Cancel epic?", sub: msg,
|
|
228
|
+
actions: [
|
|
229
|
+
{ label: "Back", onClick: () => {} },
|
|
230
|
+
{ label: "Cancel epic", className: "warn", onClick: () => doCancel() }
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function"
|
|
236
|
+
&& !window.confirm("Cancel this epic? " + msg)) return;
|
|
237
|
+
}
|
|
238
|
+
doCancel();
|
|
239
|
+
});
|
|
240
|
+
return btn;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Mount the editor for `ctx.ticketId` against a ProjectStore. Renders a header
|
|
245
|
+
* (ticket-key + title) + the type-aware field body, then subscribes for
|
|
246
|
+
* live-sync: every store commit re-syncs the sync-keyed fields (focus-guard
|
|
247
|
+
* inside syncFieldFromTicket prevents stomping a field the user is editing).
|
|
248
|
+
*
|
|
249
|
+
* Returns { unmount } — call it to detach the store subscription.
|
|
250
|
+
*/
|
|
251
|
+
function mount(host, store, ctx) {
|
|
252
|
+
ctx = ctx || {};
|
|
253
|
+
const ticketId = ctx.ticketId;
|
|
254
|
+
host.innerHTML = "";
|
|
255
|
+
|
|
256
|
+
function readTicket() {
|
|
257
|
+
const snap = store.get();
|
|
258
|
+
return (snap.tickets || []).find(t => t.id === ticketId) || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const root = el("div", { class: "te-root" });
|
|
262
|
+
host.appendChild(root);
|
|
263
|
+
|
|
264
|
+
const ticket = readTicket();
|
|
265
|
+
if (!ticket) {
|
|
266
|
+
root.appendChild(el("div", { class: "te-missing",
|
|
267
|
+
text: "Ticket not found: " + (ticketId || "(none)") }));
|
|
268
|
+
return { unmount: function () {} };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// SM-118: content-first — the header is a slim breadcrumb.
|
|
272
|
+
// SM-122/B7: a "← Board" link back to the board so the full-page editor
|
|
273
|
+
// isn't a dead end. The editable title is the hero in the main column.
|
|
274
|
+
const header = el("div", { class: "te-header" });
|
|
275
|
+
header.appendChild(el("a", { class: "te-back", href: "/", title: "Back to the board", text: "← Board" }));
|
|
276
|
+
header.appendChild(el("span", { class: "te-key", text: ticket.ticketKey || "" }));
|
|
277
|
+
root.appendChild(header);
|
|
278
|
+
|
|
279
|
+
const body = el("div", { class: "te-body" });
|
|
280
|
+
root.appendChild(body);
|
|
281
|
+
// SM-157: description self-commits to the store; bootstrap's save-subscriber
|
|
282
|
+
// then debounce-PUTs the snapshot so it survives a reload.
|
|
283
|
+
const onDescriptionCommit = function (text) {
|
|
284
|
+
try { store.updateTicket(ticketId, { description: text }, LOCAL_ACTOR); }
|
|
285
|
+
catch (e) { if (ctx.flashStatus) ctx.flashStatus("description save failed: " + (e && e.message || e), { kind: "error" }); }
|
|
286
|
+
};
|
|
287
|
+
// SM-120: full in-place persistence — every field commits a Store-op (no
|
|
288
|
+
// Save button). The shared `refs` is driven by installInPlaceCommit below.
|
|
289
|
+
const refs = { markDirty: function () {} };
|
|
290
|
+
renderTicketInto(body, ticket, store.get(), Object.assign({}, ctx, { store, onDescriptionCommit, refs }));
|
|
291
|
+
|
|
292
|
+
// SM-120: inline gate-error reporting reuses the error banner at the top.
|
|
293
|
+
function showStatusError(msg) {
|
|
294
|
+
const banner = body.querySelector('[data-sm-sync-key="_error_banner"]');
|
|
295
|
+
if (!banner) return;
|
|
296
|
+
if (!msg) { banner.style.display = "none"; banner.textContent = ""; return; }
|
|
297
|
+
banner.style.display = "block";
|
|
298
|
+
banner.textContent = msg;
|
|
299
|
+
}
|
|
300
|
+
const inplace = ticketForm.installInPlaceCommit(body, {
|
|
301
|
+
store: store, ticketId: ticketId, actor: LOCAL_ACTOR,
|
|
302
|
+
onStatusError: showStatusError, flashStatus: ctx.flashStatus
|
|
303
|
+
});
|
|
304
|
+
refs.markDirty = inplace.markDirty; // section edits now auto-commit
|
|
305
|
+
|
|
306
|
+
// Live-sync: re-sync fields on every commit. syncFieldFromTicket carries
|
|
307
|
+
// its own focus-guard + section rebuilds (AC / DoR / DoD / prereqs / steps
|
|
308
|
+
// / links), so an external edit (board, modal, MCP) reflects here.
|
|
309
|
+
const unsubscribe = store.subscribe(function () {
|
|
310
|
+
const fresh = readTicket();
|
|
311
|
+
if (!fresh) return;
|
|
312
|
+
syncFieldFromTicket(body, fresh, store.get());
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return { unmount: function () {
|
|
316
|
+
try { unsubscribe(); } catch (_) { /* ignore */ }
|
|
317
|
+
try { inplace.detach(); } catch (_) { /* ignore */ }
|
|
318
|
+
} };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* SM-157: debounced whole-snapshot save-subscriber (mirrors
|
|
323
|
+
* main.js#installSaveSubscriber). Persists every store commit EXCEPT
|
|
324
|
+
* applyRemote/hydrate (those came from the server — re-saving would
|
|
325
|
+
* ping-pong). Without it the editor store is local-only and edits vanish on
|
|
326
|
+
* reload.
|
|
327
|
+
*/
|
|
328
|
+
// SM-214: delegates to the shared save-pipeline module (debounce, SM-153
|
|
329
|
+
// race guard, one retry, pagehide flush via saveBeacon) — the exact same
|
|
330
|
+
// pipeline main.js uses. Keeps the editor surface DRY with the map/kanban.
|
|
331
|
+
function installSaveSubscriber(store, adapter, projectId, SM, flashStatus, win) {
|
|
332
|
+
const sp = (SM && SM.savePipeline) ||
|
|
333
|
+
(typeof require === "function" ? require("./save-pipeline.js") : null);
|
|
334
|
+
if (!sp) throw new Error("renderer-ticket-editor: save-pipeline module missing (load order)");
|
|
335
|
+
const skip = (SM && SM.store && SM.store.SKIP_PERSIST_REASONS) || null;
|
|
336
|
+
return sp.createSavePipeline({
|
|
337
|
+
store: store,
|
|
338
|
+
adapter: adapter,
|
|
339
|
+
projectId: projectId,
|
|
340
|
+
skipReasons: skip,
|
|
341
|
+
onError: function (err) {
|
|
342
|
+
if (flashStatus) flashStatus("save failed: " + ((err && err.message) || err), { kind: "error" });
|
|
343
|
+
},
|
|
344
|
+
win: win
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Page entry-point: parse the URL, pick an adapter, load the project into a
|
|
350
|
+
* ProjectStore, mount the editor, and wire /ws live-sync. Called from
|
|
351
|
+
* ticket-editor.html on DOMContentLoaded. Needs window.STORYMAP.{adapters,
|
|
352
|
+
* store, uiShell}. Resolves to the mount controller (or null on failure).
|
|
353
|
+
*/
|
|
354
|
+
async function bootstrap(opts) {
|
|
355
|
+
opts = opts || {};
|
|
356
|
+
const win = (typeof window !== "undefined") ? window : {};
|
|
357
|
+
const SM = win.STORYMAP || {};
|
|
358
|
+
const adapters = opts.adapters || SM.adapters;
|
|
359
|
+
const ProjectStore = (opts.store && opts.store.ProjectStore) || (SM.store && SM.store.ProjectStore);
|
|
360
|
+
const uiShell = opts.uiShell || SM.uiShell;
|
|
361
|
+
const hostEl = opts.host || (win.document && win.document.getElementById("editor-host"));
|
|
362
|
+
const flashStatus = (uiShell && uiShell.flashStatus) || function (m) { /* no shell */ if (win.console) win.console.log("[editor] " + m); };
|
|
363
|
+
|
|
364
|
+
const href = opts.href || (win.location && win.location.href) || "";
|
|
365
|
+
const search = opts.search || (win.location && win.location.search) || href;
|
|
366
|
+
const q = parseEditorQuery(search);
|
|
367
|
+
if (!q.projectId || !q.ticketId) {
|
|
368
|
+
if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Missing ?projectId= or ?ticketId= in the URL." }));
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const adapter = await adapters.pickAdapter({ url: href });
|
|
373
|
+
const snap = await adapter.load(q.projectId);
|
|
374
|
+
if (!snap) {
|
|
375
|
+
if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Project not found: " + q.projectId }));
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const store = new ProjectStore(snap);
|
|
379
|
+
|
|
380
|
+
// SM-158: the URL's ticketId may be the human ticket-KEY (SM-140) or the
|
|
381
|
+
// internal id (t-…) — resolve either to the internal id so links built from
|
|
382
|
+
// the key (Jira-style, memorable, type-able) work. All store-ops below use
|
|
383
|
+
// the resolved internal id.
|
|
384
|
+
const t = (snap.tickets || []).find(x => x.id === q.ticketId || x.ticketKey === q.ticketId);
|
|
385
|
+
if (!t) {
|
|
386
|
+
if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Ticket not found: " + q.ticketId }));
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const ticketId = t.id;
|
|
390
|
+
|
|
391
|
+
// SM-157: persist store commits (e.g. the description inline-confirm) back
|
|
392
|
+
// to the server via a debounced whole-snapshot PUT — same cmapper pattern
|
|
393
|
+
// as the board (main.js#installSaveSubscriber). Without this the editor's
|
|
394
|
+
// store would be a local-only copy and edits would vanish on reload.
|
|
395
|
+
installSaveSubscriber(store, adapter, q.projectId, SM, flashStatus, win);
|
|
396
|
+
|
|
397
|
+
// SM-185: attachment helpers (HTTP adapter only — REST feature), so the
|
|
398
|
+
// full-page editor shows the same ticket-scoped dropzone as the modal.
|
|
399
|
+
const attBase = function (p) { return adapter.base + "/api/projects/" + encodeURIComponent(q.projectId) + p; };
|
|
400
|
+
const attHeaders = function () { return { "X-Origin-Id": adapter.originId }; };
|
|
401
|
+
const ctx = {
|
|
402
|
+
projectId: q.projectId, ticketId: ticketId, store, uiShell, flashStatus,
|
|
403
|
+
attachmentsEnabled: !!(adapter && adapter.name === "Http"),
|
|
404
|
+
attachmentHref: function (aid) { return attBase("/attachments/" + encodeURIComponent(aid)); },
|
|
405
|
+
listAttachments: async function (tid) {
|
|
406
|
+
const res = await fetch(attBase("/attachments?ticketId=" + encodeURIComponent(tid)));
|
|
407
|
+
if (!res.ok) return [];
|
|
408
|
+
const d = await res.json(); return (d && d.attachments) || [];
|
|
409
|
+
},
|
|
410
|
+
uploadAttachment: async function (tid, file) {
|
|
411
|
+
// Send raw bytes as an ArrayBuffer for cross-fetch determinism (a File
|
|
412
|
+
// body works in browsers but not every fetch impl), matching main.js.
|
|
413
|
+
const bytes = (typeof file.arrayBuffer === "function") ? await file.arrayBuffer() : file;
|
|
414
|
+
const res = await fetch(attBase("/attachments?ticketId=" + encodeURIComponent(tid)), {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: Object.assign({ "Content-Type": file.type || "application/octet-stream", "X-Filename": encodeURIComponent(file.name) }, attHeaders()),
|
|
417
|
+
body: bytes
|
|
418
|
+
});
|
|
419
|
+
if (!res.ok) {
|
|
420
|
+
let d = null; try { d = JSON.parse(await res.text()); } catch (_) { /* non-JSON */ }
|
|
421
|
+
const e = new Error((d && d.error) || ("HTTP " + res.status)); e.statusCode = res.status; throw e;
|
|
422
|
+
}
|
|
423
|
+
return (await res.json()).attachment;
|
|
424
|
+
},
|
|
425
|
+
deleteAttachment: async function (aid) {
|
|
426
|
+
const res = await fetch(attBase("/attachments/" + encodeURIComponent(aid)), { method: "DELETE", headers: attHeaders() });
|
|
427
|
+
if (!res.ok && res.status !== 204) { const e = new Error("HTTP " + res.status); e.statusCode = res.status; throw e; }
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
const ctrl = mount(hostEl, store, ctx);
|
|
431
|
+
|
|
432
|
+
// Reflect the ticket key in the document title / tab.
|
|
433
|
+
if (win.document) win.document.title = (t.ticketKey ? t.ticketKey + " — " : "") + (t.title || "Editor") + " · Story Mapper";
|
|
434
|
+
|
|
435
|
+
// /ws live-sync: on any change, reload the snapshot and applyRemote.
|
|
436
|
+
if (typeof adapter.subscribe === "function") {
|
|
437
|
+
try {
|
|
438
|
+
await adapter.subscribe(q.projectId, async function () {
|
|
439
|
+
const fresh = await adapter.load(q.projectId);
|
|
440
|
+
if (fresh) store.applyRemote(fresh);
|
|
441
|
+
});
|
|
442
|
+
} catch (e) { if (win.console) win.console.warn("editor WS subscribe failed:", e); }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return ctrl;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
mount,
|
|
450
|
+
bootstrap,
|
|
451
|
+
parseEditorQuery,
|
|
452
|
+
// exposed for tests
|
|
453
|
+
_internals: { renderTicketInto, typeConfigFor }
|
|
454
|
+
};
|
|
455
|
+
}));
|