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,2499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storymap — bootstrap script.
|
|
3
|
+
*
|
|
4
|
+
* 1. pickAdapter() chooses Http / WindowStorage / LocalStorage / Memory.
|
|
5
|
+
* 2. Mounts the Project menu via ui-shell.mountMenuBar.
|
|
6
|
+
* 3. Loads project list, hydrates first project (or empty state).
|
|
7
|
+
* 4. Mounts the Story-Map renderer + subscribes to live-sync.
|
|
8
|
+
*
|
|
9
|
+
* All user dialogs go through ui-shell.showModal — never prompt/confirm/alert.
|
|
10
|
+
* All status feedback through ui-shell.flashStatus.
|
|
11
|
+
*/
|
|
12
|
+
(function () {
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const SM = window.STORYMAP;
|
|
16
|
+
if (!SM || !SM.core || !SM.store || !SM.adapters || !SM.uiShell || !SM.rendererStoryMap || !SM.rendererKanban || !SM.rendererDependencies || !SM.rendererTicketModal || !SM.viewSettings || !SM.viewRequirements) {
|
|
17
|
+
console.error("storymap bootstrap: missing modules", Object.keys(SM || {}));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { core, store, adapters, uiShell, rendererStoryMap, rendererKanban, rendererDependencies, rendererTicketModal, viewSettings, viewRequirements, projectIO } = SM;
|
|
22
|
+
const { ProjectStore, SKIP_PERSIST_REASONS } = store;
|
|
23
|
+
const { pickAdapter } = adapters;
|
|
24
|
+
const { showModal, flashStatus } = uiShell;
|
|
25
|
+
|
|
26
|
+
// E18.D: einheitlicher Actor für alle store-ops, die im UI gefeuert werden.
|
|
27
|
+
// MCP-Tools setzen ihren eigenen Actor im Server; hier landen nur lokale
|
|
28
|
+
// User-Klicks.
|
|
29
|
+
const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
|
|
30
|
+
|
|
31
|
+
// ---- Constants -------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const BOOTSTRAP = {
|
|
34
|
+
PROJECT_ID_PATTERN: /^[a-zA-Z0-9_-]{1,100}$/,
|
|
35
|
+
DEFAULT_TICKET_TYPE: "user-story",
|
|
36
|
+
// Defaults bei Project-Create — ein neues Projekt ist sonst nicht
|
|
37
|
+
// arbeitsfähig (keine Cell sichtbar, kein Drag-Target). Diese Defaults
|
|
38
|
+
// sind im UI-Bootstrap-Pfad eingebaut; MCP / direkter REST-Call kann
|
|
39
|
+
// sie umgehen indem er den Standard-Pfad nicht nutzt.
|
|
40
|
+
DEFAULT_RELEASE_NAME: "v1.0",
|
|
41
|
+
DEFAULT_PROCESS_STEP_NAME: "Activities",
|
|
42
|
+
// E18.C: debounce-Fenster für den Save-Subscriber. Cmapper nutzt 300 ms —
|
|
43
|
+
// genug, um eine Drag-Sequenz aus vielen Mikro-Commits zu einem Save
|
|
44
|
+
// zusammenzufassen, ohne wahrnehmbare Latenz.
|
|
45
|
+
SAVE_DEBOUNCE_MS: 300
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---- App state -------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const app = {
|
|
51
|
+
adapter: null,
|
|
52
|
+
store: null,
|
|
53
|
+
currentProjectId: null,
|
|
54
|
+
unsubscribeWs: null,
|
|
55
|
+
unmountStoryMap: null,
|
|
56
|
+
unmountKanban: null,
|
|
57
|
+
unmountDeps: null,
|
|
58
|
+
unmountSettings: null,
|
|
59
|
+
unmountRequirements: null,
|
|
60
|
+
activeView: readActiveViewFromStorage() || "storymap",
|
|
61
|
+
// SM-82: ticket filter (per-project). Hydrated from localStorage when
|
|
62
|
+
// a project loads; null means "no filter" (everything visible).
|
|
63
|
+
filter: null,
|
|
64
|
+
// SM-16: live free-text search. Highlights matches + fades non-matches
|
|
65
|
+
// across the active view (does NOT remove cards, unlike `filter`).
|
|
66
|
+
search: "",
|
|
67
|
+
// SM-191: the unified smart-bar's active JQL query result — a Set of
|
|
68
|
+
// matching ticket ids (REMOVES non-matching cards, like `filter`), or null
|
|
69
|
+
// when the bar is in plain free-text-search mode / empty.
|
|
70
|
+
queryMatch: null,
|
|
71
|
+
// SM-191: the active query string (kept so the match set can be recomputed
|
|
72
|
+
// on every commit — see the query-refresh subscriber). null = no query.
|
|
73
|
+
queryString: null
|
|
74
|
+
};
|
|
75
|
+
SM.app = app;
|
|
76
|
+
|
|
77
|
+
// ---- DOM helpers -----------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const $ = (id) => document.getElementById(id);
|
|
80
|
+
|
|
81
|
+
function readActiveViewFromStorage() {
|
|
82
|
+
try {
|
|
83
|
+
const v = (typeof localStorage !== "undefined") && localStorage.getItem("storymap-active-view");
|
|
84
|
+
// E23.B: "settings" is no longer a persistent activeView; it's an
|
|
85
|
+
// overlay over whatever view is underneath. Legacy entries map back
|
|
86
|
+
// to storymap on next load.
|
|
87
|
+
return (v === "kanban" || v === "storymap" || v === "dependencies" || v === "requirements" || v === "processSteps" || v === "table") ? v : null;
|
|
88
|
+
} catch (_) { return null; }
|
|
89
|
+
}
|
|
90
|
+
function writeActiveViewToStorage(v) {
|
|
91
|
+
try { if (typeof localStorage !== "undefined") localStorage.setItem("storymap-active-view", v); } catch (_) { /* ignore */ }
|
|
92
|
+
}
|
|
93
|
+
function readLastProjectIdFromStorage() {
|
|
94
|
+
try {
|
|
95
|
+
const v = (typeof localStorage !== "undefined") && localStorage.getItem("storymap-last-project");
|
|
96
|
+
return (typeof v === "string" && v.length > 0) ? v : null;
|
|
97
|
+
} catch (_) { return null; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// SM-82 — Per-project filter persistence.
|
|
101
|
+
function filterStorageKey(projectId) {
|
|
102
|
+
return "storymap-filter-" + projectId;
|
|
103
|
+
}
|
|
104
|
+
function readFilterFromStorage(projectId) {
|
|
105
|
+
if (!projectId) return null;
|
|
106
|
+
try {
|
|
107
|
+
if (typeof localStorage === "undefined") return null;
|
|
108
|
+
const v = localStorage.getItem(filterStorageKey(projectId));
|
|
109
|
+
if (!v) return null;
|
|
110
|
+
const parsed = JSON.parse(v);
|
|
111
|
+
// Empty filter ≈ null (no constraints active) — keeps app.filter falsy
|
|
112
|
+
// so the renderer skips the filter pipeline entirely.
|
|
113
|
+
if (!parsed.statuses && !parsed.types && !parsed.hideCompletedReleases) return null;
|
|
114
|
+
return parsed;
|
|
115
|
+
} catch (_) { return null; }
|
|
116
|
+
}
|
|
117
|
+
function writeFilterToStorage(projectId, filter) {
|
|
118
|
+
if (!projectId) return;
|
|
119
|
+
try {
|
|
120
|
+
if (typeof localStorage === "undefined") return;
|
|
121
|
+
const key = filterStorageKey(projectId);
|
|
122
|
+
if (!filter) localStorage.removeItem(key);
|
|
123
|
+
else localStorage.setItem(key, JSON.stringify(filter));
|
|
124
|
+
} catch (_) { /* ignore */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// SM-83 — Per-project release-collapse persistence. Each entry is a
|
|
128
|
+
// releaseId; presence means "row is collapsed (cells-row hidden)".
|
|
129
|
+
function releaseCollapseStorageKey(projectId) {
|
|
130
|
+
return "storymap-release-collapsed-" + projectId;
|
|
131
|
+
}
|
|
132
|
+
function readCollapsedReleasesFromStorage(projectId) {
|
|
133
|
+
const out = new Set();
|
|
134
|
+
if (!projectId) return out;
|
|
135
|
+
try {
|
|
136
|
+
if (typeof localStorage === "undefined") return out;
|
|
137
|
+
const v = localStorage.getItem(releaseCollapseStorageKey(projectId));
|
|
138
|
+
if (!v) return out;
|
|
139
|
+
const parsed = JSON.parse(v);
|
|
140
|
+
if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
|
|
141
|
+
} catch (_) { /* ignore */ }
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function writeCollapsedReleasesToStorage(projectId, set) {
|
|
145
|
+
if (!projectId) return;
|
|
146
|
+
try {
|
|
147
|
+
if (typeof localStorage === "undefined") return;
|
|
148
|
+
const key = releaseCollapseStorageKey(projectId);
|
|
149
|
+
if (!set || set.size === 0) localStorage.removeItem(key);
|
|
150
|
+
else localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
|
151
|
+
} catch (_) { /* ignore */ }
|
|
152
|
+
}
|
|
153
|
+
// SM-169 — Per-project Kanban swimlane-collapse persistence. Separate from
|
|
154
|
+
// the Map's release-collapse (SM-83) — the Kanban groups by release into
|
|
155
|
+
// swimlanes and the collapse state is its own concern (it also tracks the
|
|
156
|
+
// synthetic "no release" row). Each entry is a swimlane key (a releaseId, or
|
|
157
|
+
// the renderer's NO_RELEASE_KEY sentinel); presence means "row collapsed".
|
|
158
|
+
function kanbanCollapseStorageKey(projectId) {
|
|
159
|
+
return "storymap-kanban-collapsed-" + projectId;
|
|
160
|
+
}
|
|
161
|
+
function readKanbanCollapsedFromStorage(projectId) {
|
|
162
|
+
if (!projectId) return null;
|
|
163
|
+
try {
|
|
164
|
+
if (typeof localStorage === "undefined") return null;
|
|
165
|
+
const v = localStorage.getItem(kanbanCollapseStorageKey(projectId));
|
|
166
|
+
if (v == null) return null; // null = no stored state yet (seed defaults)
|
|
167
|
+
const parsed = JSON.parse(v);
|
|
168
|
+
const out = new Set();
|
|
169
|
+
if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
|
|
170
|
+
return out;
|
|
171
|
+
} catch (_) { return null; }
|
|
172
|
+
}
|
|
173
|
+
function writeKanbanCollapsedToStorage(projectId, set) {
|
|
174
|
+
if (!projectId) return;
|
|
175
|
+
try {
|
|
176
|
+
if (typeof localStorage === "undefined") return;
|
|
177
|
+
// Always write (even empty) so "user un-collapsed everything" is durable
|
|
178
|
+
// and we don't re-seed completed releases on the next load.
|
|
179
|
+
localStorage.setItem(kanbanCollapseStorageKey(projectId), JSON.stringify(Array.from(set || [])));
|
|
180
|
+
} catch (_) { /* ignore */ }
|
|
181
|
+
}
|
|
182
|
+
// First-view default: collapse every completed release's swimlane so a board
|
|
183
|
+
// with 100s of done tickets opens compact. The user's choice persists after.
|
|
184
|
+
function seedKanbanCollapsedDefault(snap) {
|
|
185
|
+
const out = new Set();
|
|
186
|
+
const releases = (snap && snap.releases) || [];
|
|
187
|
+
for (const r of releases) {
|
|
188
|
+
if (!r.isDeleted && r.status === "completed") out.add(r.id);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// SM-84 — Per-project epic-collapse persistence. Mirrors SM-83 for epics.
|
|
194
|
+
// Entry is an epicId; presence means "epic story-grid is hidden".
|
|
195
|
+
function epicCollapseStorageKey(projectId) {
|
|
196
|
+
return "storymap-epic-collapsed-" + projectId;
|
|
197
|
+
}
|
|
198
|
+
function readCollapsedEpicsFromStorage(projectId) {
|
|
199
|
+
const out = new Set();
|
|
200
|
+
if (!projectId) return out;
|
|
201
|
+
try {
|
|
202
|
+
if (typeof localStorage === "undefined") return out;
|
|
203
|
+
const v = localStorage.getItem(epicCollapseStorageKey(projectId));
|
|
204
|
+
if (!v) return out;
|
|
205
|
+
const parsed = JSON.parse(v);
|
|
206
|
+
if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
|
|
207
|
+
} catch (_) { /* ignore */ }
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
function writeCollapsedEpicsToStorage(projectId, set) {
|
|
211
|
+
if (!projectId) return;
|
|
212
|
+
try {
|
|
213
|
+
if (typeof localStorage === "undefined") return;
|
|
214
|
+
const key = epicCollapseStorageKey(projectId);
|
|
215
|
+
if (!set || set.size === 0) localStorage.removeItem(key);
|
|
216
|
+
else localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
|
217
|
+
} catch (_) { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
// SM-272: per-project collapsed process-step COLUMNS.
|
|
220
|
+
function columnCollapseStorageKey(projectId) {
|
|
221
|
+
return "storymap-column-collapsed-" + projectId;
|
|
222
|
+
}
|
|
223
|
+
function readCollapsedColumnsFromStorage(projectId) {
|
|
224
|
+
const out = new Set();
|
|
225
|
+
if (!projectId) return out;
|
|
226
|
+
try {
|
|
227
|
+
if (typeof localStorage === "undefined") return out;
|
|
228
|
+
const v = localStorage.getItem(columnCollapseStorageKey(projectId));
|
|
229
|
+
if (!v) return out;
|
|
230
|
+
const parsed = JSON.parse(v);
|
|
231
|
+
if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
|
|
232
|
+
} catch (_) { /* ignore */ }
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
function writeCollapsedColumnsToStorage(projectId, set) {
|
|
236
|
+
if (!projectId) return;
|
|
237
|
+
try {
|
|
238
|
+
if (typeof localStorage === "undefined") return;
|
|
239
|
+
const key = columnCollapseStorageKey(projectId);
|
|
240
|
+
if (!set || set.size === 0) localStorage.removeItem(key);
|
|
241
|
+
else localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
|
242
|
+
} catch (_) { /* ignore */ }
|
|
243
|
+
}
|
|
244
|
+
function writeLastProjectIdToStorage(id) {
|
|
245
|
+
try {
|
|
246
|
+
if (typeof localStorage === "undefined") return;
|
|
247
|
+
if (id) localStorage.setItem("storymap-last-project", id);
|
|
248
|
+
else localStorage.removeItem("storymap-last-project");
|
|
249
|
+
} catch (_) { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- HTTP request helper (delegates origin-id to adapter) ------------
|
|
253
|
+
// Direct REST endpoints used when adapter is HttpAdapter and we want a
|
|
254
|
+
// specific endpoint (e.g. POST /api/projects, POST /tickets).
|
|
255
|
+
async function httpReq(method, path, body) {
|
|
256
|
+
const res = await fetch(app.adapter.base + path, {
|
|
257
|
+
method,
|
|
258
|
+
headers: { "Content-Type": "application/json", "X-Origin-Id": app.adapter.originId },
|
|
259
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
260
|
+
});
|
|
261
|
+
let data = null;
|
|
262
|
+
const text = await res.text();
|
|
263
|
+
if (text) { try { data = JSON.parse(text); } catch (_) { data = text; } }
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const err = new Error((data && data.error) || ("HTTP " + res.status));
|
|
266
|
+
err.statusCode = res.status;
|
|
267
|
+
if (data && typeof data === "object") {
|
|
268
|
+
err.kind = data.kind; err.missing = data.missing;
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
return data;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* After a mutating HTTP call that the originating client made itself,
|
|
277
|
+
* pull a fresh snapshot and applyRemote() into the local store. The
|
|
278
|
+
* server's WS-push for this write is intentionally filtered out by the
|
|
279
|
+
* X-Origin-Id echo guard, so without this explicit pull the originating
|
|
280
|
+
* client would see no UI update for its own changes.
|
|
281
|
+
*/
|
|
282
|
+
async function reloadStore() {
|
|
283
|
+
if (!app.currentProjectId || !app.store) return;
|
|
284
|
+
try {
|
|
285
|
+
const snap = await app.adapter.load(app.currentProjectId);
|
|
286
|
+
if (snap) app.store.applyRemote(snap);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
console.warn("reloadStore failed:", e);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build the ctx object passed to the ticket-modal renderer. Wraps app
|
|
294
|
+
* state + adapter + helpers so the renderer doesn't need to know about
|
|
295
|
+
* `app`. Constructed lazily per call so it picks up the current
|
|
296
|
+
* projectId/adapter.
|
|
297
|
+
*/
|
|
298
|
+
// SM-253: the newest release (highest sortOrder) — the Story Map's top row.
|
|
299
|
+
// Used as the default release for toolbar/menu-created tickets so they land
|
|
300
|
+
// in that release's holding strip instead of vanishing (no backlog anymore).
|
|
301
|
+
function newestReleaseId() {
|
|
302
|
+
if (!app.store) return null;
|
|
303
|
+
const rels = (app.store.get().releases || []).filter(r => !r.isDeleted);
|
|
304
|
+
if (rels.length === 0) return null;
|
|
305
|
+
return rels.reduce((a, b) => ((b.sortOrder || 0) > (a.sortOrder || 0) ? b : a)).id;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildTicketModalCtx() {
|
|
309
|
+
return {
|
|
310
|
+
store: app.store,
|
|
311
|
+
adapter: app.adapter,
|
|
312
|
+
projectId: app.currentProjectId,
|
|
313
|
+
uiShell: uiShell,
|
|
314
|
+
httpReq: httpReq,
|
|
315
|
+
reloadStore: reloadStore,
|
|
316
|
+
applySnapshot: (snap) => app.store.applyRemote(snap),
|
|
317
|
+
flashStatus: flashStatus,
|
|
318
|
+
// SM-185: attachments are a REST feature — only available with the HTTP
|
|
319
|
+
// adapter. The modal hides its dropzone when this is false.
|
|
320
|
+
attachmentsEnabled: !!(app.adapter && app.adapter.name === "Http"),
|
|
321
|
+
attachmentHref: (aid) => app.adapter.base
|
|
322
|
+
+ "/api/projects/" + encodeURIComponent(app.currentProjectId)
|
|
323
|
+
+ "/attachments/" + encodeURIComponent(aid),
|
|
324
|
+
listAttachments: async (ticketId) => {
|
|
325
|
+
const data = await httpReq("GET", "/api/projects/" + encodeURIComponent(app.currentProjectId)
|
|
326
|
+
+ "/attachments?ticketId=" + encodeURIComponent(ticketId));
|
|
327
|
+
return (data && data.attachments) || [];
|
|
328
|
+
},
|
|
329
|
+
uploadAttachment: async (ticketId, file) => {
|
|
330
|
+
// SM-194: "none"/empty = project-level attachment → POST without ?ticketId
|
|
331
|
+
// (the "none" sentinel is a LIST filter only; on upload it must not become
|
|
332
|
+
// a literal ticket_id). A real ticketId scopes the upload to that ticket.
|
|
333
|
+
const scoped = ticketId != null && ticketId !== "" && ticketId !== "none";
|
|
334
|
+
const url = app.adapter.base + "/api/projects/" + encodeURIComponent(app.currentProjectId)
|
|
335
|
+
+ "/attachments" + (scoped ? "?ticketId=" + encodeURIComponent(ticketId) : "");
|
|
336
|
+
// Send the raw bytes as an ArrayBuffer rather than the File object: this
|
|
337
|
+
// is deterministic across fetch implementations (a File body is fine in
|
|
338
|
+
// browsers but not every fetch — e.g. Node's undici — serializes it).
|
|
339
|
+
const bytes = (typeof file.arrayBuffer === "function") ? await file.arrayBuffer() : file;
|
|
340
|
+
const res = await fetch(url, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
344
|
+
"X-Filename": encodeURIComponent(file.name),
|
|
345
|
+
"X-Origin-Id": app.adapter.originId
|
|
346
|
+
},
|
|
347
|
+
body: bytes
|
|
348
|
+
});
|
|
349
|
+
if (!res.ok) {
|
|
350
|
+
let d = null; try { d = JSON.parse(await res.text()); } catch (_) { /* non-JSON */ }
|
|
351
|
+
const e = new Error((d && d.error) || ("HTTP " + res.status));
|
|
352
|
+
e.statusCode = res.status;
|
|
353
|
+
throw e;
|
|
354
|
+
}
|
|
355
|
+
return (await res.json()).attachment;
|
|
356
|
+
},
|
|
357
|
+
deleteAttachment: async (aid) => httpReq("DELETE", "/api/projects/"
|
|
358
|
+
+ encodeURIComponent(app.currentProjectId) + "/attachments/" + encodeURIComponent(aid))
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- Project loading -------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Aktualisiert den Current-Project-Anchor im Header. Klick auf den Anchor
|
|
366
|
+
* öffnet den Load-Project-Dialog (cmapper-Pattern). Ohne aktives Projekt
|
|
367
|
+
* ist der Anchor leer + :empty in CSS versteckt.
|
|
368
|
+
*/
|
|
369
|
+
function updateCurrentProjectIndicator() {
|
|
370
|
+
const anchor = $("current-project");
|
|
371
|
+
if (!anchor) return;
|
|
372
|
+
if (!app.currentProjectId || !app.store) {
|
|
373
|
+
anchor.textContent = "";
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const proj = app.store.get().project || {};
|
|
377
|
+
const name = proj.name || app.currentProjectId;
|
|
378
|
+
anchor.textContent = name;
|
|
379
|
+
anchor.title = "Project: " + app.currentProjectId + " — click to load another";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function el(tag, props) {
|
|
383
|
+
const e = document.createElement(tag);
|
|
384
|
+
for (const k of Object.keys(props || {})) {
|
|
385
|
+
if (k === "text") e.textContent = props[k];
|
|
386
|
+
else e.setAttribute(k, props[k]);
|
|
387
|
+
}
|
|
388
|
+
return e;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* E18.C — Save-Subscriber (cmapper-Pattern). Jeder Store-Commit fließt
|
|
393
|
+
* (gedebounced) als kompletter Snapshot-PUT zurück an den Adapter.
|
|
394
|
+
* Skip-Reasons: `applyRemote` (Snapshot kam GERADE vom Server — re-saven
|
|
395
|
+
* würde ping-pongen) und `hydrate` (initial-load / Projektwechsel —
|
|
396
|
+
* gleicher Grund). Alle anderen Reasons (createTicket, updateProject,
|
|
397
|
+
* undo, redo, …) lösen einen Save aus.
|
|
398
|
+
*
|
|
399
|
+
* SM-214: die Mechanik (Debounce, SM-153-Race-Guard, ein Retry, pagehide-
|
|
400
|
+
* Flush via saveBeacon) lebt im geteilten Modul save-pipeline.js — gleiche
|
|
401
|
+
* Pipeline wie im Vollseiten-Editor. flushPendingSave landet auf `app`,
|
|
402
|
+
* damit E2E-Tests den Tab-Close-Pfad direkt treiben können.
|
|
403
|
+
*/
|
|
404
|
+
function installSaveSubscriber() {
|
|
405
|
+
const pipeline = window.STORYMAP.savePipeline.createSavePipeline({
|
|
406
|
+
store: app.store,
|
|
407
|
+
adapter: () => app.adapter,
|
|
408
|
+
projectId: () => app.currentProjectId,
|
|
409
|
+
skipReasons: SKIP_PERSIST_REASONS,
|
|
410
|
+
debounceMs: BOOTSTRAP.SAVE_DEBOUNCE_MS,
|
|
411
|
+
onError: (err) => {
|
|
412
|
+
console.error("[save] failed:", err);
|
|
413
|
+
flashStatus("save failed: " + (err.message || err), { kind: "error" });
|
|
414
|
+
},
|
|
415
|
+
win: window
|
|
416
|
+
});
|
|
417
|
+
app.flushPendingSave = pipeline.flushPendingSave;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function loadProject(projectId) {
|
|
421
|
+
if (app.unsubscribeWs) { app.unsubscribeWs(); app.unsubscribeWs = null; }
|
|
422
|
+
unmountActiveView();
|
|
423
|
+
const snap = await app.adapter.load(projectId);
|
|
424
|
+
if (!snap) { flashStatus("project not found: " + projectId, { kind: "error" }); return; }
|
|
425
|
+
app.currentProjectId = projectId;
|
|
426
|
+
writeLastProjectIdToStorage(projectId);
|
|
427
|
+
// SM-82: hydrate per-project filter from localStorage BEFORE mounting
|
|
428
|
+
// the view so the renderer starts with the correct filter applied.
|
|
429
|
+
app.filter = readFilterFromStorage(projectId);
|
|
430
|
+
refreshFilterBadge();
|
|
431
|
+
// SM-83: hydrate per-project release-collapse state.
|
|
432
|
+
app.collapsedReleases = readCollapsedReleasesFromStorage(projectId);
|
|
433
|
+
// SM-169: hydrate Kanban swimlane-collapse. No stored state yet → seed the
|
|
434
|
+
// default (completed releases collapsed) and persist it once.
|
|
435
|
+
{
|
|
436
|
+
const stored = readKanbanCollapsedFromStorage(projectId);
|
|
437
|
+
if (stored) {
|
|
438
|
+
app.kanbanCollapsedReleases = stored;
|
|
439
|
+
} else {
|
|
440
|
+
app.kanbanCollapsedReleases = seedKanbanCollapsedDefault(snap);
|
|
441
|
+
writeKanbanCollapsedToStorage(projectId, app.kanbanCollapsedReleases);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// SM-84: hydrate per-project epic-collapse state.
|
|
445
|
+
app.collapsedEpics = readCollapsedEpicsFromStorage(projectId);
|
|
446
|
+
app.collapsedColumns = readCollapsedColumnsFromStorage(projectId);
|
|
447
|
+
// SM-16 / SM-191: a new project starts with a clean smart-bar (no search,
|
|
448
|
+
// no active query filter).
|
|
449
|
+
resetSmartBar();
|
|
450
|
+
if (!app.store) {
|
|
451
|
+
app.store = new ProjectStore(snap);
|
|
452
|
+
installSaveSubscriber();
|
|
453
|
+
// SM-16: re-apply the search highlight after every commit (renderers
|
|
454
|
+
// re-render the host, dropping the dim/hit classes). Deferred a tick so
|
|
455
|
+
// it runs after the synchronous re-render.
|
|
456
|
+
app.store.subscribe(() => {
|
|
457
|
+
if (app.search) setTimeout(applySearchHighlight, 0);
|
|
458
|
+
});
|
|
459
|
+
// SM-191 (review fix): keep an active JQL query filter fresh. On every
|
|
460
|
+
// commit (local edit OR MCP/applyRemote) re-run the query; remount only
|
|
461
|
+
// when the match membership changed. Without this app.queryMatch goes
|
|
462
|
+
// stale and the storymap morph fast-path keeps now-non-matching cards.
|
|
463
|
+
app.store.subscribe((snap) => {
|
|
464
|
+
if (!app.queryString) return;
|
|
465
|
+
const engine = window.STORYMAP.queryEngine;
|
|
466
|
+
if (!engine) return;
|
|
467
|
+
const r = engine.queryTickets(snap, app.queryString);
|
|
468
|
+
if (r.error) return;
|
|
469
|
+
const next = new Set(r.tickets.map((t) => t.id));
|
|
470
|
+
if (setsEqual(next, app.queryMatch)) return;
|
|
471
|
+
app.queryMatch = next;
|
|
472
|
+
setSmartBarStatus(next.size + (next.size === 1 ? " match" : " matches"), "ok");
|
|
473
|
+
remountForQuery();
|
|
474
|
+
});
|
|
475
|
+
} else {
|
|
476
|
+
app.store.hydrate(snap);
|
|
477
|
+
}
|
|
478
|
+
// E22: Toolbar action buttons are gone; their disabled-state lives on
|
|
479
|
+
// the Edit menu items now via menu-item `disabled: () => ...` callbacks.
|
|
480
|
+
// The menu evaluates them lazily each time the dropdown opens, so no
|
|
481
|
+
// explicit refresh is needed here.
|
|
482
|
+
|
|
483
|
+
mountActiveView();
|
|
484
|
+
updateCurrentProjectIndicator();
|
|
485
|
+
// Keep the indicator in sync if the project name changes via MCP/WS.
|
|
486
|
+
app.store.subscribe(updateCurrentProjectIndicator);
|
|
487
|
+
flashStatus("loaded: " + projectId, { kind: "ok" });
|
|
488
|
+
|
|
489
|
+
// Live-sync if the adapter supports it.
|
|
490
|
+
if (typeof app.adapter.subscribe === "function") {
|
|
491
|
+
try {
|
|
492
|
+
app.unsubscribeWs = await app.adapter.subscribe(projectId, async () => {
|
|
493
|
+
const fresh = await app.adapter.load(projectId);
|
|
494
|
+
if (fresh) app.store.applyRemote(fresh);
|
|
495
|
+
});
|
|
496
|
+
// SM-153: close the subscribe-after-load gap — a change that landed
|
|
497
|
+
// between the initial load() and the subscription would otherwise be
|
|
498
|
+
// missed until the next event. applyRemote is a no-op on identical
|
|
499
|
+
// snapshots, so this is free when nothing changed.
|
|
500
|
+
if (app.currentProjectId === projectId) {
|
|
501
|
+
const gapFresh = await app.adapter.load(projectId);
|
|
502
|
+
if (gapFresh) app.store.applyRemote(gapFresh);
|
|
503
|
+
}
|
|
504
|
+
} catch (e) { console.warn("WS subscribe failed:", e); }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Expose for the switch-request flow + E2E re-load (re-runs per-project
|
|
508
|
+
// hydration incl. the SM-169 swimlane-collapse seed).
|
|
509
|
+
app.loadProject = loadProject;
|
|
510
|
+
|
|
511
|
+
function unmountActiveView() {
|
|
512
|
+
if (app.unmountStoryMap) { app.unmountStoryMap(); app.unmountStoryMap = null; }
|
|
513
|
+
if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
|
|
514
|
+
if (app.unmountDeps) { app.unmountDeps.unmount(); app.unmountDeps = null; }
|
|
515
|
+
if (app.unmountRequirements) { app.unmountRequirements.unmount(); app.unmountRequirements = null; }
|
|
516
|
+
if (app.unmountProcessSteps) { app.unmountProcessSteps.unmount(); app.unmountProcessSteps = null; }
|
|
517
|
+
if (app.unmountTable) { app.unmountTable.unmount(); app.unmountTable = null; }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function mountActiveView() {
|
|
521
|
+
const mapHost = $("story-map-host");
|
|
522
|
+
const kanbanHost = $("kanban-host");
|
|
523
|
+
const depsHost = $("deps-host");
|
|
524
|
+
const reqHost = $("requirements-view-host");
|
|
525
|
+
const psHost = $("process-steps-host");
|
|
526
|
+
const tableHost = $("table-host");
|
|
527
|
+
mapHost.hidden = (app.activeView !== "storymap");
|
|
528
|
+
kanbanHost.hidden = (app.activeView !== "kanban");
|
|
529
|
+
if (depsHost) depsHost.hidden = (app.activeView !== "dependencies");
|
|
530
|
+
if (reqHost) reqHost.hidden = (app.activeView !== "requirements");
|
|
531
|
+
if (psHost) psHost.hidden = (app.activeView !== "processSteps");
|
|
532
|
+
if (tableHost) tableHost.hidden = (app.activeView !== "table");
|
|
533
|
+
// SM-293: the Table view has its OWN query line — showing the global
|
|
534
|
+
// smart-bar + filter row on top of it means two competing query inputs
|
|
535
|
+
// (user finding). Hide that toolbar row while the Table view is active.
|
|
536
|
+
const actionBar = document.querySelector(".toolbar-action");
|
|
537
|
+
if (actionBar) actionBar.hidden = (app.activeView === "table");
|
|
538
|
+
if (app.activeView === "kanban") {
|
|
539
|
+
app.unmountKanban = mountKanbanView();
|
|
540
|
+
} else if (app.activeView === "dependencies") {
|
|
541
|
+
app.unmountDeps = mountDependenciesView();
|
|
542
|
+
} else if (app.activeView === "requirements") {
|
|
543
|
+
app.unmountRequirements = mountRequirementsView();
|
|
544
|
+
} else if (app.activeView === "processSteps") {
|
|
545
|
+
app.unmountProcessSteps = mountProcessStepsView();
|
|
546
|
+
} else if (app.activeView === "table") {
|
|
547
|
+
app.unmountTable = mountTableView();
|
|
548
|
+
} else {
|
|
549
|
+
app.unmountStoryMap = mountStoryMapView();
|
|
550
|
+
}
|
|
551
|
+
applySearchHighlight();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// SM-16: dim non-matching cards + ring the matches across the active view.
|
|
555
|
+
// A DOM overlay (not a re-render) so it's instant and survives nothing —
|
|
556
|
+
// re-applied after every mount + (debounced) after store commits.
|
|
557
|
+
function applySearchHighlight() {
|
|
558
|
+
const q = app.search || "";
|
|
559
|
+
const hostId = app.activeView === "kanban" ? "kanban-host"
|
|
560
|
+
: app.activeView === "dependencies" ? "deps-host"
|
|
561
|
+
: "story-map-host";
|
|
562
|
+
const host = $(hostId);
|
|
563
|
+
if (!host) return;
|
|
564
|
+
// Epic cards (`.sm-epic-card`) carry data-ticket-id too — include them so a
|
|
565
|
+
// search for an epic's key/title rings the epic, not just its stories
|
|
566
|
+
// (SM-208). Their opacity cascades to the contained story cards, so an epic
|
|
567
|
+
// is NEVER dimmed: dimming a non-matching epic would also dim a matching
|
|
568
|
+
// story nested inside it. Only the ring (`sm-search-hit`) applies to epics.
|
|
569
|
+
const cards = host.querySelectorAll(
|
|
570
|
+
".sm-story-card[data-ticket-id], .sm-epic-card[data-ticket-id]"
|
|
571
|
+
);
|
|
572
|
+
const byId = app.store ? indexTicketsById(app.store.get()) : null;
|
|
573
|
+
const matchesSearch = window.STORYMAP.filter.matchesSearch;
|
|
574
|
+
cards.forEach((card) => {
|
|
575
|
+
if (!q) {
|
|
576
|
+
card.classList.remove("sm-search-dim", "sm-search-hit");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const ticket = byId && byId.get(card.getAttribute("data-ticket-id"));
|
|
580
|
+
const hit = matchesSearch(ticket, q);
|
|
581
|
+
const isEpic = card.classList.contains("sm-epic-card");
|
|
582
|
+
card.classList.toggle("sm-search-hit", !!hit);
|
|
583
|
+
card.classList.toggle("sm-search-dim", !isEpic && !hit);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function indexTicketsById(snap) {
|
|
588
|
+
const m = new Map();
|
|
589
|
+
for (const t of (snap && snap.tickets) || []) m.set(t.id, t);
|
|
590
|
+
return m;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// SM-191 — Unified smart-bar. ONE input: plain words behave as the SM-16
|
|
594
|
+
// free-text highlight; field syntax (query.looksLikeQuery) runs a structured
|
|
595
|
+
// JQL query (query engine) that FILTERS the active view to its matches, with
|
|
596
|
+
// an inline result count + parse/semantic error. The query AND-combines with
|
|
597
|
+
// the SM-82 chip filter (both stay usable). `app.queryMatch` is the match id
|
|
598
|
+
// Set; the renderers read it via opts.matchTicket = queryMatchPredicate().
|
|
599
|
+
function queryMatchPredicate() {
|
|
600
|
+
const set = app.queryMatch;
|
|
601
|
+
return set ? (t) => set.has(t.id) : null;
|
|
602
|
+
}
|
|
603
|
+
function setSmartBarStatus(text, kind) {
|
|
604
|
+
const el = $("smartbar-status");
|
|
605
|
+
if (!el) return;
|
|
606
|
+
el.textContent = text || "";
|
|
607
|
+
el.classList.remove("smartbar-status-error", "smartbar-status-ok");
|
|
608
|
+
if (text && kind) el.classList.add("smartbar-status-" + kind);
|
|
609
|
+
}
|
|
610
|
+
function remountForQuery() {
|
|
611
|
+
if (!app.store) return;
|
|
612
|
+
// SM-282: the Table view has its OWN query line and ignores the global
|
|
613
|
+
// smart-bar filter (matchTicket) — a remount here would only wipe the
|
|
614
|
+
// user's typed table query + results for zero filtering effect.
|
|
615
|
+
if (app.activeView === "table") return;
|
|
616
|
+
unmountActiveView();
|
|
617
|
+
mountActiveView();
|
|
618
|
+
}
|
|
619
|
+
function applySmartBar(raw) {
|
|
620
|
+
const trimmed = (raw == null ? "" : String(raw)).trim();
|
|
621
|
+
const Q = window.STORYMAP.query;
|
|
622
|
+
const engine = window.STORYMAP.queryEngine;
|
|
623
|
+
const wasFiltering = !!app.queryMatch;
|
|
624
|
+
// Field-aware detection: a query starts with a KNOWN field + operator, so
|
|
625
|
+
// plain searches ("find in files", "price < 100") stay simple search.
|
|
626
|
+
const fieldKeys = (engine && engine.QUERY_FIELDS) ? engine.QUERY_FIELDS.map((f) => f.key) : null;
|
|
627
|
+
if (trimmed && Q && engine && Q.looksLikeQuery(trimmed, fieldKeys)) {
|
|
628
|
+
const r = engine.queryTickets(app.store ? app.store.get() : { tickets: [] }, trimmed);
|
|
629
|
+
if (r.error) {
|
|
630
|
+
// Non-destructive: keep whatever the view currently shows, just report.
|
|
631
|
+
setSmartBarStatus(r.error.message, "error");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// SM-293: skip the remount when the match set is unchanged — Enter fires
|
|
635
|
+
// immediately AND the pending 200ms debounce re-fires with the same
|
|
636
|
+
// value; without this guard the view would remount twice (flicker).
|
|
637
|
+
const nextMatch = new Set(r.tickets.map((t) => t.id));
|
|
638
|
+
const unchanged = app.queryString === trimmed && setsEqual(nextMatch, app.queryMatch);
|
|
639
|
+
app.search = "";
|
|
640
|
+
app.queryString = trimmed;
|
|
641
|
+
app.queryMatch = nextMatch;
|
|
642
|
+
setSmartBarStatus(r.tickets.length + (r.tickets.length === 1 ? " match" : " matches"), "ok");
|
|
643
|
+
if (!unchanged) remountForQuery();
|
|
644
|
+
applySearchHighlight(); // clear any stale highlight classes
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// Simple free-text mode (or empty input).
|
|
648
|
+
app.search = trimmed;
|
|
649
|
+
app.queryString = null;
|
|
650
|
+
setSmartBarStatus("", null);
|
|
651
|
+
if (wasFiltering) { app.queryMatch = null; remountForQuery(); }
|
|
652
|
+
applySearchHighlight();
|
|
653
|
+
}
|
|
654
|
+
function setsEqual(a, b) {
|
|
655
|
+
if (a === b) return true;
|
|
656
|
+
if (!a || !b || a.size !== b.size) return false;
|
|
657
|
+
for (const x of a) if (!b.has(x)) return false;
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
function resetSmartBar() {
|
|
661
|
+
app.search = "";
|
|
662
|
+
app.queryMatch = null;
|
|
663
|
+
app.queryString = null;
|
|
664
|
+
const input = $("ticket-search");
|
|
665
|
+
if (input) input.value = "";
|
|
666
|
+
setSmartBarStatus("", null);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Switch between Story-Map / Kanban / Dependencies without re-loading
|
|
670
|
+
* the project. Settings is an overlay (E23.B), not a view. */
|
|
671
|
+
function switchView(next) {
|
|
672
|
+
if (next !== "storymap" && next !== "kanban" && next !== "dependencies" && next !== "requirements" && next !== "processSteps" && next !== "table") return;
|
|
673
|
+
if (next === app.activeView) return;
|
|
674
|
+
unmountActiveView();
|
|
675
|
+
app.activeView = next;
|
|
676
|
+
writeActiveViewToStorage(next);
|
|
677
|
+
// SM-205: the active view is now indicated by the check-marked View-menu
|
|
678
|
+
// item (re-evaluated when the menu opens) — no toolbar buttons to update.
|
|
679
|
+
if (app.store) mountActiveView();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* E23.B — Settings overlay. Sits on top of whatever view is currently
|
|
684
|
+
* active (Map or Kanban). Close button + Esc closes it; the underlying
|
|
685
|
+
* view never unmounts.
|
|
686
|
+
*/
|
|
687
|
+
function openSettings() {
|
|
688
|
+
const host = $("settings-host");
|
|
689
|
+
if (!host) return;
|
|
690
|
+
if (!host.hidden) return; // already open
|
|
691
|
+
host.hidden = false;
|
|
692
|
+
// Reset host innerHTML so a previous close left no zombies.
|
|
693
|
+
host.innerHTML = "";
|
|
694
|
+
// Build close button + a content host for view-settings to render into.
|
|
695
|
+
const closeBtn = document.createElement("button");
|
|
696
|
+
closeBtn.className = "vs-overlay-close";
|
|
697
|
+
closeBtn.title = "Close settings (Esc)";
|
|
698
|
+
closeBtn.setAttribute("aria-label", "Close settings");
|
|
699
|
+
closeBtn.textContent = "×";
|
|
700
|
+
closeBtn.addEventListener("click", closeSettings);
|
|
701
|
+
host.appendChild(closeBtn);
|
|
702
|
+
const content = document.createElement("div");
|
|
703
|
+
content.className = "vs-overlay-content";
|
|
704
|
+
host.appendChild(content);
|
|
705
|
+
app.unmountSettings = viewSettings.mount(content, app.store, {
|
|
706
|
+
flashStatus: flashStatus
|
|
707
|
+
});
|
|
708
|
+
// Esc closes — install ONCE per open, removed on close.
|
|
709
|
+
app._settingsEscHandler = (ev) => {
|
|
710
|
+
if (ev.key === "Escape") { ev.preventDefault(); closeSettings(); }
|
|
711
|
+
};
|
|
712
|
+
document.addEventListener("keydown", app._settingsEscHandler, true);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function closeSettings() {
|
|
716
|
+
const host = $("settings-host");
|
|
717
|
+
if (!host || host.hidden) return;
|
|
718
|
+
if (app.unmountSettings) { app.unmountSettings.unmount(); app.unmountSettings = null; }
|
|
719
|
+
if (app._settingsEscHandler) {
|
|
720
|
+
document.removeEventListener("keydown", app._settingsEscHandler, true);
|
|
721
|
+
app._settingsEscHandler = null;
|
|
722
|
+
}
|
|
723
|
+
host.hidden = true;
|
|
724
|
+
host.innerHTML = "";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// SM-203 R-7: the Requirements reconciliation view — a FULL-PAGE view (like
|
|
728
|
+
// Map / Kanban / Dependencies), not an overlay. Source ingestion (attachment
|
|
729
|
+
// → Markdown) runs server-side, so the source pane only fills in HTTP mode.
|
|
730
|
+
// Requirement statements are contentEditable and mirror straight into their
|
|
731
|
+
// requirement ticket via store.updateTicket.
|
|
732
|
+
function mountRequirementsView() {
|
|
733
|
+
const host = $("requirements-view-host");
|
|
734
|
+
const fetchIngest = (app.adapter && app.adapter.name === "Http")
|
|
735
|
+
? (aid) => httpReq("GET", "/api/projects/" + encodeURIComponent(app.currentProjectId)
|
|
736
|
+
+ "/attachments/" + encodeURIComponent(aid) + "/ingest")
|
|
737
|
+
: null;
|
|
738
|
+
return viewRequirements.mount(host, app.store, {
|
|
739
|
+
flashStatus: flashStatus,
|
|
740
|
+
fetchIngest: fetchIngest,
|
|
741
|
+
// Click a traceability chip → open that ticket.
|
|
742
|
+
onOpenTicket: (id) => rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {}),
|
|
743
|
+
// Orphan/suspect requirement → spin up an implementing story + realises link.
|
|
744
|
+
// The create + link are two commits; if the link throws (validateLink),
|
|
745
|
+
// catch it so we don't leave an orphan ticket + an uncaught DOM-handler
|
|
746
|
+
// exception (review [major]).
|
|
747
|
+
onCreateImplementer: (req) => {
|
|
748
|
+
try {
|
|
749
|
+
const snap = app.store.createTicket({ type: "user-story", title: "Implement: " + req.title }, LOCAL_ACTOR);
|
|
750
|
+
const story = snap.tickets[snap.tickets.length - 1];
|
|
751
|
+
app.store.addLink(story.id, { linkTypeId: "realises", targetTicketId: req.id }, LOCAL_ACTOR);
|
|
752
|
+
flashStatus("Created " + story.ticketKey + " → realises " + req.ticketKey, { kind: "ok" });
|
|
753
|
+
} catch (e) { flashStatus(e.message || "create failed", { kind: "error" }); }
|
|
754
|
+
},
|
|
755
|
+
// Gap source section → create a requirement from it, contained in the module.
|
|
756
|
+
onCreateRequirement: (moduleId, sec) => {
|
|
757
|
+
try {
|
|
758
|
+
const snap = app.store.createTicket({
|
|
759
|
+
type: "requirement", title: sec.heading || sec.body || "Requirement",
|
|
760
|
+
sectionPath: sec.sectionPath, sourceAnchor: { sectionId: sec.sectionPath }
|
|
761
|
+
}, LOCAL_ACTOR);
|
|
762
|
+
const req = snap.tickets[snap.tickets.length - 1];
|
|
763
|
+
app.store.addLink(moduleId, { linkTypeId: "contains", targetTicketId: req.id }, LOCAL_ACTOR);
|
|
764
|
+
flashStatus("Created requirement " + req.ticketKey, { kind: "ok" });
|
|
765
|
+
} catch (e) { flashStatus(e.message || "create failed", { kind: "error" }); }
|
|
766
|
+
},
|
|
767
|
+
// Inline edit of the requirement statement → mirror into its ticket title.
|
|
768
|
+
onEditRequirement: (reqId, text) => {
|
|
769
|
+
try {
|
|
770
|
+
const t = (app.store.get().tickets || []).find(x => x.id === reqId);
|
|
771
|
+
if (!t || t.title === text) return;
|
|
772
|
+
app.store.updateTicket(reqId, { title: text }, LOCAL_ACTOR);
|
|
773
|
+
flashStatus("Updated " + (t.ticketKey || "requirement"), { kind: "ok" });
|
|
774
|
+
} catch (e) { flashStatus(e.message || "update failed", { kind: "error" }); }
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// SM-282 — query-driven table over ALL tickets (incl. epics + unassigned).
|
|
780
|
+
// Soft dependency (progressive enhancement, same pattern as projectIO).
|
|
781
|
+
function mountTableView() {
|
|
782
|
+
const viewTable = SM.viewTable;
|
|
783
|
+
if (!viewTable) return { unmount() {} };
|
|
784
|
+
return viewTable.mount($("table-host"), app.store, {
|
|
785
|
+
onTicketClick: (id) => {
|
|
786
|
+
rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
|
|
787
|
+
},
|
|
788
|
+
onImport: () => openImportDialog() // SM-289/295: second entry point
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// SM-289/295 — THE unified import dialog (project envelope OR ticket rows).
|
|
793
|
+
function openImportDialog() {
|
|
794
|
+
const dlg = SM.dialogTicketImport;
|
|
795
|
+
if (!dlg) return;
|
|
796
|
+
dlg.openImportDialog({
|
|
797
|
+
store: app.store, // may be null — project import works without one
|
|
798
|
+
showModal: showModal,
|
|
799
|
+
flashStatus: flashStatus,
|
|
800
|
+
actor: LOCAL_ACTOR,
|
|
801
|
+
listProjects: () => app.adapter.list(),
|
|
802
|
+
onImportProject: (snap, targetId, overwrite) => importSnapshot(snap, targetId, overwrite)
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// SM-295 — THE unified export chooser (project JSON / tickets CSV).
|
|
807
|
+
function openExportChooser() {
|
|
808
|
+
const dlg = SM.dialogTicketImport;
|
|
809
|
+
if (!dlg || !app.store) return;
|
|
810
|
+
dlg.openExportDialog({
|
|
811
|
+
showModal: showModal,
|
|
812
|
+
onExportProject: () => exportCurrentProject(),
|
|
813
|
+
onExportTickets: () => {
|
|
814
|
+
// Current table state when mounted, else persisted config (SM-292).
|
|
815
|
+
if (app.unmountTable && app.unmountTable.exportCsv) { app.unmountTable.exportCsv(); return; }
|
|
816
|
+
if (SM.viewTable) SM.viewTable.exportTicketsCsv(app.store, app.currentProjectId);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function mountKanbanView() {
|
|
822
|
+
return rendererKanban.mount($("kanban-host"), app.store, {
|
|
823
|
+
filter: app.filter,
|
|
824
|
+
matchTicket: queryMatchPredicate(), // SM-191 smart-bar query filter
|
|
825
|
+
collapsedReleases: app.kanbanCollapsedReleases || new Set(),
|
|
826
|
+
onSwimlaneCollapse: (swimlaneKey) => {
|
|
827
|
+
if (!app.kanbanCollapsedReleases) app.kanbanCollapsedReleases = new Set();
|
|
828
|
+
if (app.kanbanCollapsedReleases.has(swimlaneKey)) app.kanbanCollapsedReleases.delete(swimlaneKey);
|
|
829
|
+
else app.kanbanCollapsedReleases.add(swimlaneKey);
|
|
830
|
+
writeKanbanCollapsedToStorage(app.currentProjectId, app.kanbanCollapsedReleases);
|
|
831
|
+
if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
|
|
832
|
+
app.unmountKanban = mountKanbanView();
|
|
833
|
+
},
|
|
834
|
+
onReleaseClick: (id) => openEditReleaseDialog(id),
|
|
835
|
+
onTicketClick: (id) => {
|
|
836
|
+
rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
|
|
837
|
+
},
|
|
838
|
+
onAddItem: (laneStatus) => {
|
|
839
|
+
// Type-picker → Create-Modal mit dem Lane-Status vor-konfiguriert.
|
|
840
|
+
// Position bleibt null (Orphan-Ticket im Backlog mit gewähltem Status).
|
|
841
|
+
rendererTicketModal.openTypePickerThenCreate(buildTicketModalCtx(), { status: laneStatus });
|
|
842
|
+
},
|
|
843
|
+
onTicketStatusChange: (ticketId, newStatus) => {
|
|
844
|
+
try {
|
|
845
|
+
app.store.changeStatusGated(ticketId, newStatus, LOCAL_ACTOR);
|
|
846
|
+
flashStatus("status: " + newStatus, { kind: "ok" });
|
|
847
|
+
} catch (err) {
|
|
848
|
+
if (err.kind && err.missing) {
|
|
849
|
+
flashStatus(err.kind + " gate blocks transition: missing " + err.missing.map(m => m.label || m.id).join(", "), { kind: "error" });
|
|
850
|
+
} else {
|
|
851
|
+
flashStatus(err.message || "status change failed", { kind: "error" });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
onLaneReorder: (_laneStatus, orderedIds) => {
|
|
856
|
+
// Kanban-Reorder: NO scope — only sortOrder gets updated globally.
|
|
857
|
+
// Story-Map within an epic uses the SAME sortOrder field, so this
|
|
858
|
+
// reorder also affects the visible story order in that epic
|
|
859
|
+
// (intentional: Kanban is the operational priority view).
|
|
860
|
+
try {
|
|
861
|
+
app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
flashStatus(err.message || "reorder failed", { kind: "error" });
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
onCardContextMenu: (ticket, ev) => openCardContextMenu(ticket, ev)
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* SM-50 — Dependencies view. Read-only DAG of all tickets + typed links.
|
|
872
|
+
* Single integration point: click-on-node opens the ticket detail modal,
|
|
873
|
+
* same as Story-Map and Kanban. Filters live inside the renderer.
|
|
874
|
+
*/
|
|
875
|
+
function mountProcessStepsView() {
|
|
876
|
+
// SM-248/249: the Process-Step editor — journey maintenance as its own view.
|
|
877
|
+
return window.STORYMAP.viewProcessSteps.mount($("process-steps-host"), app.store, {
|
|
878
|
+
flashStatus: flashStatus,
|
|
879
|
+
// SM-248/256: double-click opens the SAME edit dialog the story-map
|
|
880
|
+
// backbone header uses (rename / status / delete) — DRY, no parallel UI.
|
|
881
|
+
onEditProcessStep: (id) => openEditProcessStepDialog(id)
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function mountDependenciesView() {
|
|
886
|
+
return rendererDependencies.mount($("deps-host"), app.store, {
|
|
887
|
+
// SM-163: the board "Filter N" popover (status/type/hide-completed) now
|
|
888
|
+
// applies to the dependency view too. main.js remounts the active view on
|
|
889
|
+
// filter change, so this is fresh each time.
|
|
890
|
+
boardFilter: app.filter,
|
|
891
|
+
onTicketClick: (id) => {
|
|
892
|
+
rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* SM-30 — promote / demote popover. Right-click on a Kanban card opens
|
|
899
|
+
* a small menu with four pile-stack actions:
|
|
900
|
+
* • Top of Backlog / End of Backlog — force `status: backlog` AND park
|
|
901
|
+
* the ticket at the start/end of the backlog lane.
|
|
902
|
+
* • Top of Lane / End of Lane — keep current status, shuffle to
|
|
903
|
+
* the start/end of the current status's lane.
|
|
904
|
+
*
|
|
905
|
+
* All four actions go through `store.reorderTickets(orderedIds, null)`
|
|
906
|
+
* (and `store.changeStatus` for the backlog-forcing variants), so undo /
|
|
907
|
+
* redo + the WS-sync round-trip work identically to drag-drop.
|
|
908
|
+
*/
|
|
909
|
+
function openCardContextMenu(ticket, ev) {
|
|
910
|
+
const items = [
|
|
911
|
+
{ label: "Top of Backlog", onClick: () => moveToBacklog(ticket.id, "top") },
|
|
912
|
+
{ label: "End of Backlog", onClick: () => moveToBacklog(ticket.id, "end") },
|
|
913
|
+
{ label: "Top of Lane", onClick: () => moveWithinLane(ticket.id, "top") },
|
|
914
|
+
{ label: "End of Lane", onClick: () => moveWithinLane(ticket.id, "end") }
|
|
915
|
+
];
|
|
916
|
+
uiShell.showContextMenu({
|
|
917
|
+
clientX: ev.clientX,
|
|
918
|
+
clientY: ev.clientY,
|
|
919
|
+
items
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function moveToBacklog(ticketId, where) {
|
|
924
|
+
try {
|
|
925
|
+
const snap = app.store.get();
|
|
926
|
+
const t = snap.tickets.find(x => x.id === ticketId);
|
|
927
|
+
if (!t) return;
|
|
928
|
+
// Bring it back to the backlog status first. Reverse transitions are
|
|
929
|
+
// ungated in the default workflow (E18.D); a custom workflow that
|
|
930
|
+
// refuses the move surfaces the gate error here.
|
|
931
|
+
if (t.status !== "backlog") {
|
|
932
|
+
try { app.store.changeStatusGated(ticketId, "backlog", LOCAL_ACTOR); }
|
|
933
|
+
catch (err) {
|
|
934
|
+
const msg = err.kind && err.missing
|
|
935
|
+
? err.kind + " gate blocks → backlog: " + err.missing.map(m => m.label || m.id).join(", ")
|
|
936
|
+
: (err.message || "status change failed");
|
|
937
|
+
flashStatus(msg, { kind: "error" });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// Re-read after the (possible) status change, then assemble peers.
|
|
942
|
+
const fresh = app.store.get();
|
|
943
|
+
const peers = fresh.tickets
|
|
944
|
+
.filter(x => !x.isDeleted && x.type !== "epic" && x.status === "backlog" && x.id !== ticketId)
|
|
945
|
+
.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
|
|
946
|
+
- ((b.position && b.position.sortOrder) || 0));
|
|
947
|
+
const orderedIds = where === "top"
|
|
948
|
+
? [ticketId, ...peers.map(p => p.id)]
|
|
949
|
+
: [...peers.map(p => p.id), ticketId];
|
|
950
|
+
app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
|
|
951
|
+
flashStatus("moved to " + where + " of backlog", { kind: "ok" });
|
|
952
|
+
} catch (err) {
|
|
953
|
+
flashStatus(err.message || "move failed", { kind: "error" });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function moveWithinLane(ticketId, where) {
|
|
958
|
+
try {
|
|
959
|
+
const snap = app.store.get();
|
|
960
|
+
const t = snap.tickets.find(x => x.id === ticketId);
|
|
961
|
+
if (!t) return;
|
|
962
|
+
const peers = snap.tickets
|
|
963
|
+
.filter(x => !x.isDeleted && x.type !== "epic" && x.status === t.status && x.id !== ticketId)
|
|
964
|
+
.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
|
|
965
|
+
- ((b.position && b.position.sortOrder) || 0));
|
|
966
|
+
const orderedIds = where === "top"
|
|
967
|
+
? [ticketId, ...peers.map(p => p.id)]
|
|
968
|
+
: [...peers.map(p => p.id), ticketId];
|
|
969
|
+
app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
|
|
970
|
+
flashStatus("moved to " + where + " of lane", { kind: "ok" });
|
|
971
|
+
} catch (err) {
|
|
972
|
+
flashStatus(err.message || "move failed", { kind: "error" });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* SM-83 — Animate a release's cells-row collapse / expand.
|
|
978
|
+
*
|
|
979
|
+
* Canonical "animate height:auto" trick: max-height transitions only
|
|
980
|
+
* work between two known values, so we measure scrollHeight, fix the
|
|
981
|
+
* start value, force a reflow, then set the target value. On
|
|
982
|
+
* transitionend the inline max-height is cleared so future content
|
|
983
|
+
* growth (new tickets, etc.) isn't capped by the animation value.
|
|
984
|
+
*
|
|
985
|
+
* The cells-row stays in the DOM throughout (good for animation +
|
|
986
|
+
* keeps DnD listeners attached). The chevron icon + aria-expanded and
|
|
987
|
+
* the label-row's compact class are flipped synchronously so the
|
|
988
|
+
* label is in its target visual state from the first frame.
|
|
989
|
+
*/
|
|
990
|
+
function animateReleaseCollapse(releaseId, willCollapse) {
|
|
991
|
+
const cells = document.querySelector('.sm-release-cells[data-release-id="' + releaseId + '"]');
|
|
992
|
+
const labelRow = document.querySelector('.sm-release-label-row[data-release-id="' + releaseId + '"]');
|
|
993
|
+
if (labelRow) labelRow.classList.toggle("sm-release-collapsed", willCollapse);
|
|
994
|
+
if (labelRow) {
|
|
995
|
+
const ch = labelRow.querySelector(".sm-release-chevron");
|
|
996
|
+
if (ch) {
|
|
997
|
+
ch.textContent = willCollapse ? "▶" : "▼";
|
|
998
|
+
ch.setAttribute("aria-expanded", willCollapse ? "false" : "true");
|
|
999
|
+
ch.setAttribute("title", willCollapse ? "Expand release" : "Collapse release");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (!cells) return;
|
|
1003
|
+
// Token guards against stale transitionend listeners — if the user
|
|
1004
|
+
// toggles again before the animation finishes, the previous
|
|
1005
|
+
// listener must NOT clobber the current inline max-height.
|
|
1006
|
+
const token = (cells._collapseToken = (cells._collapseToken || 0) + 1);
|
|
1007
|
+
if (willCollapse) {
|
|
1008
|
+
// Collapse: animate from natural height down to 0.
|
|
1009
|
+
const h = cells.scrollHeight;
|
|
1010
|
+
cells.style.maxHeight = h + "px";
|
|
1011
|
+
// Force reflow so the browser commits the start value before we
|
|
1012
|
+
// change it to 0 (otherwise the two updates batch into one paint
|
|
1013
|
+
// and the transition is skipped entirely — common Safari pitfall).
|
|
1014
|
+
void cells.offsetHeight;
|
|
1015
|
+
cells.classList.add("sm-release-collapsed-cells");
|
|
1016
|
+
// Schedule the target on the next frame so the browser definitely
|
|
1017
|
+
// sees a start state distinct from the end state.
|
|
1018
|
+
requestAnimationFrame(() => {
|
|
1019
|
+
if (cells._collapseToken !== token) return;
|
|
1020
|
+
cells.style.maxHeight = "0px";
|
|
1021
|
+
});
|
|
1022
|
+
} else {
|
|
1023
|
+
// Expand: animate from 0 → natural height. Remove the class FIRST
|
|
1024
|
+
// so opacity/padding/border transition back in alongside max-height.
|
|
1025
|
+
cells.classList.remove("sm-release-collapsed-cells");
|
|
1026
|
+
// Make sure we start at 0 (the inline value persisted from the
|
|
1027
|
+
// collapse animation, or the renderer's initial-collapsed mark).
|
|
1028
|
+
cells.style.maxHeight = "0px";
|
|
1029
|
+
void cells.offsetHeight;
|
|
1030
|
+
const target = cells.scrollHeight;
|
|
1031
|
+
requestAnimationFrame(() => {
|
|
1032
|
+
if (cells._collapseToken !== token) return;
|
|
1033
|
+
cells.style.maxHeight = target + "px";
|
|
1034
|
+
});
|
|
1035
|
+
const onEnd = (ev) => {
|
|
1036
|
+
if (ev.propertyName !== "max-height") return;
|
|
1037
|
+
if (cells._collapseToken !== token) return;
|
|
1038
|
+
// Clear inline max-height so future content growth (new cards
|
|
1039
|
+
// added later) isn't clipped at the post-animation value.
|
|
1040
|
+
cells.style.maxHeight = "";
|
|
1041
|
+
cells.removeEventListener("transitionend", onEnd);
|
|
1042
|
+
};
|
|
1043
|
+
cells.addEventListener("transitionend", onEnd);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* SM-84 — Animate an epic's story-grid collapse / expand.
|
|
1049
|
+
*
|
|
1050
|
+
* Mirrors animateReleaseCollapse but targets the .sm-stories-grid inside
|
|
1051
|
+
* the .sm-epic-card. Same scrollHeight → 0 / 0 → scrollHeight trick with
|
|
1052
|
+
* a token to guard against rapid re-toggles, and the same transitionend
|
|
1053
|
+
* cleanup so future content growth (new stories added) doesn't get
|
|
1054
|
+
* clipped at the post-animation value.
|
|
1055
|
+
*
|
|
1056
|
+
* Why scope to a per-card lookup rather than a single selector: an epic
|
|
1057
|
+
* can appear in the DOM multiple times when filters / live-drag shadows
|
|
1058
|
+
* are active; the data-ticket-id selector grabs the real (non-shadow)
|
|
1059
|
+
* card only. Shadow cards intentionally render uncollapsed and have no
|
|
1060
|
+
* chevron — see renderEpicCard's isShadow guard.
|
|
1061
|
+
*/
|
|
1062
|
+
function animateEpicCollapse(epicId, willCollapse) {
|
|
1063
|
+
const card = document.querySelector('.sm-epic-card[data-ticket-id="' + epicId + '"]:not(.sm-card-shadow)');
|
|
1064
|
+
if (!card) return;
|
|
1065
|
+
card.classList.toggle("sm-epic-card-collapsed", willCollapse);
|
|
1066
|
+
const chevron = card.querySelector(".sm-epic-chevron");
|
|
1067
|
+
if (chevron) {
|
|
1068
|
+
chevron.textContent = willCollapse ? "▶" : "▼";
|
|
1069
|
+
chevron.setAttribute("aria-expanded", willCollapse ? "false" : "true");
|
|
1070
|
+
chevron.setAttribute("title", willCollapse ? "Expand epic" : "Collapse epic");
|
|
1071
|
+
}
|
|
1072
|
+
const grid = card.querySelector(".sm-stories-grid");
|
|
1073
|
+
if (!grid) return;
|
|
1074
|
+
const token = (grid._collapseToken = (grid._collapseToken || 0) + 1);
|
|
1075
|
+
if (willCollapse) {
|
|
1076
|
+
const h = grid.scrollHeight;
|
|
1077
|
+
grid.style.maxHeight = h + "px";
|
|
1078
|
+
void grid.offsetHeight;
|
|
1079
|
+
grid.classList.add("sm-stories-grid-collapsed");
|
|
1080
|
+
requestAnimationFrame(() => {
|
|
1081
|
+
if (grid._collapseToken !== token) return;
|
|
1082
|
+
grid.style.maxHeight = "0px";
|
|
1083
|
+
});
|
|
1084
|
+
} else {
|
|
1085
|
+
grid.classList.remove("sm-stories-grid-collapsed");
|
|
1086
|
+
grid.style.maxHeight = "0px";
|
|
1087
|
+
void grid.offsetHeight;
|
|
1088
|
+
const target = grid.scrollHeight;
|
|
1089
|
+
requestAnimationFrame(() => {
|
|
1090
|
+
if (grid._collapseToken !== token) return;
|
|
1091
|
+
grid.style.maxHeight = target + "px";
|
|
1092
|
+
});
|
|
1093
|
+
const onEnd = (ev) => {
|
|
1094
|
+
if (ev.propertyName !== "max-height") return;
|
|
1095
|
+
if (grid._collapseToken !== token) return;
|
|
1096
|
+
grid.style.maxHeight = "";
|
|
1097
|
+
grid.removeEventListener("transitionend", onEnd);
|
|
1098
|
+
};
|
|
1099
|
+
grid.addEventListener("transitionend", onEnd);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function mountStoryMapView() {
|
|
1104
|
+
return rendererStoryMap.mount($("story-map-host"), app.store, {
|
|
1105
|
+
filter: app.filter,
|
|
1106
|
+
matchTicket: queryMatchPredicate(), // SM-191 smart-bar query filter
|
|
1107
|
+
// SM-83: per-project release collapse state. Renderer reads the set
|
|
1108
|
+
// each render for the initial DOM (so a collapsed-on-page-load
|
|
1109
|
+
// release renders correctly). Toggling uses in-place DOM animation
|
|
1110
|
+
// — no remount — to slide the cells-row up/down and let adjacent
|
|
1111
|
+
// rows reflow naturally.
|
|
1112
|
+
collapsedReleases: app.collapsedReleases || new Set(),
|
|
1113
|
+
onToggleReleaseCollapsed: (releaseId) => {
|
|
1114
|
+
if (!app.collapsedReleases) app.collapsedReleases = new Set();
|
|
1115
|
+
const willCollapse = !app.collapsedReleases.has(releaseId);
|
|
1116
|
+
if (willCollapse) app.collapsedReleases.add(releaseId);
|
|
1117
|
+
else app.collapsedReleases.delete(releaseId);
|
|
1118
|
+
writeCollapsedReleasesToStorage(app.currentProjectId, app.collapsedReleases);
|
|
1119
|
+
animateReleaseCollapse(releaseId, willCollapse);
|
|
1120
|
+
},
|
|
1121
|
+
// SM-84: per-project epic collapse state. Same pattern as releases —
|
|
1122
|
+
// renderer reads the set for initial DOM (so a collapsed-on-load epic
|
|
1123
|
+
// renders with max-height:0), toggling owns the in-place animation.
|
|
1124
|
+
collapsedEpics: app.collapsedEpics || new Set(),
|
|
1125
|
+
onToggleEpicCollapsed: (epicId) => {
|
|
1126
|
+
if (!app.collapsedEpics) app.collapsedEpics = new Set();
|
|
1127
|
+
const willCollapse = !app.collapsedEpics.has(epicId);
|
|
1128
|
+
if (willCollapse) app.collapsedEpics.add(epicId);
|
|
1129
|
+
else app.collapsedEpics.delete(epicId);
|
|
1130
|
+
writeCollapsedEpicsToStorage(app.currentProjectId, app.collapsedEpics);
|
|
1131
|
+
animateEpicCollapse(epicId, willCollapse);
|
|
1132
|
+
},
|
|
1133
|
+
// SM-272: per-project process-step COLUMN collapse. The Set is mutated in
|
|
1134
|
+
// place + persisted; the renderer re-renders itself after the callback
|
|
1135
|
+
// (width + cell content change, so no CSS-only animation like release/epic).
|
|
1136
|
+
collapsedColumns: app.collapsedColumns || new Set(),
|
|
1137
|
+
onToggleColumnCollapsed: (psId) => {
|
|
1138
|
+
if (!app.collapsedColumns) app.collapsedColumns = new Set();
|
|
1139
|
+
if (app.collapsedColumns.has(psId)) app.collapsedColumns.delete(psId);
|
|
1140
|
+
else app.collapsedColumns.add(psId);
|
|
1141
|
+
writeCollapsedColumnsToStorage(app.currentProjectId, app.collapsedColumns);
|
|
1142
|
+
},
|
|
1143
|
+
onTicketClick: (id) => {
|
|
1144
|
+
// SM-73: opts.onDelete no longer required — openTicketModal
|
|
1145
|
+
// falls back to ctx.store.softDeleteTicket on its own.
|
|
1146
|
+
rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
|
|
1147
|
+
},
|
|
1148
|
+
onAddTicket: (cellCtx) => {
|
|
1149
|
+
// Per-cell "+ Add Item" button → type-picker → detail-modal (Create-Mode).
|
|
1150
|
+
rendererTicketModal.openTypePickerThenCreate(buildTicketModalCtx(), cellCtx);
|
|
1151
|
+
},
|
|
1152
|
+
onReleaseClick: (id) => openEditReleaseDialog(id),
|
|
1153
|
+
onProcessStepClick: (id) => openEditProcessStepDialog(id),
|
|
1154
|
+
onAddRelease: () => openNewReleaseDialog(),
|
|
1155
|
+
onAddProcessStep: () => openNewProcessStepDialog(),
|
|
1156
|
+
onAddProcessStepWithTicket: (ticketId) => openNewProcessStepDialog({ assignTicketId: ticketId }),
|
|
1157
|
+
/**
|
|
1158
|
+
* Persistenz-Pfad für alle DnD-Drop-Operationen. E18.D: lokale Mutation
|
|
1159
|
+
* via Store-Op; save-Subscriber persistiert debounced. Server schreibt
|
|
1160
|
+
* eine Revision pro flush, WS pusht Echo an andere Clients (eigener
|
|
1161
|
+
* Echo wird per Origin-Id gefiltert).
|
|
1162
|
+
*/
|
|
1163
|
+
persistMove: (ticketId, position) => {
|
|
1164
|
+
try {
|
|
1165
|
+
app.store.moveTicket(ticketId, position, LOCAL_ACTOR);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
flashStatus(err.message || "move failed", { kind: "error" });
|
|
1168
|
+
}
|
|
1169
|
+
},
|
|
1170
|
+
/**
|
|
1171
|
+
* Ticket-Reorder — mirrors onProcessStepReorder exactly. Takes an
|
|
1172
|
+
* orderedIds array and an optional scope ({releaseId, processStepId,
|
|
1173
|
+
* epicId}). Store renumbers atomically.
|
|
1174
|
+
*/
|
|
1175
|
+
persistReorderTickets: (orderedIds, scope) => {
|
|
1176
|
+
if (!orderedIds || orderedIds.length === 0) return;
|
|
1177
|
+
try {
|
|
1178
|
+
app.store.reorderTickets(orderedIds, scope || null, LOCAL_ACTOR);
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
console.error("[reorder] persistReorderTickets failed:", err, "orderedIds:", orderedIds, "scope:", scope);
|
|
1181
|
+
flashStatus(err.message || "reorder failed", { kind: "error" });
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
onProcessStepReorder: (orderedIds) => {
|
|
1185
|
+
try {
|
|
1186
|
+
app.store.reorderProcessSteps(orderedIds, LOCAL_ACTOR);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
flashStatus(err.message || "reorder failed", { kind: "error" });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---- Dialog: New Project --------------------------------------------
|
|
1195
|
+
|
|
1196
|
+
function openNewProjectDialog() {
|
|
1197
|
+
// SM-195: allow attaching a source PRD/PLD right at project creation.
|
|
1198
|
+
// Attachments are a REST feature → only with the HTTP adapter. Files are
|
|
1199
|
+
// collected here and uploaded as project-level attachments AFTER the
|
|
1200
|
+
// project row exists (an upload failure never aborts the create).
|
|
1201
|
+
const isHttp = !!(app.adapter && app.adapter.name === "Http");
|
|
1202
|
+
const pendingFiles = [];
|
|
1203
|
+
const attachRowHTML = isHttp ? `
|
|
1204
|
+
<div class="modal-row np-attach-row">
|
|
1205
|
+
<label>Attachments</label>
|
|
1206
|
+
<div class="np-attach-wrap">
|
|
1207
|
+
<div class="np-attach-dropzone tm-attach-dropzone" id="np-dropzone">Drop a PRD/document here or click to attach (optional)</div>
|
|
1208
|
+
<input id="np-file" type="file" multiple class="tm-attach-input" style="display:none">
|
|
1209
|
+
<ul class="np-attach-list tm-attach-list" id="np-pending"></ul>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
` : "";
|
|
1213
|
+
showModal({
|
|
1214
|
+
title: "New project",
|
|
1215
|
+
sub: "Project ID must match [a-zA-Z0-9_-] (1–100 chars). DoR/DoD-Definitionen können später in den Projekt-Einstellungen gepflegt werden.",
|
|
1216
|
+
bodyHTML: `
|
|
1217
|
+
<div class="modal-row">
|
|
1218
|
+
<label for="np-id">Project ID</label>
|
|
1219
|
+
<input id="np-id" type="text" autofocus>
|
|
1220
|
+
</div>
|
|
1221
|
+
<div class="modal-row">
|
|
1222
|
+
<label for="np-name">Display name</label>
|
|
1223
|
+
<input id="np-name" type="text">
|
|
1224
|
+
</div>
|
|
1225
|
+
<div class="modal-row">
|
|
1226
|
+
<label for="np-prefix">Ticket prefix</label>
|
|
1227
|
+
<input id="np-prefix" type="text" value="P" maxlength="10">
|
|
1228
|
+
</div>
|
|
1229
|
+
${attachRowHTML}
|
|
1230
|
+
`,
|
|
1231
|
+
onMount: (modal) => {
|
|
1232
|
+
const idIn = modal.querySelector("#np-id");
|
|
1233
|
+
const nameIn = modal.querySelector("#np-name");
|
|
1234
|
+
idIn.addEventListener("input", () => {
|
|
1235
|
+
if (!nameIn.value) nameIn.value = idIn.value;
|
|
1236
|
+
});
|
|
1237
|
+
if (!isHttp) return;
|
|
1238
|
+
const drop = modal.querySelector("#np-dropzone");
|
|
1239
|
+
const fileIn = modal.querySelector("#np-file");
|
|
1240
|
+
const listEl = modal.querySelector("#np-pending");
|
|
1241
|
+
function renderPending() {
|
|
1242
|
+
listEl.innerHTML = "";
|
|
1243
|
+
pendingFiles.forEach((f, i) => {
|
|
1244
|
+
const li = document.createElement("li");
|
|
1245
|
+
li.className = "tm-attach-item";
|
|
1246
|
+
const name = document.createElement("span");
|
|
1247
|
+
name.className = "tm-attach-name";
|
|
1248
|
+
name.textContent = f.name;
|
|
1249
|
+
const del = document.createElement("button");
|
|
1250
|
+
del.type = "button"; del.className = "tm-attach-del"; del.textContent = "✕";
|
|
1251
|
+
del.title = "Remove";
|
|
1252
|
+
del.addEventListener("click", (ev) => { ev.preventDefault(); pendingFiles.splice(i, 1); renderPending(); });
|
|
1253
|
+
li.appendChild(name); li.appendChild(del);
|
|
1254
|
+
listEl.appendChild(li);
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
function addFiles(fileList) {
|
|
1258
|
+
for (const f of fileList) pendingFiles.push(f);
|
|
1259
|
+
renderPending();
|
|
1260
|
+
}
|
|
1261
|
+
drop.addEventListener("click", () => fileIn.click());
|
|
1262
|
+
fileIn.addEventListener("change", () => { if (fileIn.files) addFiles(fileIn.files); fileIn.value = ""; });
|
|
1263
|
+
drop.addEventListener("dragover", (ev) => { ev.preventDefault(); drop.classList.add("tm-attach-dropzone-over"); });
|
|
1264
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("tm-attach-dropzone-over"));
|
|
1265
|
+
drop.addEventListener("drop", (ev) => {
|
|
1266
|
+
ev.preventDefault(); drop.classList.remove("tm-attach-dropzone-over");
|
|
1267
|
+
if (ev.dataTransfer && ev.dataTransfer.files) addFiles(ev.dataTransfer.files);
|
|
1268
|
+
});
|
|
1269
|
+
},
|
|
1270
|
+
actions: [
|
|
1271
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1272
|
+
{
|
|
1273
|
+
label: "Create", primary: true,
|
|
1274
|
+
onClick: async (modal) => {
|
|
1275
|
+
const id = modal.querySelector("#np-id").value.trim();
|
|
1276
|
+
const name = modal.querySelector("#np-name").value.trim() || id;
|
|
1277
|
+
const prefix = (modal.querySelector("#np-prefix").value.trim() || "P").toUpperCase();
|
|
1278
|
+
if (!BOOTSTRAP.PROJECT_ID_PATTERN.test(id)) {
|
|
1279
|
+
flashStatus("invalid project id", { kind: "error" });
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
if (app.adapter.name === "Http") {
|
|
1284
|
+
// SM-254: the server seeds the default release + process step on
|
|
1285
|
+
// create — the UI no longer double-seeds.
|
|
1286
|
+
await httpReq("POST", "/api/projects", { id, name, ticketPrefix: prefix });
|
|
1287
|
+
} else {
|
|
1288
|
+
// Local adapters have no server; seed via the shared helper (DRY).
|
|
1289
|
+
const seed = core.normalizeSnapshot({ project: { id, name, ticketPrefix: prefix } });
|
|
1290
|
+
const snap = core.seedDefaultScaffold(seed, { type: "human", id: "local", name: "Local" });
|
|
1291
|
+
await app.adapter.save(id, snap);
|
|
1292
|
+
}
|
|
1293
|
+
await loadProject(id);
|
|
1294
|
+
flashStatus("created project: " + id + " (with " + BOOTSTRAP.DEFAULT_RELEASE_NAME + " + " + BOOTSTRAP.DEFAULT_PROCESS_STEP_NAME + ")", { kind: "ok" });
|
|
1295
|
+
// SM-195: upload any dropped PRDs as project-level attachments now
|
|
1296
|
+
// that the project exists. A failure here does NOT abort the create
|
|
1297
|
+
// (the project is already there) — just report it.
|
|
1298
|
+
if (isHttp && pendingFiles.length) {
|
|
1299
|
+
const ctx = buildTicketModalCtx();
|
|
1300
|
+
let okCount = 0;
|
|
1301
|
+
for (const f of pendingFiles) {
|
|
1302
|
+
try { await ctx.uploadAttachment("none", f); okCount++; }
|
|
1303
|
+
catch (e) { flashStatus("attachment '" + f.name + "' failed: " + (e.message || "error"), { kind: "error" }); }
|
|
1304
|
+
}
|
|
1305
|
+
if (okCount) flashStatus("attached " + okCount + " document" + (okCount > 1 ? "s" : "") + " to " + id, { kind: "ok" });
|
|
1306
|
+
}
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
flashStatus(err.message || "create failed", { kind: "error" });
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
]
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ---- Dialog: New Release (E12) --------------------------------------
|
|
1318
|
+
|
|
1319
|
+
function openNewReleaseDialog() {
|
|
1320
|
+
const statusOpts = core.DEFAULT_RELEASE_STATUSES
|
|
1321
|
+
.map(s => `<option value="${escapeAttr(s)}"${s === "planning" ? " selected" : ""}>${escapeHtml(s)}</option>`).join("");
|
|
1322
|
+
showModal({
|
|
1323
|
+
title: "New release",
|
|
1324
|
+
sub: "Releases are the vertical slices of the Story Map (e.g. v1.0, MVP, Q3 milestone).",
|
|
1325
|
+
bodyHTML: `
|
|
1326
|
+
<div class="modal-row">
|
|
1327
|
+
<label for="nr-name">Name</label>
|
|
1328
|
+
<input id="nr-name" type="text" autofocus>
|
|
1329
|
+
</div>
|
|
1330
|
+
<div class="modal-row">
|
|
1331
|
+
<label for="nr-status">Status</label>
|
|
1332
|
+
<select id="nr-status">${statusOpts}</select>
|
|
1333
|
+
</div>
|
|
1334
|
+
<div class="modal-row">
|
|
1335
|
+
<label for="nr-desc">Description</label>
|
|
1336
|
+
<textarea id="nr-desc" rows="2" placeholder="Optional — what this release covers."></textarea>
|
|
1337
|
+
</div>
|
|
1338
|
+
`,
|
|
1339
|
+
actions: [
|
|
1340
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1341
|
+
{
|
|
1342
|
+
label: "Create", primary: true,
|
|
1343
|
+
onClick: async (modal) => {
|
|
1344
|
+
const name = modal.querySelector("#nr-name").value.trim();
|
|
1345
|
+
const status = modal.querySelector("#nr-status").value;
|
|
1346
|
+
const description = modal.querySelector("#nr-desc").value;
|
|
1347
|
+
if (!name) { flashStatus("name is required", { kind: "error" }); return false; }
|
|
1348
|
+
try {
|
|
1349
|
+
app.store.createRelease({ name, status, description }, LOCAL_ACTOR);
|
|
1350
|
+
flashStatus("release created", { kind: "ok" });
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
flashStatus(err.message || "create failed", { kind: "error" });
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
]
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// SM-167: tickets positioned in a release that aren't done. Counted by
|
|
1362
|
+
// position.releaseId (NOT by what the board filter shows) — this is what the
|
|
1363
|
+
// release-completion warning reports. Sorted by sortOrder for a stable list.
|
|
1364
|
+
function openTicketsInRelease(snap, releaseId) {
|
|
1365
|
+
// SM-244: "open" = NOT terminal (terminal = done ∪ cancelled). A cancelled
|
|
1366
|
+
// ticket is closed work, so it no longer counts against completing a release.
|
|
1367
|
+
return (snap.tickets || [])
|
|
1368
|
+
.filter(t => !t.isDeleted && t.position && t.position.releaseId === releaseId
|
|
1369
|
+
&& !core.isTerminalStatus(snap.project, t.status))
|
|
1370
|
+
.sort((a, b) => ((a.position && a.position.sortOrder) || 0) - ((b.position && b.position.sortOrder) || 0));
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// ---- Dialog: Edit Release (rename, status, dates, delete) -----------
|
|
1374
|
+
|
|
1375
|
+
function openEditReleaseDialog(releaseId) {
|
|
1376
|
+
const snap = app.store.get();
|
|
1377
|
+
const release = (snap.releases || []).find(r => r.id === releaseId);
|
|
1378
|
+
if (!release) { flashStatus("release not found", { kind: "error" }); return; }
|
|
1379
|
+
// SM-255: a project keeps ≥1 release — the last one can't be deleted.
|
|
1380
|
+
const isLastRelease = (snap.releases || []).filter(r => !r.isDeleted).length <= 1;
|
|
1381
|
+
const statusOpts = core.DEFAULT_RELEASE_STATUSES
|
|
1382
|
+
.map(s => `<option value="${escapeAttr(s)}"${s === release.status ? " selected" : ""}>${escapeHtml(s)}</option>`).join("");
|
|
1383
|
+
// SM-239: show the same X/Y progress (non-epic work items) as the Map/Kanban.
|
|
1384
|
+
const prog = core.releaseProgress(snap, releaseId);
|
|
1385
|
+
const progText = prog.total > 0
|
|
1386
|
+
? ` · ${prog.done}/${prog.total} done${prog.complete ? " ✓" : ""}`
|
|
1387
|
+
: "";
|
|
1388
|
+
showModal({
|
|
1389
|
+
title: "Edit release",
|
|
1390
|
+
sub: `Release "${escapeHtml(release.name)}" — ${escapeHtml(release.id)}${progText}`,
|
|
1391
|
+
bodyHTML: `
|
|
1392
|
+
<div class="modal-row">
|
|
1393
|
+
<label for="er-name">Name</label>
|
|
1394
|
+
<input id="er-name" type="text" value="${escapeAttr(release.name)}">
|
|
1395
|
+
</div>
|
|
1396
|
+
<div class="modal-row">
|
|
1397
|
+
<label for="er-status">Status</label>
|
|
1398
|
+
<select id="er-status">${statusOpts}</select>
|
|
1399
|
+
</div>
|
|
1400
|
+
<div class="modal-row">
|
|
1401
|
+
<label for="er-desc">Description</label>
|
|
1402
|
+
<textarea id="er-desc" rows="2">${escapeHtml(release.description || "")}</textarea>
|
|
1403
|
+
</div>
|
|
1404
|
+
<div id="er-open-warning" class="er-open-warning" hidden></div>
|
|
1405
|
+
`,
|
|
1406
|
+
// SM-167: when "completed" is selected, reactively list the tickets that
|
|
1407
|
+
// are POSITIONED in this release and not done — incl. backlog-status ones
|
|
1408
|
+
// (the release-completion count is by position.releaseId, independent of
|
|
1409
|
+
// any board filter). Replaces the old window.confirm.
|
|
1410
|
+
onMount: (modal) => {
|
|
1411
|
+
const statusSel = modal.querySelector("#er-status");
|
|
1412
|
+
const warn = modal.querySelector("#er-open-warning");
|
|
1413
|
+
const refresh = () => {
|
|
1414
|
+
const completing = statusSel.value === "completed" && release.status !== "completed";
|
|
1415
|
+
const open = completing ? openTicketsInRelease(app.store.get(), releaseId) : [];
|
|
1416
|
+
if (open.length === 0) { warn.hidden = true; warn.innerHTML = ""; return; }
|
|
1417
|
+
const rows = open.map(t =>
|
|
1418
|
+
`<li><span class="er-open-key">${escapeHtml(t.ticketKey || t.id)}</span>` +
|
|
1419
|
+
`<span class="er-open-title">${escapeHtml(t.title || "")}</span>` +
|
|
1420
|
+
`<span class="er-open-status">${escapeHtml(t.status)}</span></li>`).join("");
|
|
1421
|
+
warn.innerHTML =
|
|
1422
|
+
`<div class="er-open-head">${open.length} ticket(s) are positioned in this release and still open ` +
|
|
1423
|
+
`(not done or cancelled; counted by release, independent of any filter). Completing the release leaves them in it:</div>` +
|
|
1424
|
+
`<ul class="er-open-list">${rows}</ul>`;
|
|
1425
|
+
warn.hidden = false;
|
|
1426
|
+
};
|
|
1427
|
+
statusSel.addEventListener("change", refresh);
|
|
1428
|
+
refresh();
|
|
1429
|
+
// SM-255: disable Delete when this is the last release (≥1 required).
|
|
1430
|
+
if (isLastRelease) {
|
|
1431
|
+
const delBtn = modal.querySelector(".btn.destructive");
|
|
1432
|
+
if (delBtn) {
|
|
1433
|
+
delBtn.setAttribute("disabled", "disabled");
|
|
1434
|
+
delBtn.title = "At least one release is required";
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
actions: [
|
|
1439
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1440
|
+
{
|
|
1441
|
+
label: "Delete", destructive: true,
|
|
1442
|
+
onClick: () => {
|
|
1443
|
+
if (isLastRelease) { // SM-255: guard even if the disabled button is bypassed
|
|
1444
|
+
flashStatus("at least one release is required", { kind: "error" });
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
try {
|
|
1448
|
+
app.store.softDeleteRelease(releaseId, LOCAL_ACTOR);
|
|
1449
|
+
flashStatus("release deleted", { kind: "ok" });
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
flashStatus(err.message || "delete failed", { kind: "error" });
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
label: "Save", primary: true,
|
|
1458
|
+
onClick: (modal) => {
|
|
1459
|
+
const patch = {
|
|
1460
|
+
name: modal.querySelector("#er-name").value.trim(),
|
|
1461
|
+
status: modal.querySelector("#er-status").value,
|
|
1462
|
+
description: modal.querySelector("#er-desc").value
|
|
1463
|
+
};
|
|
1464
|
+
if (!patch.name) { flashStatus("name is required", { kind: "error" }); return false; }
|
|
1465
|
+
// SM-167: the open-ticket warning is now a reactive inline list in
|
|
1466
|
+
// the dialog (see onMount). It's informational, not blocking — the
|
|
1467
|
+
// user already saw which tickets are positioned in this release
|
|
1468
|
+
// before clicking Save, so completing proceeds without a second
|
|
1469
|
+
// browser-native confirm. Surface the count once more for the log.
|
|
1470
|
+
if (patch.status === "completed" && release.status !== "completed") {
|
|
1471
|
+
const stillOpen = openTicketsInRelease(app.store.get(), releaseId);
|
|
1472
|
+
if (stillOpen.length > 0) {
|
|
1473
|
+
flashStatus("release completed — " + stillOpen.length + " ticket(s) remain positioned in it", { kind: "ok" });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
app.store.updateRelease(releaseId, patch, LOCAL_ACTOR);
|
|
1478
|
+
if (!(patch.status === "completed" && release.status !== "completed")) {
|
|
1479
|
+
flashStatus("release updated", { kind: "ok" });
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
flashStatus(err.message || "update failed", { kind: "error" });
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
]
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// ---- Dialog: New Process Step (E12) --------------------------------
|
|
1492
|
+
|
|
1493
|
+
function openNewProcessStepDialog(opts) {
|
|
1494
|
+
opts = opts || {};
|
|
1495
|
+
// assignTicketId: works for both epic and non-epic tickets. For a
|
|
1496
|
+
// non-epic ticket the move clears epicId so it lands as a loose
|
|
1497
|
+
// ticket in the new column.
|
|
1498
|
+
const assignTicketId = opts.assignTicketId || opts.assignEpicId || null;
|
|
1499
|
+
showModal({
|
|
1500
|
+
title: assignTicketId ? "New process step + place ticket" : "New process step",
|
|
1501
|
+
sub: assignTicketId
|
|
1502
|
+
? "Create a new process step. The dragged ticket will be moved into the new column on submit."
|
|
1503
|
+
: "Process steps form the horizontal Backbone — user activities or workflow stages (e.g. Onboarding, Daily Use, Reporting).",
|
|
1504
|
+
bodyHTML: `
|
|
1505
|
+
<div class="modal-row">
|
|
1506
|
+
<label for="nps-name">Name</label>
|
|
1507
|
+
<input id="nps-name" type="text" autofocus>
|
|
1508
|
+
</div>
|
|
1509
|
+
<div class="modal-row">
|
|
1510
|
+
<label for="nps-desc">Description</label>
|
|
1511
|
+
<textarea id="nps-desc" rows="2" placeholder="Optional."></textarea>
|
|
1512
|
+
</div>
|
|
1513
|
+
`,
|
|
1514
|
+
actions: [
|
|
1515
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1516
|
+
{
|
|
1517
|
+
label: "Create", primary: true,
|
|
1518
|
+
onClick: (modal) => {
|
|
1519
|
+
const name = modal.querySelector("#nps-name").value.trim();
|
|
1520
|
+
const description = modal.querySelector("#nps-desc").value;
|
|
1521
|
+
if (!name) { flashStatus("name is required", { kind: "error" }); return false; }
|
|
1522
|
+
try {
|
|
1523
|
+
app.store.createProcessStep({ name, description }, LOCAL_ACTOR);
|
|
1524
|
+
if (assignTicketId) {
|
|
1525
|
+
const snap = app.store.get();
|
|
1526
|
+
const newStepId = snap.processSteps[snap.processSteps.length - 1].id;
|
|
1527
|
+
const ticket = snap.tickets.find(t => t.id === assignTicketId);
|
|
1528
|
+
if (ticket) {
|
|
1529
|
+
app.store.moveTicket(assignTicketId, {
|
|
1530
|
+
releaseId: ticket.position && ticket.position.releaseId,
|
|
1531
|
+
processStepId: newStepId,
|
|
1532
|
+
epicId: null,
|
|
1533
|
+
sortOrder: 0
|
|
1534
|
+
}, LOCAL_ACTOR);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
flashStatus(assignTicketId
|
|
1538
|
+
? "process step created — ticket placed"
|
|
1539
|
+
: "process step created", { kind: "ok" });
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
flashStatus(err.message || "create failed", { kind: "error" });
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
]
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ---- Dialog: Edit Process Step --------------------------------------
|
|
1551
|
+
|
|
1552
|
+
function openEditProcessStepDialog(stepId) {
|
|
1553
|
+
const snap = app.store.get();
|
|
1554
|
+
const step = (snap.processSteps || []).find(p => p.id === stepId);
|
|
1555
|
+
if (!step) { flashStatus("process step not found", { kind: "error" }); return; }
|
|
1556
|
+
showModal({
|
|
1557
|
+
title: "Edit process step",
|
|
1558
|
+
sub: `Step "${escapeHtml(step.name)}" — ${escapeHtml(step.id)}`,
|
|
1559
|
+
bodyHTML: `
|
|
1560
|
+
<div class="modal-row">
|
|
1561
|
+
<label for="eps-name">Name</label>
|
|
1562
|
+
<input id="eps-name" type="text" value="${escapeAttr(step.name)}">
|
|
1563
|
+
</div>
|
|
1564
|
+
<div class="modal-row">
|
|
1565
|
+
<label for="eps-desc">Description</label>
|
|
1566
|
+
<textarea id="eps-desc" rows="2">${escapeHtml(step.description || "")}</textarea>
|
|
1567
|
+
</div>
|
|
1568
|
+
`,
|
|
1569
|
+
actions: [
|
|
1570
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1571
|
+
{
|
|
1572
|
+
label: "Delete", destructive: true,
|
|
1573
|
+
onClick: () => {
|
|
1574
|
+
try {
|
|
1575
|
+
app.store.softDeleteProcessStep(stepId, LOCAL_ACTOR);
|
|
1576
|
+
flashStatus("process step deleted", { kind: "ok" });
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
flashStatus(err.message || "delete failed", { kind: "error" });
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
label: "Save", primary: true,
|
|
1585
|
+
onClick: (modal) => {
|
|
1586
|
+
const patch = {
|
|
1587
|
+
name: modal.querySelector("#eps-name").value.trim(),
|
|
1588
|
+
description: modal.querySelector("#eps-desc").value
|
|
1589
|
+
};
|
|
1590
|
+
if (!patch.name) { flashStatus("name is required", { kind: "error" }); return false; }
|
|
1591
|
+
try {
|
|
1592
|
+
app.store.updateProcessStep(stepId, patch, LOCAL_ACTOR);
|
|
1593
|
+
flashStatus("process step updated", { kind: "ok" });
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
flashStatus(err.message || "update failed", { kind: "error" });
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
]
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ---- Dialog: Delete Project (confirm) -------------------------------
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Load-Project-Dialog (cmapper-Pattern). Listet alle nicht-gelöschten
|
|
1608
|
+
* Projekte als Cards mit Counts. Klick auf Card → loadProject. Aktuelles
|
|
1609
|
+
* Projekt bekommt ein "current"-Badge und ist nicht klickbar. Pro Card
|
|
1610
|
+
* gibt's einen Delete-Button (außer für das aktuelle).
|
|
1611
|
+
*/
|
|
1612
|
+
async function openLoadProjectDialog() {
|
|
1613
|
+
let ids;
|
|
1614
|
+
try { ids = await app.adapter.list(); }
|
|
1615
|
+
catch (err) { flashStatus(err.message || "list failed", { kind: "error" }); return; }
|
|
1616
|
+
if (!Array.isArray(ids)) ids = [];
|
|
1617
|
+
|
|
1618
|
+
// Fetch counts per project in parallel so the modal opens fast.
|
|
1619
|
+
const summaries = await Promise.all(ids.map(async (id) => {
|
|
1620
|
+
if (id === app.currentProjectId && app.store) {
|
|
1621
|
+
return { id, snap: app.store.get() };
|
|
1622
|
+
}
|
|
1623
|
+
try { return { id, snap: await app.adapter.load(id) }; }
|
|
1624
|
+
catch { return { id, snap: null }; }
|
|
1625
|
+
}));
|
|
1626
|
+
function countsText(s) {
|
|
1627
|
+
if (!s) return "—";
|
|
1628
|
+
const t = (s.tickets || []).filter(x => !x.isDeleted).length;
|
|
1629
|
+
const r = (s.releases || []).filter(x => !x.isDeleted).length;
|
|
1630
|
+
const p = (s.processSteps || []).filter(x => !x.isDeleted).length;
|
|
1631
|
+
return t + " tickets · " + r + " releases · " + p + " process steps";
|
|
1632
|
+
}
|
|
1633
|
+
function nameOf(s) {
|
|
1634
|
+
return (s && s.project && s.project.name) || "(unnamed)";
|
|
1635
|
+
}
|
|
1636
|
+
function descOf(s) {
|
|
1637
|
+
return (s && s.project && typeof s.project.description === "string") ? s.project.description : "";
|
|
1638
|
+
}
|
|
1639
|
+
// SM-209: recency without a server endpoint — the dialog already loads each
|
|
1640
|
+
// snapshot, so derive "last touched" as the max updatedAt/createdAt across the
|
|
1641
|
+
// project header and all of its entities. Works for Http/Local/Memory alike.
|
|
1642
|
+
function recencyOf(s) {
|
|
1643
|
+
if (!s) return 0;
|
|
1644
|
+
let max = 0;
|
|
1645
|
+
const bump = (e) => {
|
|
1646
|
+
if (!e) return;
|
|
1647
|
+
const v = Number(e.updatedAt || e.createdAt || 0);
|
|
1648
|
+
if (v > max) max = v;
|
|
1649
|
+
};
|
|
1650
|
+
bump(s.project);
|
|
1651
|
+
(s.tickets || []).forEach(bump);
|
|
1652
|
+
(s.releases || []).forEach(bump);
|
|
1653
|
+
(s.processSteps || []).forEach(bump);
|
|
1654
|
+
return max;
|
|
1655
|
+
}
|
|
1656
|
+
function fmtRecency(ms) {
|
|
1657
|
+
if (!ms) return "";
|
|
1658
|
+
const diff = Date.now() - ms;
|
|
1659
|
+
const day = 86400000;
|
|
1660
|
+
if (diff < 0) return "just now";
|
|
1661
|
+
if (diff < day) {
|
|
1662
|
+
try { return "today, " + new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); }
|
|
1663
|
+
catch (_e) { return "today"; }
|
|
1664
|
+
}
|
|
1665
|
+
if (diff < 2 * day) return "yesterday";
|
|
1666
|
+
if (diff < 7 * day) return Math.floor(diff / day) + " days ago";
|
|
1667
|
+
try { return new Date(ms).toLocaleDateString(); } catch (_e) { return ""; }
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Enrich each summary once with the derived fields used for filtering + sort.
|
|
1671
|
+
summaries.forEach(it => {
|
|
1672
|
+
it.name = nameOf(it.snap);
|
|
1673
|
+
it.desc = descOf(it.snap);
|
|
1674
|
+
it.mtimeMs = recencyOf(it.snap);
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
const SORT_KEY = "storymap-project-sort";
|
|
1678
|
+
function readSort() {
|
|
1679
|
+
try {
|
|
1680
|
+
const v = localStorage.getItem(SORT_KEY);
|
|
1681
|
+
if (v === "name-asc" || v === "name-desc" || v === "recent" || v === "oldest") return v;
|
|
1682
|
+
} catch (_e) {}
|
|
1683
|
+
return "recent";
|
|
1684
|
+
}
|
|
1685
|
+
function writeSort(v) { try { localStorage.setItem(SORT_KEY, v); } catch (_e) {} }
|
|
1686
|
+
|
|
1687
|
+
function cardHtml(it) {
|
|
1688
|
+
const isCurrent = it.id === app.currentProjectId;
|
|
1689
|
+
const safeId = escapeHtml(it.id);
|
|
1690
|
+
const safeName = escapeHtml(it.name);
|
|
1691
|
+
const counts = escapeHtml(countsText(it.snap));
|
|
1692
|
+
const recency = it.mtimeMs ? escapeHtml(fmtRecency(it.mtimeMs)) : "";
|
|
1693
|
+
return ''
|
|
1694
|
+
+ '<div class="project-card' + (isCurrent ? ' current' : '') + '" data-id="' + safeId + '">'
|
|
1695
|
+
+ '<div class="project-card-header">'
|
|
1696
|
+
+ '<span class="project-card-name">' + safeName + '</span>'
|
|
1697
|
+
+ '<span class="project-card-id">' + safeId + '</span>'
|
|
1698
|
+
+ (isCurrent ? '<span class="project-card-current-badge">current</span>' : '')
|
|
1699
|
+
+ '</div>'
|
|
1700
|
+
+ '<div class="project-card-counts">' + counts + '</div>'
|
|
1701
|
+
+ (it.desc ? '<div class="project-card-desc">' + escapeHtml(it.desc) + '</div>' : '')
|
|
1702
|
+
+ (recency ? '<div class="project-card-mtime">' + recency + '</div>' : '')
|
|
1703
|
+
+ (!isCurrent ? '<button class="btn btn-delete project-card-delete" data-delete="' + safeId + '" title="Delete project">Delete</button>' : '')
|
|
1704
|
+
+ '</div>';
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
const initialSort = readSort();
|
|
1708
|
+
const toolbarHTML = ''
|
|
1709
|
+
+ '<div class="project-picker-toolbar">'
|
|
1710
|
+
+ '<input type="text" class="project-search" id="proj-search" placeholder="Search name, id or description…" autocomplete="off" spellcheck="false">'
|
|
1711
|
+
+ '<select class="project-sort" id="proj-sort">'
|
|
1712
|
+
+ '<option value="name-asc"' + (initialSort === "name-asc" ? " selected" : "") + '>Name A→Z</option>'
|
|
1713
|
+
+ '<option value="name-desc"' + (initialSort === "name-desc" ? " selected" : "") + '>Name Z→A</option>'
|
|
1714
|
+
+ '<option value="recent"' + (initialSort === "recent" ? " selected" : "") + '>Newest first</option>'
|
|
1715
|
+
+ '<option value="oldest"' + (initialSort === "oldest" ? " selected" : "") + '>Oldest first</option>'
|
|
1716
|
+
+ '</select>'
|
|
1717
|
+
+ '</div>';
|
|
1718
|
+
|
|
1719
|
+
showModal({
|
|
1720
|
+
title: "Projects",
|
|
1721
|
+
sub: "Click a card to switch. Each project is its own story map.",
|
|
1722
|
+
bodyHTML: ids.length
|
|
1723
|
+
? toolbarHTML + '<div class="project-list-cards" id="proj-cards-host"></div>'
|
|
1724
|
+
: '<div class="project-list-empty">No projects yet — use <em>Project ▸ New project…</em>.</div>',
|
|
1725
|
+
actions: [{ label: "Close", onClick: () => {} }],
|
|
1726
|
+
onMount: (modal, close) => {
|
|
1727
|
+
const host = modal.querySelector("#proj-cards-host");
|
|
1728
|
+
const searchEl = modal.querySelector("#proj-search");
|
|
1729
|
+
const sortEl = modal.querySelector("#proj-sort");
|
|
1730
|
+
if (!host) return; // empty-state: nothing to wire.
|
|
1731
|
+
|
|
1732
|
+
const wireCard = (card) => {
|
|
1733
|
+
card.addEventListener("click", async (e) => {
|
|
1734
|
+
if (e.target.closest("[data-delete]")) return;
|
|
1735
|
+
const id = card.getAttribute("data-id");
|
|
1736
|
+
if (id === app.currentProjectId) { close(); return; }
|
|
1737
|
+
close();
|
|
1738
|
+
await loadProject(id);
|
|
1739
|
+
});
|
|
1740
|
+
const delBtn = card.querySelector("[data-delete]");
|
|
1741
|
+
if (delBtn) {
|
|
1742
|
+
delBtn.addEventListener("click", async (e) => {
|
|
1743
|
+
e.stopPropagation();
|
|
1744
|
+
const id = delBtn.getAttribute("data-delete");
|
|
1745
|
+
if (!confirm("Delete project \"" + id + "\"? (soft delete — restorable via revision history)")) return;
|
|
1746
|
+
try {
|
|
1747
|
+
if (app.adapter.name === "Http") await httpReq("DELETE", "/api/projects/" + encodeURIComponent(id));
|
|
1748
|
+
else await app.adapter.delete(id);
|
|
1749
|
+
flashStatus("deleted: " + id, { kind: "ok" });
|
|
1750
|
+
close();
|
|
1751
|
+
openLoadProjectDialog(); // re-open with refreshed list
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
flashStatus(err.message || "delete failed", { kind: "error" });
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
const render = () => {
|
|
1760
|
+
const q = (searchEl.value || "").trim().toLowerCase();
|
|
1761
|
+
const sort = sortEl.value;
|
|
1762
|
+
let items = summaries.slice();
|
|
1763
|
+
if (q) {
|
|
1764
|
+
items = items.filter(it =>
|
|
1765
|
+
it.name.toLowerCase().includes(q) ||
|
|
1766
|
+
it.id.toLowerCase().includes(q) ||
|
|
1767
|
+
(it.desc || "").toLowerCase().includes(q)
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
items.sort((a, b) => {
|
|
1771
|
+
if (sort === "name-asc") return a.name.localeCompare(b.name);
|
|
1772
|
+
if (sort === "name-desc") return b.name.localeCompare(a.name);
|
|
1773
|
+
if (sort === "recent") return ((b.mtimeMs || 0) - (a.mtimeMs || 0)) || a.name.localeCompare(b.name);
|
|
1774
|
+
if (sort === "oldest") return ((a.mtimeMs || 0) - (b.mtimeMs || 0)) || a.name.localeCompare(b.name);
|
|
1775
|
+
return 0;
|
|
1776
|
+
});
|
|
1777
|
+
if (items.length === 0) {
|
|
1778
|
+
host.innerHTML = '<div class="project-list-empty-filtered">No projects match "' + escapeHtml(q) + '"</div>';
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
host.innerHTML = items.map(cardHtml).join("");
|
|
1782
|
+
host.querySelectorAll(".project-card").forEach(wireCard);
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
searchEl.addEventListener("input", render);
|
|
1786
|
+
sortEl.addEventListener("change", () => { writeSort(sortEl.value); render(); });
|
|
1787
|
+
render(); // initial paint applies persisted sort + wires cards.
|
|
1788
|
+
setTimeout(() => { try { searchEl.focus(); } catch (_e) {} }, 0);
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* History-Dialog (E13, cmapper-Pattern). Listet die Revisions des aktiven
|
|
1795
|
+
* Projekts (reverse-chrono via storage.listRevisions). Pro Row:
|
|
1796
|
+
* Timestamp (parsed from YYYYMMDD-HHmmss-mmm), op-Pille, Actor, Restore-
|
|
1797
|
+
* Button. Restore feuert POST /revisions/:rev/restore und appliziert das
|
|
1798
|
+
* zurückgegebene Snapshot direkt.
|
|
1799
|
+
*/
|
|
1800
|
+
// SM-194: project-level attachments (e.g. a source PRD that precedes the
|
|
1801
|
+
// tickets it will be decomposed into). Reuses the shared dropzone/list
|
|
1802
|
+
// component with the "none" sentinel = project-scope (ticket_id IS NULL).
|
|
1803
|
+
function openProjectAttachmentsDialog() {
|
|
1804
|
+
if (!app.currentProjectId) return;
|
|
1805
|
+
const ctx = buildTicketModalCtx();
|
|
1806
|
+
if (!ctx.attachmentsEnabled) {
|
|
1807
|
+
flashStatus("attachments need the HTTP server (not available in local mode)", { kind: "error" });
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
showModal({
|
|
1811
|
+
title: "Project attachments",
|
|
1812
|
+
sub: "Project-level reference documents (e.g. a source PRD). Not tied to any ticket.",
|
|
1813
|
+
bodyHTML: '<div class="tm-project-attach-host"></div>',
|
|
1814
|
+
actions: [{ label: "Close", onClick: () => {} }],
|
|
1815
|
+
onMount: (modal) => {
|
|
1816
|
+
const host = modal.querySelector(".tm-project-attach-host");
|
|
1817
|
+
if (host && SM.ticketForm && SM.ticketForm.buildAttachmentsSection) {
|
|
1818
|
+
host.appendChild(SM.ticketForm.buildAttachmentsSection("none", ctx));
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async function openHistoryDialog() {
|
|
1825
|
+
if (!app.currentProjectId) return;
|
|
1826
|
+
let revisions;
|
|
1827
|
+
try {
|
|
1828
|
+
revisions = await httpReq("GET",
|
|
1829
|
+
"/api/projects/" + encodeURIComponent(app.currentProjectId) + "/revisions");
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
flashStatus(err.message || "could not load history", { kind: "error" });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (!Array.isArray(revisions)) revisions = [];
|
|
1835
|
+
|
|
1836
|
+
function fmtRev(rev) {
|
|
1837
|
+
// YYYYMMDD-HHmmss-mmm[-NNNN] → "YYYY-MM-DD HH:mm:ss.mmm UTC"
|
|
1838
|
+
const m = String(rev).match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d{3})/);
|
|
1839
|
+
if (!m) return rev;
|
|
1840
|
+
return m[1] + "-" + m[2] + "-" + m[3] + " " + m[4] + ":" + m[5] + ":" + m[6] + "." + m[7] + " UTC";
|
|
1841
|
+
}
|
|
1842
|
+
function actorLabel(a) {
|
|
1843
|
+
if (!a) return "—";
|
|
1844
|
+
const kind = a.type || "?";
|
|
1845
|
+
const name = a.name || a.id || "";
|
|
1846
|
+
return name ? (kind + " · " + name) : kind;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const rowsHtml = revisions.length
|
|
1850
|
+
? revisions.map(r => ''
|
|
1851
|
+
+ '<div class="hist-row" data-rev="' + escapeAttr(r.revision) + '">'
|
|
1852
|
+
+ '<div class="hist-row-main">'
|
|
1853
|
+
+ '<span class="hist-timestamp">' + escapeHtml(fmtRev(r.revision)) + '</span>'
|
|
1854
|
+
+ '<span class="hist-op-pill">' + escapeHtml(r.op || "save") + '</span>'
|
|
1855
|
+
+ '<span class="hist-actor">' + escapeHtml(actorLabel(r.actor)) + '</span>'
|
|
1856
|
+
+ '</div>'
|
|
1857
|
+
+ '<button class="btn hist-restore" data-restore="' + escapeAttr(r.revision) + '" title="Restore this revision">Restore</button>'
|
|
1858
|
+
+ '</div>').join("")
|
|
1859
|
+
: '<div class="hist-empty">No revisions yet — make changes and they will be recorded here.</div>';
|
|
1860
|
+
|
|
1861
|
+
showModal({
|
|
1862
|
+
title: "History",
|
|
1863
|
+
sub: "Each save creates a revision. Restoring an older one writes a NEW revision (the restore itself is undoable).",
|
|
1864
|
+
bodyHTML: '<div class="hist-list">' + rowsHtml + '</div>',
|
|
1865
|
+
actions: [{ label: "Close", onClick: () => {} }],
|
|
1866
|
+
onMount: (modal, close) => {
|
|
1867
|
+
modal.querySelectorAll("[data-restore]").forEach(btn => {
|
|
1868
|
+
btn.addEventListener("click", async (e) => {
|
|
1869
|
+
e.stopPropagation();
|
|
1870
|
+
const rev = btn.getAttribute("data-restore");
|
|
1871
|
+
if (!confirm("Restore revision\n" + fmtRev(rev) + "?\n\n(creates a new revision, so this is undoable.)")) return;
|
|
1872
|
+
try {
|
|
1873
|
+
const result = await httpReq("POST",
|
|
1874
|
+
"/api/projects/" + encodeURIComponent(app.currentProjectId)
|
|
1875
|
+
+ "/revisions/" + encodeURIComponent(rev) + "/restore");
|
|
1876
|
+
if (result && result.snapshot) app.store.applyRemote(result.snapshot);
|
|
1877
|
+
else await reloadStore();
|
|
1878
|
+
flashStatus("restored " + fmtRev(rev), { kind: "ok" });
|
|
1879
|
+
close();
|
|
1880
|
+
} catch (err) {
|
|
1881
|
+
flashStatus(err.message || "restore failed", { kind: "error" });
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function openDeleteProjectDialog() {
|
|
1890
|
+
if (!app.currentProjectId) return;
|
|
1891
|
+
const pid = app.currentProjectId;
|
|
1892
|
+
showModal({
|
|
1893
|
+
title: "Delete project?",
|
|
1894
|
+
sub: `Project "${pid}" wird in den Soft-Delete-Status versetzt. Diese Aktion ist über die Revision-History rückgängig zu machen.`,
|
|
1895
|
+
actions: [
|
|
1896
|
+
{ label: "Cancel", onClick: () => {} },
|
|
1897
|
+
{
|
|
1898
|
+
label: "Delete", destructive: true,
|
|
1899
|
+
onClick: async () => {
|
|
1900
|
+
try {
|
|
1901
|
+
if (app.adapter.name === "Http") {
|
|
1902
|
+
await httpReq("DELETE", "/api/projects/" + encodeURIComponent(pid));
|
|
1903
|
+
} else {
|
|
1904
|
+
await app.adapter.delete(pid);
|
|
1905
|
+
}
|
|
1906
|
+
app.currentProjectId = null;
|
|
1907
|
+
if (app.unmountStoryMap) { app.unmountStoryMap(); app.unmountStoryMap = null; }
|
|
1908
|
+
if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
|
|
1909
|
+
closeSettings(); // if the settings overlay was open, hide it too
|
|
1910
|
+
writeLastProjectIdToStorage(null);
|
|
1911
|
+
updateCurrentProjectIndicator();
|
|
1912
|
+
flashStatus("deleted: " + pid, { kind: "ok" });
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
flashStatus(err.message || "delete failed", { kind: "error" });
|
|
1915
|
+
return false;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
]
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ---- Menu Bar wiring -------------------------------------------------
|
|
1924
|
+
|
|
1925
|
+
// ---- SM-15: project Export / Import (JSON) --------------------------
|
|
1926
|
+
|
|
1927
|
+
function exportCurrentProject() {
|
|
1928
|
+
if (!app.currentProjectId || !app.store) {
|
|
1929
|
+
flashStatus("no project to export", { kind: "error" });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const snap = app.store.get();
|
|
1933
|
+
const text = projectIO.serializeProject(snap, { exportedAt: new Date().toISOString() });
|
|
1934
|
+
triggerDownload(projectIO.exportFilename(snap), text);
|
|
1935
|
+
flashStatus("exported " + (snap.project && snap.project.id), { kind: "ok" });
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// Thin browser glue: stream `text` to the user as a file download. Guarded
|
|
1939
|
+
// so a JSDOM smoke (no Blob/URL.createObjectURL) doesn't throw.
|
|
1940
|
+
function triggerDownload(filename, text) {
|
|
1941
|
+
try {
|
|
1942
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
1943
|
+
const url = URL.createObjectURL(blob);
|
|
1944
|
+
const a = document.createElement("a");
|
|
1945
|
+
a.href = url;
|
|
1946
|
+
a.download = filename;
|
|
1947
|
+
document.body.appendChild(a);
|
|
1948
|
+
a.click();
|
|
1949
|
+
document.body.removeChild(a);
|
|
1950
|
+
setTimeout(() => { try { URL.revokeObjectURL(url); } catch (_) {} }, 1000);
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
flashStatus("download failed: " + (err.message || "error"), { kind: "error" });
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// SM-295: the old file-picker-only project-import UI (no paste, no preview)
|
|
1957
|
+
// is gone — the unified Import… dialog hosts the project branch now.
|
|
1958
|
+
|
|
1959
|
+
// SM-295 (review): THROWS on failure — the import dialog owns the error
|
|
1960
|
+
// path (flash + keep the modal open so the pasted text is not lost).
|
|
1961
|
+
async function importSnapshot(snap, targetId, isOverwrite) {
|
|
1962
|
+
const pinned = projectIO.withProjectId(snap, targetId);
|
|
1963
|
+
if (app.adapter.name === "Http") {
|
|
1964
|
+
if (!isOverwrite) {
|
|
1965
|
+
await httpReq("POST", "/api/projects", {
|
|
1966
|
+
id: targetId,
|
|
1967
|
+
name: (pinned.project && pinned.project.name) || targetId,
|
|
1968
|
+
ticketPrefix: (pinned.project && pinned.project.ticketPrefix) || "P"
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
await httpReq("PUT", "/api/projects/" + encodeURIComponent(targetId), pinned);
|
|
1972
|
+
} else {
|
|
1973
|
+
await app.adapter.save(targetId, pinned);
|
|
1974
|
+
}
|
|
1975
|
+
await loadProject(targetId);
|
|
1976
|
+
flashStatus("imported project: " + targetId, { kind: "ok" });
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function mountMenu() {
|
|
1980
|
+
uiShell.mountMenuBar($("menu-bar"), [
|
|
1981
|
+
{
|
|
1982
|
+
id: "project",
|
|
1983
|
+
label: "Project",
|
|
1984
|
+
items: [
|
|
1985
|
+
{ label: "New project…", shortcut: "Mod+N", action: () => openNewProjectDialog() },
|
|
1986
|
+
{ label: "Load project…", shortcut: "Mod+O", action: () => openLoadProjectDialog() },
|
|
1987
|
+
{ type: "separator" },
|
|
1988
|
+
// SM-295: ONE import + ONE export entry (previously four). The
|
|
1989
|
+
// import dialog auto-detects project envelope vs. ticket rows;
|
|
1990
|
+
// the export chooser offers project JSON vs. tickets CSV.
|
|
1991
|
+
{ label: "Import…", action: () => openImportDialog() },
|
|
1992
|
+
{ label: "Export…",
|
|
1993
|
+
disabled: () => !app.currentProjectId || !app.store,
|
|
1994
|
+
action: () => openExportChooser() },
|
|
1995
|
+
{ type: "separator" },
|
|
1996
|
+
{ label: "Settings…",
|
|
1997
|
+
disabled: () => !app.currentProjectId,
|
|
1998
|
+
action: () => openSettings() },
|
|
1999
|
+
{ label: "Attachments…",
|
|
2000
|
+
disabled: () => !app.currentProjectId,
|
|
2001
|
+
action: () => openProjectAttachmentsDialog() },
|
|
2002
|
+
{ type: "separator" },
|
|
2003
|
+
{
|
|
2004
|
+
label: "Delete current project…",
|
|
2005
|
+
disabled: () => !app.currentProjectId,
|
|
2006
|
+
action: () => openDeleteProjectDialog()
|
|
2007
|
+
}
|
|
2008
|
+
]
|
|
2009
|
+
},
|
|
2010
|
+
{
|
|
2011
|
+
// E22 — Edit menu. Soaks up the actions that used to live in the
|
|
2012
|
+
// toolbar (Undo/Redo, Add Ticket/Release/Process Step) plus History
|
|
2013
|
+
// (moved out of Project menu). Cut/Copy/Paste are placeholders for
|
|
2014
|
+
// now — storymap has no multi-select / clipboard concept yet, so
|
|
2015
|
+
// they're disabled. They occupy the slot so the menu reads like
|
|
2016
|
+
// the user's mental model from cmapper.
|
|
2017
|
+
id: "edit",
|
|
2018
|
+
label: "Edit",
|
|
2019
|
+
items: [
|
|
2020
|
+
{ label: "Undo", shortcut: "Mod+Z",
|
|
2021
|
+
disabled: () => !(app.store && app.store.canUndo()),
|
|
2022
|
+
action: () => app.store && app.store.undo() },
|
|
2023
|
+
{ label: "Redo", shortcut: "Mod+Shift+Z",
|
|
2024
|
+
disabled: () => !(app.store && app.store.canRedo()),
|
|
2025
|
+
action: () => app.store && app.store.redo() },
|
|
2026
|
+
{ type: "separator" },
|
|
2027
|
+
{ label: "Cut", shortcut: "Mod+X",
|
|
2028
|
+
disabled: () => true, // E22: placeholder; no selection model yet
|
|
2029
|
+
action: () => {} },
|
|
2030
|
+
{ label: "Copy", shortcut: "Mod+C",
|
|
2031
|
+
disabled: () => true,
|
|
2032
|
+
action: () => {} },
|
|
2033
|
+
{ label: "Paste", shortcut: "Mod+V",
|
|
2034
|
+
disabled: () => true,
|
|
2035
|
+
action: () => {} },
|
|
2036
|
+
{ type: "separator" },
|
|
2037
|
+
{ label: "Add Ticket…", shortcut: "Mod+T",
|
|
2038
|
+
disabled: () => !app.currentProjectId,
|
|
2039
|
+
// SM-253: default to the newest release (unplaced) so the ticket is
|
|
2040
|
+
// visible in the Story Map's holding strip — there is no backlog to
|
|
2041
|
+
// hold release-less tickets anymore.
|
|
2042
|
+
action: () => rendererTicketModal.openTypePickerThenCreate(
|
|
2043
|
+
buildTicketModalCtx(), { releaseId: newestReleaseId() }) },
|
|
2044
|
+
{ label: "Add Release…",
|
|
2045
|
+
disabled: () => !app.currentProjectId,
|
|
2046
|
+
action: () => openNewReleaseDialog() },
|
|
2047
|
+
{ label: "Add Process Step…",
|
|
2048
|
+
disabled: () => !app.currentProjectId,
|
|
2049
|
+
action: () => openNewProcessStepDialog() },
|
|
2050
|
+
{ type: "separator" },
|
|
2051
|
+
{ label: "History…",
|
|
2052
|
+
disabled: () => !app.currentProjectId,
|
|
2053
|
+
action: () => openHistoryDialog() }
|
|
2054
|
+
]
|
|
2055
|
+
},
|
|
2056
|
+
{
|
|
2057
|
+
// SM-205 — View menu. View switching used to be toolbar toggle buttons;
|
|
2058
|
+
// now it's a top-level menu alongside Project + Edit. The active view
|
|
2059
|
+
// shows a ✓ in the check gutter — `checked` is a predicate re-evaluated
|
|
2060
|
+
// each time the dropdown is rebuilt on open.
|
|
2061
|
+
id: "view",
|
|
2062
|
+
label: "View",
|
|
2063
|
+
items: [
|
|
2064
|
+
viewMenuItem("storymap", "Map"),
|
|
2065
|
+
viewMenuItem("kanban", "Kanban"),
|
|
2066
|
+
viewMenuItem("table", "Table"),
|
|
2067
|
+
viewMenuItem("dependencies", "Dependencies"),
|
|
2068
|
+
viewMenuItem("requirements", "Requirements"),
|
|
2069
|
+
viewMenuItem("processSteps", "Process Steps"),
|
|
2070
|
+
{ type: "separator" },
|
|
2071
|
+
// SM-271: surface the existing hideCompletedReleases board filter as a
|
|
2072
|
+
// discoverable View toggle (it was buried in the Filter popover). The
|
|
2073
|
+
// ✓ re-evaluates each time the dropdown opens; setFilter persists +
|
|
2074
|
+
// remounts so Map + Kanban hide completed releases immediately.
|
|
2075
|
+
{
|
|
2076
|
+
label: "Hide completed releases",
|
|
2077
|
+
checked: () => !!(app.filter && app.filter.hideCompletedReleases),
|
|
2078
|
+
action: () => {
|
|
2079
|
+
const cur = Object.assign({ statuses: null, types: null, hideCompletedReleases: false }, app.filter || {});
|
|
2080
|
+
cur.hideCompletedReleases = !cur.hideCompletedReleases;
|
|
2081
|
+
setFilter(cur);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
]
|
|
2085
|
+
}
|
|
2086
|
+
]);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// SM-205 — one View-menu entry. `checked` drives the gutter ✓ (re-evaluated
|
|
2090
|
+
// each time the dropdown opens), so the label text stays in one column.
|
|
2091
|
+
function viewMenuItem(view, name) {
|
|
2092
|
+
return {
|
|
2093
|
+
label: name,
|
|
2094
|
+
checked: () => app.activeView === view,
|
|
2095
|
+
action: () => switchView(view)
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function wireToolbar() {
|
|
2100
|
+
// E22 + SM-205: the toolbar no longer hosts action buttons OR view toggles
|
|
2101
|
+
// — those moved to the Edit and View menus. Only the current-project
|
|
2102
|
+
// indicator and the SM-82 filter button remain wired here.
|
|
2103
|
+
const cpAnchor = $("current-project");
|
|
2104
|
+
if (cpAnchor) cpAnchor.addEventListener("click", () => openLoadProjectDialog());
|
|
2105
|
+
// SM-82: filter button + popover.
|
|
2106
|
+
const filterBtn = $("btn-filter");
|
|
2107
|
+
if (filterBtn) {
|
|
2108
|
+
filterBtn.addEventListener("click", (ev) => {
|
|
2109
|
+
ev.stopPropagation();
|
|
2110
|
+
toggleFilterPopover(filterBtn);
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
refreshFilterBadge();
|
|
2114
|
+
// SM-16: live ticket search (highlight matches, fade the rest).
|
|
2115
|
+
const searchInput = $("ticket-search");
|
|
2116
|
+
if (searchInput) {
|
|
2117
|
+
// SM-191: the search box is now the unified smart-bar (simple OR JQL).
|
|
2118
|
+
uiShell.wireDebounced(searchInput, () => {
|
|
2119
|
+
applySmartBar(searchInput.value || "");
|
|
2120
|
+
}, 200);
|
|
2121
|
+
searchInput.addEventListener("keydown", (ev) => {
|
|
2122
|
+
if (ev.key === "Escape") {
|
|
2123
|
+
searchInput.value = "";
|
|
2124
|
+
applySmartBar("");
|
|
2125
|
+
searchInput.blur();
|
|
2126
|
+
} else if (ev.key === "Enter") {
|
|
2127
|
+
// SM-293: Enter applies immediately (the autocomplete only consumes
|
|
2128
|
+
// Enter for an explicitly arrow-chosen suggestion).
|
|
2129
|
+
applySmartBar(searchInput.value || "");
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
// SM-216: Jira-style autocomplete dropdown on the smart-bar. The keydown
|
|
2133
|
+
// listener is capture-phase (added inside attachAutocomplete), so when the
|
|
2134
|
+
// dropdown is open it intercepts Enter/Tab/Esc before the handlers above.
|
|
2135
|
+
const ac = window.STORYMAP.smartbarAutocomplete;
|
|
2136
|
+
if (ac && ac.attachAutocomplete) {
|
|
2137
|
+
// The returned handle (incl. detach()) is intentionally discarded:
|
|
2138
|
+
// wireToolbar() runs once at boot and the input lives for the page
|
|
2139
|
+
// lifetime, so there is nothing to tear down.
|
|
2140
|
+
ac.attachAutocomplete(searchInput, {
|
|
2141
|
+
getSnapshot: () => (app.store ? app.store.get() : { tickets: [] }),
|
|
2142
|
+
onAccept: () => applySmartBar(searchInput.value || "")
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// ---- SM-82 Filter UI -------------------------------------------------
|
|
2149
|
+
//
|
|
2150
|
+
// The filter button toggles a fixed-positioned popover anchored under it.
|
|
2151
|
+
// The popover hosts checkboxes for statuses + types + a hide-completed
|
|
2152
|
+
// toggle. State lives in app.filter; localStorage is `storymap-filter-
|
|
2153
|
+
// <projectId>`. Filter changes trigger a remount of the active view so
|
|
2154
|
+
// the renderer picks up the new opts.filter.
|
|
2155
|
+
|
|
2156
|
+
function refreshFilterBadge() {
|
|
2157
|
+
const btn = $("btn-filter");
|
|
2158
|
+
const badge = $("filter-badge");
|
|
2159
|
+
if (!btn || !badge || !window.STORYMAP || !window.STORYMAP.filter) return;
|
|
2160
|
+
const n = window.STORYMAP.filter.countActiveRules(app.filter || {});
|
|
2161
|
+
btn.classList.toggle("active", n > 0);
|
|
2162
|
+
if (n > 0) { badge.hidden = false; badge.textContent = String(n); }
|
|
2163
|
+
else { badge.hidden = true; badge.textContent = "0"; }
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function setFilter(next) {
|
|
2167
|
+
// Empty/no-op filter → null (so renderer skips filter pipeline).
|
|
2168
|
+
const fmod = window.STORYMAP && window.STORYMAP.filter;
|
|
2169
|
+
const normalised = fmod ? fmod.normalizeFilter(next || {}) : (next || null);
|
|
2170
|
+
const active = normalised && (normalised.statuses || normalised.types || normalised.hideCompletedReleases);
|
|
2171
|
+
app.filter = active ? normalised : null;
|
|
2172
|
+
writeFilterToStorage(app.currentProjectId, app.filter);
|
|
2173
|
+
refreshFilterBadge();
|
|
2174
|
+
// Remount the active view so it picks up app.filter via opts.
|
|
2175
|
+
// SM-282: not the Table view — it ignores the chip filter (own query
|
|
2176
|
+
// line); a remount would only wipe the typed query for no effect. The
|
|
2177
|
+
// filter still applies on the next switch to Map/Kanban (fresh mount).
|
|
2178
|
+
if (app.store && app.activeView !== "table") {
|
|
2179
|
+
unmountActiveView();
|
|
2180
|
+
mountActiveView();
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
let _filterPopoverOpen = false;
|
|
2185
|
+
function toggleFilterPopover(anchorBtn) {
|
|
2186
|
+
if (_filterPopoverOpen) { closeFilterPopover(); return; }
|
|
2187
|
+
openFilterPopover(anchorBtn);
|
|
2188
|
+
}
|
|
2189
|
+
function closeFilterPopover() {
|
|
2190
|
+
const pop = $("filter-popover");
|
|
2191
|
+
if (pop && pop.parentNode) pop.parentNode.removeChild(pop);
|
|
2192
|
+
document.removeEventListener("click", _filterPopoverOutsideClick, true);
|
|
2193
|
+
document.removeEventListener("keydown", _filterPopoverEsc, true);
|
|
2194
|
+
_filterPopoverOpen = false;
|
|
2195
|
+
const btn = $("btn-filter");
|
|
2196
|
+
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
2197
|
+
}
|
|
2198
|
+
function _filterPopoverOutsideClick(ev) {
|
|
2199
|
+
const pop = document.getElementById("filter-popover");
|
|
2200
|
+
const btn = document.getElementById("btn-filter");
|
|
2201
|
+
if (!pop) return;
|
|
2202
|
+
if (pop.contains(ev.target)) return;
|
|
2203
|
+
if (btn && btn.contains(ev.target)) return;
|
|
2204
|
+
closeFilterPopover();
|
|
2205
|
+
}
|
|
2206
|
+
function _filterPopoverEsc(ev) {
|
|
2207
|
+
if (ev.key === "Escape") { ev.stopPropagation(); closeFilterPopover(); }
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function openFilterPopover(anchorBtn) {
|
|
2211
|
+
const snap = app.store ? app.store.get() : null;
|
|
2212
|
+
if (!snap) return;
|
|
2213
|
+
const project = snap.project || {};
|
|
2214
|
+
const workflowStatuses = (project.workflow && project.workflow.statuses) || [];
|
|
2215
|
+
const ticketTypes = (project.ticketTypes && project.ticketTypes.length)
|
|
2216
|
+
? project.ticketTypes : ["epic", "user-story", "bug"];
|
|
2217
|
+
const cur = app.filter || { statuses: null, types: null, hideCompletedReleases: false };
|
|
2218
|
+
|
|
2219
|
+
// Bulk-toggle helpers re-open the popover so checkbox visual state
|
|
2220
|
+
// reflects the new filter immediately.
|
|
2221
|
+
function reopen() {
|
|
2222
|
+
closeFilterPopover();
|
|
2223
|
+
setTimeout(() => openFilterPopover(anchorBtn), 0);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const pop = document.createElement("div");
|
|
2227
|
+
pop.id = "filter-popover";
|
|
2228
|
+
pop.setAttribute("role", "dialog");
|
|
2229
|
+
pop.setAttribute("aria-label", "Filter tickets");
|
|
2230
|
+
|
|
2231
|
+
function section(title, body, bulkActions) {
|
|
2232
|
+
const sec = document.createElement("section");
|
|
2233
|
+
sec.className = "filter-popover-section";
|
|
2234
|
+
const headerRow = document.createElement("div");
|
|
2235
|
+
headerRow.className = "filter-popover-section-header";
|
|
2236
|
+
const h = document.createElement("div");
|
|
2237
|
+
h.className = "filter-popover-section-title";
|
|
2238
|
+
h.textContent = title;
|
|
2239
|
+
headerRow.appendChild(h);
|
|
2240
|
+
if (bulkActions) {
|
|
2241
|
+
const actions = document.createElement("div");
|
|
2242
|
+
actions.className = "filter-popover-section-actions";
|
|
2243
|
+
// Bulk-toggles: All / None for multi-select sections.
|
|
2244
|
+
const allBtn = document.createElement("button");
|
|
2245
|
+
allBtn.type = "button";
|
|
2246
|
+
allBtn.className = "filter-popover-bulk";
|
|
2247
|
+
allBtn.textContent = "All";
|
|
2248
|
+
allBtn.addEventListener("click", () => bulkActions.selectAll());
|
|
2249
|
+
const noneBtn = document.createElement("button");
|
|
2250
|
+
noneBtn.type = "button";
|
|
2251
|
+
noneBtn.className = "filter-popover-bulk";
|
|
2252
|
+
noneBtn.textContent = "None";
|
|
2253
|
+
noneBtn.addEventListener("click", () => bulkActions.selectNone());
|
|
2254
|
+
actions.appendChild(allBtn);
|
|
2255
|
+
actions.appendChild(noneBtn);
|
|
2256
|
+
headerRow.appendChild(actions);
|
|
2257
|
+
}
|
|
2258
|
+
sec.appendChild(headerRow);
|
|
2259
|
+
if (body) sec.appendChild(body);
|
|
2260
|
+
return sec;
|
|
2261
|
+
}
|
|
2262
|
+
function checkrow(labelText, checked, onChange) {
|
|
2263
|
+
const row = document.createElement("label");
|
|
2264
|
+
row.className = "filter-popover-checkrow";
|
|
2265
|
+
const cb = document.createElement("input");
|
|
2266
|
+
cb.type = "checkbox";
|
|
2267
|
+
cb.checked = !!checked;
|
|
2268
|
+
cb.addEventListener("change", () => onChange(cb.checked));
|
|
2269
|
+
const span = document.createElement("span");
|
|
2270
|
+
span.textContent = labelText;
|
|
2271
|
+
row.appendChild(cb);
|
|
2272
|
+
row.appendChild(span);
|
|
2273
|
+
return row;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// Status section.
|
|
2277
|
+
const statusIds = workflowStatuses.map(s => typeof s === "string" ? s : s.id);
|
|
2278
|
+
const statusBody = document.createElement("div");
|
|
2279
|
+
for (const s of workflowStatuses) {
|
|
2280
|
+
const sid = typeof s === "string" ? s : s.id;
|
|
2281
|
+
const sName = typeof s === "string" ? s : (s.name || s.id);
|
|
2282
|
+
const checked = !!(cur.statuses && cur.statuses.indexOf(sid) >= 0);
|
|
2283
|
+
statusBody.appendChild(checkrow(sName, checked, (on) => {
|
|
2284
|
+
const list = (cur.statuses ? cur.statuses.slice() : []);
|
|
2285
|
+
const idx = list.indexOf(sid);
|
|
2286
|
+
if (on && idx < 0) list.push(sid);
|
|
2287
|
+
if (!on && idx >= 0) list.splice(idx, 1);
|
|
2288
|
+
cur.statuses = list;
|
|
2289
|
+
setFilter(cur);
|
|
2290
|
+
}));
|
|
2291
|
+
}
|
|
2292
|
+
pop.appendChild(section("Status", statusBody, {
|
|
2293
|
+
selectAll: () => { cur.statuses = statusIds.slice(); setFilter(cur); reopen(); },
|
|
2294
|
+
selectNone: () => { cur.statuses = null; setFilter(cur); reopen(); }
|
|
2295
|
+
}));
|
|
2296
|
+
|
|
2297
|
+
// Type section.
|
|
2298
|
+
const typeBody = document.createElement("div");
|
|
2299
|
+
for (const t of ticketTypes) {
|
|
2300
|
+
const checked = !!(cur.types && cur.types.indexOf(t) >= 0);
|
|
2301
|
+
typeBody.appendChild(checkrow(t, checked, (on) => {
|
|
2302
|
+
const list = (cur.types ? cur.types.slice() : []);
|
|
2303
|
+
const idx = list.indexOf(t);
|
|
2304
|
+
if (on && idx < 0) list.push(t);
|
|
2305
|
+
if (!on && idx >= 0) list.splice(idx, 1);
|
|
2306
|
+
cur.types = list;
|
|
2307
|
+
setFilter(cur);
|
|
2308
|
+
}));
|
|
2309
|
+
}
|
|
2310
|
+
pop.appendChild(section("Type", typeBody, {
|
|
2311
|
+
selectAll: () => { cur.types = ticketTypes.slice(); setFilter(cur); reopen(); },
|
|
2312
|
+
selectNone: () => { cur.types = null; setFilter(cur); reopen(); }
|
|
2313
|
+
}));
|
|
2314
|
+
|
|
2315
|
+
// Release-Status section: just one toggle.
|
|
2316
|
+
const relBody = document.createElement("div");
|
|
2317
|
+
relBody.appendChild(checkrow("Hide completed releases", cur.hideCompletedReleases, (on) => {
|
|
2318
|
+
cur.hideCompletedReleases = on;
|
|
2319
|
+
setFilter(cur);
|
|
2320
|
+
}));
|
|
2321
|
+
pop.appendChild(section("Releases", relBody));
|
|
2322
|
+
|
|
2323
|
+
// Footer: reset link.
|
|
2324
|
+
const footer = document.createElement("div");
|
|
2325
|
+
footer.className = "filter-popover-footer";
|
|
2326
|
+
const reset = document.createElement("button");
|
|
2327
|
+
reset.type = "button";
|
|
2328
|
+
reset.className = "filter-popover-reset";
|
|
2329
|
+
reset.textContent = "Reset filter";
|
|
2330
|
+
reset.addEventListener("click", () => {
|
|
2331
|
+
setFilter(null);
|
|
2332
|
+
closeFilterPopover();
|
|
2333
|
+
});
|
|
2334
|
+
footer.appendChild(reset);
|
|
2335
|
+
pop.appendChild(footer);
|
|
2336
|
+
|
|
2337
|
+
document.body.appendChild(pop);
|
|
2338
|
+
|
|
2339
|
+
// Anchor below the button, right-aligned to the button's right edge.
|
|
2340
|
+
const rect = anchorBtn.getBoundingClientRect();
|
|
2341
|
+
const popW = pop.offsetWidth || 280;
|
|
2342
|
+
let left = rect.right - popW;
|
|
2343
|
+
if (left < 8) left = 8;
|
|
2344
|
+
pop.style.left = left + "px";
|
|
2345
|
+
pop.style.top = (rect.bottom + 6) + "px";
|
|
2346
|
+
|
|
2347
|
+
_filterPopoverOpen = true;
|
|
2348
|
+
anchorBtn.setAttribute("aria-expanded", "true");
|
|
2349
|
+
// Outside-click + Esc handlers (capture phase so we win against bubbling).
|
|
2350
|
+
setTimeout(() => {
|
|
2351
|
+
document.addEventListener("click", _filterPopoverOutsideClick, true);
|
|
2352
|
+
document.addEventListener("keydown", _filterPopoverEsc, true);
|
|
2353
|
+
}, 0);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Global keyboard shortcuts. Skip when focus is in an editable element
|
|
2358
|
+
* so we don't intercept the user's actual text-input typing/undo.
|
|
2359
|
+
*/
|
|
2360
|
+
function wireKeyboardShortcuts() {
|
|
2361
|
+
function isEditable(el) {
|
|
2362
|
+
if (!el) return false;
|
|
2363
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
2364
|
+
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
|
2365
|
+
if (el.isContentEditable) return true;
|
|
2366
|
+
return false;
|
|
2367
|
+
}
|
|
2368
|
+
document.addEventListener("keydown", (ev) => {
|
|
2369
|
+
if (!app.store) return;
|
|
2370
|
+
if (isEditable(ev.target)) return;
|
|
2371
|
+
const mod = ev.metaKey || ev.ctrlKey;
|
|
2372
|
+
if (!mod) return;
|
|
2373
|
+
const key = (ev.key || "").toLowerCase();
|
|
2374
|
+
if (key === "z" && !ev.shiftKey) {
|
|
2375
|
+
ev.preventDefault();
|
|
2376
|
+
app.store.undo();
|
|
2377
|
+
} else if ((key === "z" && ev.shiftKey) || key === "y") {
|
|
2378
|
+
ev.preventDefault();
|
|
2379
|
+
app.store.redo();
|
|
2380
|
+
}
|
|
2381
|
+
}, true);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* E20.C — MCP-driven project switch. When the server pushes a
|
|
2386
|
+
* `switch_request` frame (because a standalone MCP tool called
|
|
2387
|
+
* `request_switch_project`), show a confirm modal and route the verdict
|
|
2388
|
+
* back. Accept also triggers `loadProject(workspace)` so the user lands
|
|
2389
|
+
* on the prepared project immediately.
|
|
2390
|
+
*/
|
|
2391
|
+
function wireSwitchRequestHandler() {
|
|
2392
|
+
if (!app.adapter || typeof app.adapter.onSwitchRequest !== "function") return;
|
|
2393
|
+
app.adapter.onSwitchRequest(({ workspace, reason, requestId }) => {
|
|
2394
|
+
// SM-29: if the requested project is already the one we're viewing,
|
|
2395
|
+
// there's nothing to switch to. Silently accept and short-circuit —
|
|
2396
|
+
// no modal interrupts the user for a no-op. The MCP caller still gets
|
|
2397
|
+
// its `{accepted:true}` resolution so callers that wait_seconds>0
|
|
2398
|
+
// don't time out.
|
|
2399
|
+
if (workspace && app.currentProjectId === workspace) {
|
|
2400
|
+
app.adapter.sendSwitchResponse(requestId, true);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
const safeWorkspace = escapeHtml(workspace || "");
|
|
2404
|
+
const safeReason = reason ? escapeHtml(reason) : null;
|
|
2405
|
+
const bodyHTML = safeReason
|
|
2406
|
+
? `<p>An MCP client wants to switch you to project <code>${safeWorkspace}</code>.</p><p class="modal-sub">${safeReason}</p>`
|
|
2407
|
+
: `<p>An MCP client wants to switch you to project <code>${safeWorkspace}</code>.</p>`;
|
|
2408
|
+
// If the user closes the modal via Esc / outside-click without
|
|
2409
|
+
// clicking either action, the server-side long-poll will time out
|
|
2410
|
+
// (default 30 s) — the MCP tool resolves with timedOut:true. We
|
|
2411
|
+
// accept that fallback rather than trying to monkey-patch the
|
|
2412
|
+
// modal's close handler.
|
|
2413
|
+
showModal({
|
|
2414
|
+
title: "Switch to another project?",
|
|
2415
|
+
sub: "Initiated by Claude / MCP",
|
|
2416
|
+
bodyHTML: bodyHTML,
|
|
2417
|
+
actions: [
|
|
2418
|
+
{ label: "Cancel", onClick: () => {
|
|
2419
|
+
app.adapter.sendSwitchResponse(requestId, false);
|
|
2420
|
+
} },
|
|
2421
|
+
{ label: "Switch", primary: true, onClick: async () => {
|
|
2422
|
+
app.adapter.sendSwitchResponse(requestId, true);
|
|
2423
|
+
try { await loadProject(workspace); }
|
|
2424
|
+
catch (err) { flashStatus("could not switch: " + err.message, { kind: "error" }); }
|
|
2425
|
+
} }
|
|
2426
|
+
]
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// ---- Bootstrap -------------------------------------------------------
|
|
2432
|
+
|
|
2433
|
+
async function fetchBuildInfo() {
|
|
2434
|
+
// Only meaningful in Http-Mode — local modes have no server to ask.
|
|
2435
|
+
if (!app.adapter || app.adapter.name !== "Http") return;
|
|
2436
|
+
try {
|
|
2437
|
+
const res = await fetch(app.adapter.base + "/api/build-info");
|
|
2438
|
+
if (!res.ok) return;
|
|
2439
|
+
const info = await res.json();
|
|
2440
|
+
const el = $("build-tag");
|
|
2441
|
+
if (el && info.commit) {
|
|
2442
|
+
el.textContent = "build " + info.commit;
|
|
2443
|
+
el.title = (info.subject || info.commit) + (info.committedAt ? " · " + info.committedAt : "");
|
|
2444
|
+
}
|
|
2445
|
+
} catch (_) { /* ignore */ }
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
async function init() {
|
|
2449
|
+
// SM-99: pickAdapter is async — probes /api/health on the current
|
|
2450
|
+
// origin before falling back to localStorage. The `?api=<url>`
|
|
2451
|
+
// override is still honoured first.
|
|
2452
|
+
app.adapter = await pickAdapter({ url: window.location.href });
|
|
2453
|
+
$("adapter-badge").textContent = app.adapter.name;
|
|
2454
|
+
fetchBuildInfo();
|
|
2455
|
+
mountMenu();
|
|
2456
|
+
wireToolbar();
|
|
2457
|
+
wireKeyboardShortcuts();
|
|
2458
|
+
wireSwitchRequestHandler();
|
|
2459
|
+
// SM-281: Shift+Scrollrad → horizontal. Safari übersetzt shift+wheel
|
|
2460
|
+
// nicht selbst; ohne das kommt eine Maus mit rein vertikalem Rad nie
|
|
2461
|
+
// an rechts außerhalb liegende Process-Steps.
|
|
2462
|
+
if (SM.wheelPan) SM.wheelPan.installShiftWheelPan($("view-host"));
|
|
2463
|
+
// SM-244: when the board page is restored from the browser bfcache (e.g.
|
|
2464
|
+
// navigating BACK from the full-page editor after cancelling/deleting a
|
|
2465
|
+
// ticket there), the WebSocket was frozen and the store is stale — reload
|
|
2466
|
+
// the current project from the server so the change is reflected.
|
|
2467
|
+
window.addEventListener("pageshow", (ev) => {
|
|
2468
|
+
if (ev && ev.persisted && app.currentProjectId) reloadStore();
|
|
2469
|
+
});
|
|
2470
|
+
try {
|
|
2471
|
+
const ids = await app.adapter.list();
|
|
2472
|
+
if (ids.length === 0) {
|
|
2473
|
+
flashStatus("no projects yet — Project ▸ New project…");
|
|
2474
|
+
} else {
|
|
2475
|
+
// Prefer the last-opened project (localStorage). If it's gone (deleted
|
|
2476
|
+
// or just doesn't exist on this server), fall back to the first one.
|
|
2477
|
+
const lastId = readLastProjectIdFromStorage();
|
|
2478
|
+
const target = (lastId && ids.indexOf(lastId) >= 0) ? lastId : ids[0];
|
|
2479
|
+
await loadProject(target);
|
|
2480
|
+
}
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
console.error(err);
|
|
2483
|
+
flashStatus("init failed: " + err.message, { kind: "error" });
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// ---- Small helpers ---------------------------------------------------
|
|
2488
|
+
|
|
2489
|
+
function escapeHtml(s) {
|
|
2490
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2491
|
+
}
|
|
2492
|
+
function escapeAttr(s) { return escapeHtml(s); }
|
|
2493
|
+
|
|
2494
|
+
if (document.readyState === "loading") {
|
|
2495
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
2496
|
+
} else {
|
|
2497
|
+
init();
|
|
2498
|
+
}
|
|
2499
|
+
}());
|