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,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban-Renderer (E11).
|
|
3
|
+
*
|
|
4
|
+
* Operative Sicht auf das Projekt: eine Lane pro Status, jede Lane zeigt
|
|
5
|
+
* alle non-epic Tickets in diesem Status, sortiert nach `sortOrder`.
|
|
6
|
+
* Epics gehören nicht in die Kanban-Ansicht (sie sind Container, keine
|
|
7
|
+
* Work-Items).
|
|
8
|
+
*
|
|
9
|
+
* Reorder-Mechanik (siehe Memory [[lists-are-sortable-by-default]]):
|
|
10
|
+
* - Drag zwischen Lanes: Status-Wechsel via POST /tickets/:tid/status.
|
|
11
|
+
* DoR/DoD-Gates auf dem Server greifen; bei 422 (`{kind, missing}`)
|
|
12
|
+
* wird das Modal-Banner-Pattern wiederverwendet (flashStatus + Revert).
|
|
13
|
+
* - Drag innerhalb einer Lane: Sortier-Reorder via POST /tickets/reorder
|
|
14
|
+
* OHNE scope. Der orderedIds-Set sind alle Tickets dieser Lane in
|
|
15
|
+
* neuer Reihenfolge. Server setzt sortOrder = idx (Container-Felder
|
|
16
|
+
* bleiben unverändert). Das wirkt rückwirkend auf die Story-Map-
|
|
17
|
+
* Sortierung innerhalb des Epic, weil beide Views auf demselben
|
|
18
|
+
* sortOrder-Feld lesen.
|
|
19
|
+
*
|
|
20
|
+
* Mount API:
|
|
21
|
+
* mount(host, store, opts) → { unmount }
|
|
22
|
+
* opts.onTicketClick(ticketId) — Doppelklick öffnet Detail-Modal
|
|
23
|
+
* opts.onTicketStatusChange(id, newStatus) → Promise<void>
|
|
24
|
+
* opts.onLaneReorder(laneStatus, orderedIds) → Promise<void>
|
|
25
|
+
*
|
|
26
|
+
* Alle Layout-Werte in KANBAN_LAYOUT (keine Magic Numbers im Code).
|
|
27
|
+
*/
|
|
28
|
+
(function (root, factory) {
|
|
29
|
+
if (typeof module === "object" && module.exports) module.exports = factory(
|
|
30
|
+
require("./core.js"), require("./dnd.js"), require("./renderer-card.js"), require("./card-animate.js"), require("./filter.js"));
|
|
31
|
+
else (root.STORYMAP = root.STORYMAP || {}).rendererKanban = factory(
|
|
32
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
33
|
+
root.STORYMAP && root.STORYMAP.dnd,
|
|
34
|
+
root.STORYMAP && root.STORYMAP.rendererCard,
|
|
35
|
+
root.STORYMAP && root.STORYMAP.cardAnimate,
|
|
36
|
+
root.STORYMAP && root.STORYMAP.filter
|
|
37
|
+
);
|
|
38
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd, rendererCard, cardAnimate, filterMod) {
|
|
39
|
+
"use strict";
|
|
40
|
+
|
|
41
|
+
if (!core) throw new Error("renderer-kanban: core module missing");
|
|
42
|
+
if (!dnd) throw new Error("renderer-kanban: dnd module missing");
|
|
43
|
+
if (!rendererCard) throw new Error("renderer-kanban: renderer-card module missing");
|
|
44
|
+
// filterMod is optional — older callers / tests may mount without it.
|
|
45
|
+
|
|
46
|
+
// ---- Konstanten ----------------------------------------------------
|
|
47
|
+
const KANBAN_LAYOUT = {
|
|
48
|
+
LANE_MIN_WIDTH_PX: 260,
|
|
49
|
+
CARD_GAP_PX: 6,
|
|
50
|
+
HEADER_HEIGHT_PX: 40,
|
|
51
|
+
LANE_PADDING_PX: 10,
|
|
52
|
+
CARD_PADDING_PX: 8
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Drag-type that's exclusive to the Kanban view (so a card dragged inside
|
|
56
|
+
// Kanban doesn't accidentally trigger a Story-Map drop-target).
|
|
57
|
+
const DRAG_TYPE = "kanban-card";
|
|
58
|
+
|
|
59
|
+
// SM-170: which workflow status-categories show the card-aging badge. Aging
|
|
60
|
+
// is a signal for ACTIVE work that's sitting too long — backlog/ready (todo)
|
|
61
|
+
// and done are excluded (a backlog item aging is normal; a done one is moot).
|
|
62
|
+
const AGING_CATEGORIES = ["doing", "blocked"];
|
|
63
|
+
|
|
64
|
+
// ---- Drag-projection state (SM-27) --------------------------------
|
|
65
|
+
//
|
|
66
|
+
// Mirrors the Story-Map's `_dragProjection` pattern (see
|
|
67
|
+
// renderer-storymap.js#effectiveStoriesFor). While a card is being
|
|
68
|
+
// dragged, `_dragProjection` holds `{ticketId, columnId, insertionIndex}`
|
|
69
|
+
// — the renderer pulls the dragged ticket out of every lane and inserts
|
|
70
|
+
// a `.sm-card-shadow` placeholder at the projection target. Setting and
|
|
71
|
+
// clearing the projection triggers a re-render, so the lane visibly
|
|
72
|
+
// reflows under the cursor instead of waiting for the drop.
|
|
73
|
+
let _dragProjection = null;
|
|
74
|
+
let _rerender = null;
|
|
75
|
+
let _linkIndex = null; // SM-49: per-render cache
|
|
76
|
+
let _lastExecIndex = null; // SM-60: per-render cache (last-execution per test-definition)
|
|
77
|
+
let _projectId = null; // SM-158: for ticket-key → editor links
|
|
78
|
+
let _agingStatuses = null; // SM-170: Set<statusId> whose cards show the aging badge
|
|
79
|
+
|
|
80
|
+
function setProjection(p) {
|
|
81
|
+
if (_dragProjection && p
|
|
82
|
+
&& _dragProjection.ticketId === p.ticketId
|
|
83
|
+
&& _dragProjection.swimlaneKey === p.swimlaneKey
|
|
84
|
+
&& _dragProjection.columnId === p.columnId
|
|
85
|
+
&& _dragProjection.insertionIndex === p.insertionIndex) {
|
|
86
|
+
return; // no-op: same projection, avoid render loops
|
|
87
|
+
}
|
|
88
|
+
_dragProjection = p;
|
|
89
|
+
if (typeof _rerender === "function") _rerender();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearProjection() {
|
|
93
|
+
if (_dragProjection === null) return;
|
|
94
|
+
_dragProjection = null;
|
|
95
|
+
if (typeof _rerender === "function") _rerender();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute a cell's tickets after applying the active drag projection. The
|
|
100
|
+
* dragged ticket is removed from every cell and re-inserted as a shadow at
|
|
101
|
+
* insertionIndex in the target cell (same swimlane + same column). Returns
|
|
102
|
+
* `[{ticket, shadow}]` pairs. Cross-swimlane drags never project a shadow —
|
|
103
|
+
* the Kanban only moves within a release row (release changes live in the Map).
|
|
104
|
+
*/
|
|
105
|
+
function effectiveCellTickets(swimlaneKey, columnId, cellTickets, snapshot) {
|
|
106
|
+
if (!_dragProjection) return cellTickets.map(t => ({ ticket: t, shadow: false }));
|
|
107
|
+
const out = cellTickets
|
|
108
|
+
.filter(t => t.id !== _dragProjection.ticketId)
|
|
109
|
+
.map(t => ({ ticket: t, shadow: false }));
|
|
110
|
+
if (_dragProjection.swimlaneKey === swimlaneKey && _dragProjection.columnId === columnId) {
|
|
111
|
+
const dragged = (snapshot.tickets || []).find(t => t.id === _dragProjection.ticketId);
|
|
112
|
+
if (dragged) {
|
|
113
|
+
const idx = Math.max(0, Math.min(out.length, _dragProjection.insertionIndex || 0));
|
|
114
|
+
out.splice(idx, 0, { ticket: dragged, shadow: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Type→Cluster mapping kommt aus renderer-card.js (geteilt mit Story-Map).
|
|
121
|
+
|
|
122
|
+
// ---- DOM-Helper ----------------------------------------------------
|
|
123
|
+
function el(tag, props, children) {
|
|
124
|
+
const e = document.createElement(tag);
|
|
125
|
+
if (props) {
|
|
126
|
+
for (const k of Object.keys(props)) {
|
|
127
|
+
if (k === "class") e.className = props[k];
|
|
128
|
+
else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
|
|
129
|
+
else if (k === "style") Object.assign(e.style, props[k]);
|
|
130
|
+
else if (k === "text") e.textContent = props[k];
|
|
131
|
+
else if (k === "html") e.innerHTML = props[k];
|
|
132
|
+
else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
133
|
+
else e.setAttribute(k, props[k]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
137
|
+
return e;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Pure layout ---------------------------------------------------
|
|
141
|
+
|
|
142
|
+
// Sentinel key for the "no release / unscheduled" swimlane.
|
|
143
|
+
const NO_RELEASE_KEY = "__none__";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* SM-169: group all non-epic tickets into a (release × status-column) grid.
|
|
147
|
+
* Returns:
|
|
148
|
+
* { columns: [{id, name, statusIds, status?, orphan?}],
|
|
149
|
+
* swimlanes: [{ release: {id,name,status} | null, key, collapsed,
|
|
150
|
+
* count, cells: {[columnId]: ticket[]} }] }
|
|
151
|
+
*
|
|
152
|
+
* One swimlane per release that holds ≥1 (filtered) ticket — ordered by the
|
|
153
|
+
* release sortOrder — plus a trailing "No release" swimlane (releaseId=null)
|
|
154
|
+
* that is ALWAYS present (it is the unscheduled/backlog home + hosts the
|
|
155
|
+
* "+ Add Item" button). `columns` is the board's Kanban columns (E21.C) plus
|
|
156
|
+
* synthetic trailing columns for any status that no column covers. A status
|
|
157
|
+
* column's `status` shortcut is set only for 1-status columns (backward-compat
|
|
158
|
+
* with `[data-status]` selectors). `opts.collapsedReleases` is a Set of
|
|
159
|
+
* release-ids whose swimlane should render collapsed (cells hidden).
|
|
160
|
+
*/
|
|
161
|
+
function computeKanbanLayout(snapshot, opts) {
|
|
162
|
+
opts = opts || {};
|
|
163
|
+
const filter = opts.filter || null;
|
|
164
|
+
const collapsed = opts.collapsedReleases instanceof Set ? opts.collapsedReleases : new Set();
|
|
165
|
+
// SM-82: per-ticket filter (incl. hideCompletedReleases).
|
|
166
|
+
const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
|
|
167
|
+
// SM-191: smart-bar query predicate, AND-combined with the chip filter.
|
|
168
|
+
const matchTicket = (typeof opts.matchTicket === "function") ? opts.matchTicket : null;
|
|
169
|
+
const filterMatch = (filterMod && filter)
|
|
170
|
+
? (t) => filterMod.matchesFilter(t, filter, filterCtx)
|
|
171
|
+
: (_t) => true;
|
|
172
|
+
const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
|
|
173
|
+
|
|
174
|
+
const board = snapshot && snapshot.project && snapshot.project.boards
|
|
175
|
+
&& snapshot.project.boards.kanban;
|
|
176
|
+
const fallbackStatuses = core.DEFAULT_STATUSES.slice();
|
|
177
|
+
const baseColumns = (board && Array.isArray(board.columns) && board.columns.length > 0)
|
|
178
|
+
? board.columns
|
|
179
|
+
: fallbackStatuses.map(s => ({ id: "col-" + s, name: s, statusIds: [s] }));
|
|
180
|
+
|
|
181
|
+
const tickets = (snapshot.tickets || [])
|
|
182
|
+
.filter(t => !t.isDeleted && core.isBoardWorkItem(t.type) && matches(t));
|
|
183
|
+
tickets.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
|
|
184
|
+
- ((b.position && b.position.sortOrder) || 0));
|
|
185
|
+
|
|
186
|
+
// Full column set = board columns + synthetic columns for stranded statuses.
|
|
187
|
+
const columns = baseColumns.map(col => {
|
|
188
|
+
const c = { id: col.id, name: col.name, statusIds: (col.statusIds || []).slice() };
|
|
189
|
+
if (c.statusIds.length === 1) c.status = c.statusIds[0];
|
|
190
|
+
return c;
|
|
191
|
+
});
|
|
192
|
+
const covered = new Set(columns.flatMap(c => c.statusIds));
|
|
193
|
+
const orphanStatuses = [];
|
|
194
|
+
for (const t of tickets) {
|
|
195
|
+
if (!covered.has(t.status) && orphanStatuses.indexOf(t.status) < 0) orphanStatuses.push(t.status);
|
|
196
|
+
}
|
|
197
|
+
for (const s of orphanStatuses) {
|
|
198
|
+
columns.push({ id: "col-orphan-" + s, name: s, statusIds: [s], status: s, orphan: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const buildCells = (laneTickets) => {
|
|
202
|
+
const cells = {};
|
|
203
|
+
for (const col of columns) {
|
|
204
|
+
const set = new Set(col.statusIds);
|
|
205
|
+
cells[col.id] = laneTickets.filter(t => set.has(t.status));
|
|
206
|
+
}
|
|
207
|
+
return cells;
|
|
208
|
+
};
|
|
209
|
+
const relIdOf = (t) => (t.position && t.position.releaseId) || null;
|
|
210
|
+
|
|
211
|
+
const swimlanes = [];
|
|
212
|
+
const releases = (snapshot.releases || []).filter(r => !r.isDeleted)
|
|
213
|
+
.slice().sort((a, b) => ((a.sortOrder || 0) - (b.sortOrder || 0)));
|
|
214
|
+
for (const r of releases) {
|
|
215
|
+
const lt = tickets.filter(t => relIdOf(t) === r.id);
|
|
216
|
+
if (lt.length === 0) continue; // only release rows with work
|
|
217
|
+
swimlanes.push({
|
|
218
|
+
release: r, key: r.id, collapsed: collapsed.has(r.id),
|
|
219
|
+
count: lt.length, progress: core.releaseProgress(snapshot, r.id), // SM-239
|
|
220
|
+
cells: buildCells(lt)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// "No release" swimlane — always present (unscheduled home + add button).
|
|
224
|
+
const noRel = tickets.filter(t => relIdOf(t) === null);
|
|
225
|
+
swimlanes.push({
|
|
226
|
+
release: null, key: NO_RELEASE_KEY, collapsed: collapsed.has(NO_RELEASE_KEY),
|
|
227
|
+
count: noRel.length, progress: core.releaseProgress(snapshot, null), // SM-239
|
|
228
|
+
cells: buildCells(noRel)
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { columns, swimlanes };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- Card renderer -------------------------------------------------
|
|
235
|
+
//
|
|
236
|
+
// Wir nutzen die geteilte Card-Komponente (renderer-card.js) — gleiches
|
|
237
|
+
// DOM-Markup, gleiches CSS (`.sm-story-card`) wie in der Story-Map. Damit
|
|
238
|
+
// sind beide Views visuell konsistent und Selection-Verhalten beim Drag
|
|
239
|
+
// (.sm-story-card { user-select: none }) wirkt automatisch auch hier.
|
|
240
|
+
|
|
241
|
+
function renderCard(ticket, opts, isShadow) {
|
|
242
|
+
const card = rendererCard.renderTicketCard(ticket, {
|
|
243
|
+
onTicketClick: isShadow ? null : (opts && opts.onTicketClick),
|
|
244
|
+
projectId: _projectId, // SM-158: ticket-key → editor link
|
|
245
|
+
dragType: isShadow ? null : DRAG_TYPE,
|
|
246
|
+
dragId: ticket.id,
|
|
247
|
+
// Clear the projection after the drag ends (drop OR cancel), so the
|
|
248
|
+
// next render shows the real state without the stale shadow.
|
|
249
|
+
dragOnEnd: isShadow ? null : (() => clearProjection()),
|
|
250
|
+
isShadow: !!isShadow,
|
|
251
|
+
// SM-49: dependency badges (forward + backward).
|
|
252
|
+
linkInfo: _linkIndex && _linkIndex.get(ticket.id) || null,
|
|
253
|
+
// SM-60: test-definition cards get the last-execution badge.
|
|
254
|
+
lastExecution: _lastExecIndex && _lastExecIndex.get(ticket.id) || null,
|
|
255
|
+
// SM-170: aging badge for cards in active (doing/blocked) statuses.
|
|
256
|
+
showAge: !!(_agingStatuses && _agingStatuses.has(ticket.status))
|
|
257
|
+
// No width → card stretches to lane width (flex layout).
|
|
258
|
+
});
|
|
259
|
+
// SM-30: right-click on a non-shadow card opens a promote/demote menu.
|
|
260
|
+
// We suppress the native browser menu unconditionally; the actual menu
|
|
261
|
+
// wiring happens in main.js (opts.onCardContextMenu).
|
|
262
|
+
if (!isShadow && opts && typeof opts.onCardContextMenu === "function") {
|
|
263
|
+
card.addEventListener("contextmenu", (ev) => {
|
|
264
|
+
ev.preventDefault();
|
|
265
|
+
ev.stopPropagation();
|
|
266
|
+
opts.onCardContextMenu(ticket, ev);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return card;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// gridTemplateColumns shared by the header row + every swimlane's cells row
|
|
273
|
+
// so the column boundaries line up vertically across all release rows.
|
|
274
|
+
function gridTemplate(columns) {
|
|
275
|
+
return "repeat(" + columns.length + ", minmax("
|
|
276
|
+
+ KANBAN_LAYOUT.LANE_MIN_WIDTH_PX + "px, 1fr))";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// One (release × status-column) cell — a drop target holding that release's
|
|
280
|
+
// cards in that column's statuses.
|
|
281
|
+
function renderCell(swimlane, column, opts, snapshot) {
|
|
282
|
+
const ds = { columnId: column.id, swimlaneKey: swimlane.key };
|
|
283
|
+
if (column.status) ds.status = column.status;
|
|
284
|
+
if (swimlane.release) ds.releaseId = swimlane.release.id;
|
|
285
|
+
const cell = el("div", { class: "km-cell", dataset: ds });
|
|
286
|
+
|
|
287
|
+
const cellTickets = swimlane.cells[column.id] || [];
|
|
288
|
+
const effective = effectiveCellTickets(swimlane.key, column.id, cellTickets, snapshot);
|
|
289
|
+
for (const entry of effective) cell.appendChild(renderCard(entry.ticket, opts, entry.shadow));
|
|
290
|
+
|
|
291
|
+
// "+ Add Item" lives on the No-release swimlane's backlog column only —
|
|
292
|
+
// new tickets start in backlog with no release and walk the workflow.
|
|
293
|
+
const isBacklogCol = (column.statusIds || []).includes("backlog");
|
|
294
|
+
if (swimlane.release === null && isBacklogCol) {
|
|
295
|
+
const add = el("button", {
|
|
296
|
+
class: "km-add-item",
|
|
297
|
+
title: "Add a new ticket to the backlog",
|
|
298
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Item</span>'
|
|
299
|
+
});
|
|
300
|
+
add.addEventListener("click", (ev) => {
|
|
301
|
+
ev.stopPropagation();
|
|
302
|
+
if (opts && typeof opts.onAddItem === "function") opts.onAddItem("backlog");
|
|
303
|
+
});
|
|
304
|
+
cell.appendChild(add);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const targetReleaseId = swimlane.release ? swimlane.release.id : null;
|
|
308
|
+
dnd.enableDropTarget(cell, {
|
|
309
|
+
accepts: [DRAG_TYPE],
|
|
310
|
+
onEnter: (e) => e.classList.add("km-cell-drop-target"),
|
|
311
|
+
onLeave: (e) => e.classList.remove("km-cell-drop-target"),
|
|
312
|
+
onMove: ({ id, clientY }) => {
|
|
313
|
+
// SM-169: a Kanban move never changes the release — that's a planning
|
|
314
|
+
// action (Map). Don't project a shadow into another release's row.
|
|
315
|
+
const src = (snapshot.tickets || []).find(t => t.id === id);
|
|
316
|
+
const srcRel = (src && src.position && src.position.releaseId) || null;
|
|
317
|
+
if (srcRel !== targetReleaseId) return;
|
|
318
|
+
const cards = Array.from(cell.querySelectorAll(".sm-story-card"))
|
|
319
|
+
.filter(c => !c.classList.contains("sm-dragging")
|
|
320
|
+
&& !c.classList.contains("sm-card-shadow"));
|
|
321
|
+
let idx = cards.length;
|
|
322
|
+
for (let i = 0; i < cards.length; i++) {
|
|
323
|
+
const r = cards[i].getBoundingClientRect();
|
|
324
|
+
if (clientY < r.top + r.height / 2) { idx = i; break; }
|
|
325
|
+
}
|
|
326
|
+
setProjection({ ticketId: id, swimlaneKey: swimlane.key, columnId: column.id, insertionIndex: idx });
|
|
327
|
+
},
|
|
328
|
+
onDrop: ({ id }) => {
|
|
329
|
+
cell.classList.remove("km-cell-drop-target");
|
|
330
|
+
const src = (snapshot.tickets || []).find(t => t.id === id);
|
|
331
|
+
const srcRel = (src && src.position && src.position.releaseId) || null;
|
|
332
|
+
const sourceStatus = src && src.status;
|
|
333
|
+
const insertionIndex = (_dragProjection
|
|
334
|
+
&& _dragProjection.swimlaneKey === swimlane.key
|
|
335
|
+
&& _dragProjection.columnId === column.id)
|
|
336
|
+
? _dragProjection.insertionIndex : 0;
|
|
337
|
+
clearProjection();
|
|
338
|
+
// Cross-swimlane drop → reject (snap back). Release changes live in the
|
|
339
|
+
// Map; the Kanban only moves a ticket within its own release row.
|
|
340
|
+
if (srcRel !== targetReleaseId) return;
|
|
341
|
+
const statusSet = new Set(column.statusIds || []);
|
|
342
|
+
const staysInColumn = sourceStatus && statusSet.has(sourceStatus);
|
|
343
|
+
if (!staysInColumn && (column.statusIds || []).length > 0) {
|
|
344
|
+
if (typeof opts.onTicketStatusChange === "function") {
|
|
345
|
+
opts.onTicketStatusChange(id, column.statusIds[0]);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
const peers = cellTickets.filter(t => t.id !== id);
|
|
349
|
+
const ordered = peers.slice();
|
|
350
|
+
ordered.splice(Math.max(0, Math.min(ordered.length, insertionIndex)), 0, { id });
|
|
351
|
+
const orderedIds = ordered.map(t => t.id);
|
|
352
|
+
if (typeof opts.onLaneReorder === "function") {
|
|
353
|
+
opts.onLaneReorder(sourceStatus || (column.statusIds || [])[0], orderedIds);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
return cell;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// A per-release column-header row (Backlog / Ready / … / Done). SM-169
|
|
362
|
+
// repeats this inside every expanded swimlane so the column→status mapping
|
|
363
|
+
// stays visible no matter how far you scroll (no single floating header).
|
|
364
|
+
function renderColumnHeader(columns) {
|
|
365
|
+
const head = el("div", { class: "km-col-header",
|
|
366
|
+
style: { gridTemplateColumns: gridTemplate(columns) } });
|
|
367
|
+
for (const col of columns) {
|
|
368
|
+
head.appendChild(el("div", {
|
|
369
|
+
class: "km-col-head-cell" + (col.orphan ? " km-col-orphan" : ""),
|
|
370
|
+
text: col.name || (col.statusIds || []).join(", ")
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
return head;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// One release swimlane: a collapsible release header + a cells row. The
|
|
377
|
+
// header is the SAME component the Story-Map uses (renderer-card.js#
|
|
378
|
+
// renderReleaseLabelRow) — identical chevron, name, completed/cancelled
|
|
379
|
+
// pill + strikethrough across both views (SM-169 visual unification).
|
|
380
|
+
function renderSwimlane(swimlane, columns, opts, snapshot) {
|
|
381
|
+
const rel = swimlane.release;
|
|
382
|
+
const ds = { swimlaneKey: swimlane.key };
|
|
383
|
+
if (rel) ds.releaseId = rel.id;
|
|
384
|
+
if (rel && rel.status) ds.status = rel.status;
|
|
385
|
+
const lane = el("div", {
|
|
386
|
+
class: "km-swimlane" + (swimlane.collapsed ? " km-swimlane-collapsed" : ""),
|
|
387
|
+
dataset: ds
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const toggle = () => {
|
|
391
|
+
if (opts && typeof opts.onSwimlaneCollapse === "function") opts.onSwimlaneCollapse(swimlane.key);
|
|
392
|
+
};
|
|
393
|
+
const header = rendererCard.renderReleaseLabelRow({
|
|
394
|
+
id: swimlane.key,
|
|
395
|
+
name: rel ? rel.name : "No release",
|
|
396
|
+
status: rel ? rel.status : null,
|
|
397
|
+
progress: swimlane.progress, // SM-239: X/Y replaces the raw count, same source as the Map
|
|
398
|
+
collapsed: swimlane.collapsed,
|
|
399
|
+
onToggleCollapsed: toggle,
|
|
400
|
+
// Real releases: label click opens the edit dialog (Map parity). The
|
|
401
|
+
// synthetic "No release" row has nothing to edit → no label handler
|
|
402
|
+
// (toggle via the chevron, exactly like the Map).
|
|
403
|
+
onLabelClick: (rel && opts && typeof opts.onReleaseClick === "function")
|
|
404
|
+
? () => opts.onReleaseClick(rel.id)
|
|
405
|
+
: null
|
|
406
|
+
});
|
|
407
|
+
lane.appendChild(header);
|
|
408
|
+
|
|
409
|
+
if (!swimlane.collapsed) {
|
|
410
|
+
// Each expanded release carries its own column header + cells grid, so
|
|
411
|
+
// the columns line up vertically (shared grid template) and read as
|
|
412
|
+
// continuous tinted bands from the header down through the cards.
|
|
413
|
+
lane.appendChild(renderColumnHeader(columns));
|
|
414
|
+
const cells = el("div", {
|
|
415
|
+
class: "km-swimlane-cells",
|
|
416
|
+
style: { gridTemplateColumns: gridTemplate(columns) }
|
|
417
|
+
});
|
|
418
|
+
for (const col of columns) cells.appendChild(renderCell(swimlane, col, opts, snapshot));
|
|
419
|
+
lane.appendChild(cells);
|
|
420
|
+
}
|
|
421
|
+
return lane;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function cssEscape(s) {
|
|
425
|
+
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderInto(host, store, opts) {
|
|
429
|
+
// Clear stale drag artifacts from a previous mount before rebuilding.
|
|
430
|
+
dnd.scrubArtifacts();
|
|
431
|
+
dnd.clearAll();
|
|
432
|
+
|
|
433
|
+
const snap = store.get();
|
|
434
|
+
_linkIndex = rendererCard.computeLinkIndex(snap); // SM-49
|
|
435
|
+
_lastExecIndex = rendererCard.computeLastExecutionIndex(snap); // SM-60
|
|
436
|
+
_projectId = snap.project && snap.project.id; // SM-158
|
|
437
|
+
// SM-170: which statuses get the aging badge (doing/blocked categories).
|
|
438
|
+
_agingStatuses = new Set();
|
|
439
|
+
const wfStatuses = (snap.project && snap.project.workflow && snap.project.workflow.statuses) || [];
|
|
440
|
+
for (const st of wfStatuses) {
|
|
441
|
+
if (st && AGING_CATEGORIES.indexOf(st.category) >= 0) _agingStatuses.add(st.id);
|
|
442
|
+
}
|
|
443
|
+
const layout = computeKanbanLayout(snap, {
|
|
444
|
+
filter: opts.filter || null,
|
|
445
|
+
matchTicket: opts.matchTicket || null, // SM-191 smart-bar query filter
|
|
446
|
+
collapsedReleases: opts.collapsedReleases
|
|
447
|
+
});
|
|
448
|
+
// Vertical stack of release swimlanes; each expanded one carries its own
|
|
449
|
+
// repeated column header (SM-169) so the status columns stay labelled.
|
|
450
|
+
const board = el("div", { class: "km-board" });
|
|
451
|
+
|
|
452
|
+
for (const swimlane of layout.swimlanes) {
|
|
453
|
+
board.appendChild(renderSwimlane(swimlane, layout.columns, opts, snap));
|
|
454
|
+
}
|
|
455
|
+
host.innerHTML = "";
|
|
456
|
+
host.appendChild(board);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- Mount API -----------------------------------------------------
|
|
460
|
+
|
|
461
|
+
function mount(host, store, opts) {
|
|
462
|
+
opts = opts || {};
|
|
463
|
+
function rerender() { renderInto(host, store, opts); }
|
|
464
|
+
_rerender = rerender;
|
|
465
|
+
rerender();
|
|
466
|
+
// E24: animate cards when the commit came from outside (applyRemote
|
|
467
|
+
// → MCP / WS push). Local commits already have their own visual
|
|
468
|
+
// feedback (drag-ghost, dialog dismiss) so we skip the animation
|
|
469
|
+
// for them — otherwise the user's own click would feel laggy.
|
|
470
|
+
let prevSnap = store.get();
|
|
471
|
+
const unsub = store.subscribe(function (snap, reason) {
|
|
472
|
+
if (reason === "applyRemote" && cardAnimate) {
|
|
473
|
+
cardAnimate.animateExternalCommit({
|
|
474
|
+
host: host,
|
|
475
|
+
prevSnap: prevSnap,
|
|
476
|
+
nextSnap: snap,
|
|
477
|
+
rerender: rerender
|
|
478
|
+
});
|
|
479
|
+
} else {
|
|
480
|
+
rerender();
|
|
481
|
+
}
|
|
482
|
+
prevSnap = snap;
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
unmount: () => {
|
|
486
|
+
unsub();
|
|
487
|
+
_rerender = null;
|
|
488
|
+
_dragProjection = null;
|
|
489
|
+
dnd.clearAll();
|
|
490
|
+
host.innerHTML = "";
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
KANBAN_LAYOUT,
|
|
497
|
+
DRAG_TYPE,
|
|
498
|
+
NO_RELEASE_KEY,
|
|
499
|
+
computeKanbanLayout,
|
|
500
|
+
effectiveCellTickets,
|
|
501
|
+
setProjection,
|
|
502
|
+
clearProjection,
|
|
503
|
+
mount
|
|
504
|
+
};
|
|
505
|
+
}));
|