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,2530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story-Map renderer — 3-Ebenen-Modell (E9c + E9j).
|
|
3
|
+
*
|
|
4
|
+
* ProcessSteps → horizontale Spalten (Feature-Backbone)
|
|
5
|
+
* Releases → vertikale Zeilen
|
|
6
|
+
* pro (Release × ProcessStep)-Zelle: Epic-Karten als Container,
|
|
7
|
+
* unter jeder Epic ein Story-Sub-Grid.
|
|
8
|
+
* Backlog-Section am Boden ist die einzige Holding-Area: Gruppe
|
|
9
|
+
* "Unscheduled" (kein Release) + pro Release "Scheduled for X but
|
|
10
|
+
* unplaced" für Tickets mit Release, aber noch ohne Zell-Position.
|
|
11
|
+
*
|
|
12
|
+
* Pure-Funktionen (testbar ohne DOM):
|
|
13
|
+
* computeStoryMapLayout(snapshot) → { rows, columns, releases, backlog }
|
|
14
|
+
* applyEpicDrop(store, epicId, targetProcessStepId, actor)
|
|
15
|
+
* applyStoryDrop(store, storyId, targetEpicId, actor)
|
|
16
|
+
* applyBacklogDrop(store, ticketId, ticketType, actor)
|
|
17
|
+
* applyReleaseReorder(store, orderedReleaseIds, actor)
|
|
18
|
+
* applyProcessStepReorder(store, orderedStepIds, actor)
|
|
19
|
+
*
|
|
20
|
+
* Mount API:
|
|
21
|
+
* mount(host, store, opts?)
|
|
22
|
+
* opts.onAddTicket({releaseId, processStepId, epicId?})
|
|
23
|
+
* opts.onTicketClick(ticketId)
|
|
24
|
+
*
|
|
25
|
+
* Drag-and-Drop: Pointer-Events via dnd.js. Zwei Kontexte:
|
|
26
|
+
* "epic", "story"
|
|
27
|
+
*
|
|
28
|
+
* Alle Layout-Werte als Konstanten oben — keine Magic Numbers im Code.
|
|
29
|
+
*/
|
|
30
|
+
(function (root, factory) {
|
|
31
|
+
if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"), require("./renderer-card.js"), require("./card-animate.js"), require("./filter.js"));
|
|
32
|
+
else (root.STORYMAP = root.STORYMAP || {}).rendererStoryMap = factory(
|
|
33
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
34
|
+
root.STORYMAP && root.STORYMAP.dnd,
|
|
35
|
+
root.STORYMAP && root.STORYMAP.rendererCard,
|
|
36
|
+
root.STORYMAP && root.STORYMAP.cardAnimate,
|
|
37
|
+
root.STORYMAP && root.STORYMAP.filter
|
|
38
|
+
);
|
|
39
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd, rendererCard, cardAnimate, filterMod) {
|
|
40
|
+
"use strict";
|
|
41
|
+
|
|
42
|
+
if (!core) throw new Error("renderer-storymap: core module missing");
|
|
43
|
+
if (!dnd) throw new Error("renderer-storymap: dnd module missing");
|
|
44
|
+
if (!rendererCard) throw new Error("renderer-storymap: renderer-card module missing");
|
|
45
|
+
// filterMod is optional — older callers / tests may mount without it.
|
|
46
|
+
// When absent, all tickets and releases are visible (no-op filter).
|
|
47
|
+
|
|
48
|
+
// ---- Konstanten (keine Magic Numbers) -------------------------------
|
|
49
|
+
|
|
50
|
+
const STORY_MAP_LAYOUT = {
|
|
51
|
+
// SM-277: fixed width of the release title column. The title truncates with
|
|
52
|
+
// an ellipsis past this, and the progress/pill/Add-release badges line up in
|
|
53
|
+
// stable columns across all release rows (no more fluttering). Flows to CSS
|
|
54
|
+
// as `--sm-release-label-w` (set on .sm-grid in renderInto).
|
|
55
|
+
RELEASE_LABEL_WIDTH_PX: 300,
|
|
56
|
+
// SM-55 follow-up: clean integer multipliers for Epic widths.
|
|
57
|
+
//
|
|
58
|
+
// INVARIANT: STORY_CARD_WIDTH_PX is the canonical "1 unit". It is the
|
|
59
|
+
// exact width of a standalone (loose) ticket card AND the exact width
|
|
60
|
+
// of a 1-sub-col Epic card. An N-sub-col Epic card is exactly
|
|
61
|
+
// N × STORY_CARD_WIDTH_PX wide. Story cards INSIDE an Epic become
|
|
62
|
+
// slightly narrower (epic-width - padding - inter-card gap) / N so
|
|
63
|
+
// they fit inside the Epic's clean N-unit width. The grid column unit
|
|
64
|
+
// BACKBONE_COL_WIDTH_PX is STORY_CARD_WIDTH_PX + 2×CELL_PADDING_PX so
|
|
65
|
+
// cell content area equals the Epic-card width with breathing room.
|
|
66
|
+
//
|
|
67
|
+
// Story card width — SINGLE source is rendererCard.CARD_WIDTH_PX, so the
|
|
68
|
+
// story map and dependency graph can't drift (bumping it there widens both).
|
|
69
|
+
STORY_CARD_WIDTH_PX: rendererCard.CARD_WIDTH_PX,
|
|
70
|
+
CELL_PADDING_PX: 4, // .sm-cell padding (each side); mirrors css
|
|
71
|
+
// SM-159: one grid unit = STORY_CARD (280) + one inter-column gap (8) = 288.
|
|
72
|
+
// A cell with N columns is N units wide; its content is N×280 cards +
|
|
73
|
+
// (N-1)×8 gaps + 2×4 cell-padding = 288N — an EXACT fit, no residual.
|
|
74
|
+
//
|
|
75
|
+
// It used to be 256 (= card + 2×pad + 8px slack, SM-62). That slack was
|
|
76
|
+
// added PER UNIT to keep the old flex-WRAP layout from wrapping on
|
|
77
|
+
// sub-pixel rounding — but since SM-136 the epic row is `nowrap`, so the
|
|
78
|
+
// slack only over-allocated each column and left an accumulating ~8px×N
|
|
79
|
+
// gap on the right (user-reported). `.sm-epic-card`/`.sm-loose-card` are
|
|
80
|
+
// flex-shrinkable and `.sm-cell` has overflow-x:auto, so a ≤1px sub-pixel
|
|
81
|
+
// overflow is absorbed without a scrollbar.
|
|
82
|
+
BACKBONE_COL_WIDTH_PX: rendererCard.CARD_WIDTH_PX + 8, // card + TICKET_GAP_PX (8)
|
|
83
|
+
// SM-272: a collapsed process-step column shrinks to this fixed narrow
|
|
84
|
+
// width (independent of how many epics it holds) — fixes the runaway-wide
|
|
85
|
+
// columns. Its cells show a compact "N epics" indicator instead of cards.
|
|
86
|
+
COLLAPSED_COL_WIDTH_PX: 72,
|
|
87
|
+
TICKET_GAP_PX: 8,
|
|
88
|
+
// SM-131: uniform grid — feste Höhen, gespiegelt als CSS-Custom-Props
|
|
89
|
+
// (--sm-card-h, --sm-epic-header-h) auf .sm-grid.
|
|
90
|
+
CARD_HEIGHT_PX: 72, // feste Story-Karten-Höhe (alle Karten gleich; Badges einzeilig dank 280px-Breite)
|
|
91
|
+
EPIC_HEADER_HEIGHT_PX: 56, // 2-Zeilen-Titel + Key + Padding
|
|
92
|
+
COLLAPSED_RELEASE_HEIGHT_PX: 40,
|
|
93
|
+
ZOOM_MIN: 0.5,
|
|
94
|
+
ZOOM_MAX: 1.5,
|
|
95
|
+
ZOOM_STEP: 0.1,
|
|
96
|
+
ZOOM_DEFAULT: 1.0,
|
|
97
|
+
// SM-135/136 (Schlanke-Säulen-Umbau): die alten Wrap-Konstanten
|
|
98
|
+
// (TICKETS_PER_COLUMN_DEFAULT, LOOSE_TICKETS_PER_COLUMN, EPICS_PER_ROW)
|
|
99
|
+
// und STORIES_GRID_GAP_PX sind entfallen — jede Epic-/Loose-Säule ist
|
|
100
|
+
// einspaltig und wächst beliebig nach unten, es gibt keinen Sub-Spalten-
|
|
101
|
+
// Wrap und keine Inter-Column-Gap-Mathematik mehr.
|
|
102
|
+
// Stories-Grid padding (mirrors .sm-stories-grid CSS) — wird genutzt um
|
|
103
|
+
// die innere Story-Karten-Breite = STORY_CARD - 2×PAD zu berechnen.
|
|
104
|
+
STORIES_GRID_PADDING_PX: 6,
|
|
105
|
+
FLIP_ANIM_MS: 180,
|
|
106
|
+
// SM-193: the backlog grows unbounded downward as tickets pile up. The wrap
|
|
107
|
+
// is driven by the WHOLE backlog (all sub-lists combined): once the total
|
|
108
|
+
// passes BACKLOG_WRAP_THRESHOLD, each long list balances into card-width
|
|
109
|
+
// columns of at most BACKLOG_TICKETS_PER_COLUMN cards. Total-based, because a
|
|
110
|
+
// backlog split across "Unscheduled" + per-release groups (e.g. 9 + 2 = 11)
|
|
111
|
+
// must still wrap even though no single sub-list exceeds the threshold
|
|
112
|
+
// (user-reported regression on the first per-list implementation).
|
|
113
|
+
BACKLOG_WRAP_THRESHOLD: 10,
|
|
114
|
+
BACKLOG_TICKETS_PER_COLUMN: 6,
|
|
115
|
+
// SM-274/SM-275: Map-Eingang (Bodenstreifen) — Sammelbereich für unzugeordnete
|
|
116
|
+
// Tickets (releaseId leer). Spalten-Flow wie in den Zellen (E9k): Karten
|
|
117
|
+
// füllen vertikal, brechen nach rechts um, eigener horizontaler Scroll.
|
|
118
|
+
// Die Zeilenzahl ist VIEWPORT-reaktiv (SM-275): so viele Zeilen, wie unter
|
|
119
|
+
// den Releases bis zum Fensterboden passen — der Rest bricht nach rechts um.
|
|
120
|
+
// So nutzt der Streifen den vertikalen Raum, ohne über den Viewport hinaus
|
|
121
|
+
// zu wachsen. TRAY_ROWS_DEFAULT ist nur noch der Fallback (vor dem ersten
|
|
122
|
+
// Layout-Measure / in JSDOM / bei sehr hohem Board).
|
|
123
|
+
TRAY_ROWS_DEFAULT: 2,
|
|
124
|
+
TRAY_BODY_PADDING_PX: 8, // mirrors .sm-tray-body padding (each side)
|
|
125
|
+
TRAY_RESIZE_DEBOUNCE_MS: 150, // SM-275: debounce window-resize → recompute rows
|
|
126
|
+
// SM-223 (a): `content-visibility: auto` on cells skips layout/paint of
|
|
127
|
+
// off-screen cells — a real win on very large boards, but it makes
|
|
128
|
+
// WebKit/Gecko estimate off-screen cell heights (contain-intrinsic-size) and
|
|
129
|
+
// only settle the row height when the cell scrolls in (a visible jump; Blink
|
|
130
|
+
// hides it by rendering eagerly). Gate it by board size so normal boards are
|
|
131
|
+
// pixel-correct and only genuinely large boards (SM-223's 500+ target) pay
|
|
132
|
+
// the jump for the perf. renderInto toggles `.sm-grid-virtualized`.
|
|
133
|
+
VIRTUALIZE_TICKET_THRESHOLD: 400
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Type→Cluster mapping zog mit renderTicketCard nach renderer-card.js um.
|
|
137
|
+
|
|
138
|
+
const DRAG_TYPES = {
|
|
139
|
+
EPIC: "epic",
|
|
140
|
+
STORY: "story",
|
|
141
|
+
PROCESS_STEP: "process-step"
|
|
142
|
+
};
|
|
143
|
+
// Legacy alias retained so existing tests that imported DRAG_FORMATS keep
|
|
144
|
+
// compiling — the underlying drag mechanic switched from HTML5-DT-MIME
|
|
145
|
+
// strings to a pointer-events-based string-type registry.
|
|
146
|
+
const DRAG_FORMATS = DRAG_TYPES;
|
|
147
|
+
|
|
148
|
+
const ACTOR_LOCAL = { type: "human", id: "local", name: "Local" };
|
|
149
|
+
|
|
150
|
+
// ---- Pure layout -----------------------------------------------------
|
|
151
|
+
|
|
152
|
+
// SM-221: instrumentation — how many times the (O(R×S×T)) base layout was
|
|
153
|
+
// actually computed. The drag-layout cache (computeBaseLayout) drives this to
|
|
154
|
+
// ≤1 per drag; tests assert it. Reset/read via the exposed test hooks.
|
|
155
|
+
let _layoutComputeCount = 0;
|
|
156
|
+
// SM-223: instrumentation — how many times renderInto built the FULL grid.
|
|
157
|
+
// The drag-incremental path drives this to 0 per mid-drag pointermove.
|
|
158
|
+
let _gridBuildCount = 0;
|
|
159
|
+
|
|
160
|
+
function computeStoryMapLayout(snapshot, opts) {
|
|
161
|
+
_layoutComputeCount += 1;
|
|
162
|
+
opts = opts || {};
|
|
163
|
+
const filter = opts.filter || null;
|
|
164
|
+
// SM-82: when a filter is active, we use the filter module to decide
|
|
165
|
+
// per-ticket visibility AND per-release visibility. Without filterMod
|
|
166
|
+
// (older bundles / tests), the predicates are no-ops.
|
|
167
|
+
// SM-191: the unified smart-bar passes a query-engine-backed predicate as
|
|
168
|
+
// opts.matchTicket. It AND-combines with the SM-82 chip filter so both stay
|
|
169
|
+
// usable (a query narrows the chip-filtered set, and vice versa).
|
|
170
|
+
const matchTicket = (typeof opts.matchTicket === "function") ? opts.matchTicket : null;
|
|
171
|
+
const filterMatch = (filterMod && filter)
|
|
172
|
+
? (t) => filterMod.matchesFilter(t, filter, filterCtx)
|
|
173
|
+
: (_t) => true;
|
|
174
|
+
const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
|
|
175
|
+
const visibleRelease = (filterMod && filter)
|
|
176
|
+
? (r) => filterMod.releaseVisible(r, filter)
|
|
177
|
+
: (_r) => true;
|
|
178
|
+
const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
|
|
179
|
+
|
|
180
|
+
// SM-252: newest release on TOP. Releases sort DESCENDING by sortOrder so
|
|
181
|
+
// the most recent (highest sortOrder, appended by createRelease) heads the
|
|
182
|
+
// map and old releases sink to the bottom — the board no longer grows
|
|
183
|
+
// top-heavy with completed releases over time. Story-Map only; the Kanban
|
|
184
|
+
// swimlanes (computeKanbanLayout) keep their own order.
|
|
185
|
+
const releases = (snapshot.releases || [])
|
|
186
|
+
.filter(r => !r.isDeleted && visibleRelease(r))
|
|
187
|
+
.slice()
|
|
188
|
+
.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
189
|
+
const processSteps = (snapshot.processSteps || [])
|
|
190
|
+
.filter(p => !p.isDeleted)
|
|
191
|
+
.slice()
|
|
192
|
+
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
193
|
+
|
|
194
|
+
// SM-239: each release row carries its X/Y progress over non-epic work
|
|
195
|
+
// items (the single releaseProgress source, shared with the Kanban).
|
|
196
|
+
const rows = releases.map(r => ({
|
|
197
|
+
id: r.id, name: r.name, kind: "release", entity: r,
|
|
198
|
+
progress: core.releaseProgress(snapshot, r.id)
|
|
199
|
+
}));
|
|
200
|
+
const columns = processSteps.map(p => ({ id: p.id, name: p.name, kind: "processStep", entity: p }));
|
|
201
|
+
|
|
202
|
+
// SM-253: tickets with a release but no process-step (not in any cell) are
|
|
203
|
+
// shown per-release in a holding strip — NOT in a global backlog. Grouped
|
|
204
|
+
// by release here so each release row can render + accept its own strip.
|
|
205
|
+
const partial = core.tickets.partiallyAssignedByRelease(snapshot);
|
|
206
|
+
|
|
207
|
+
const releasesOut = {};
|
|
208
|
+
for (const r of releases) {
|
|
209
|
+
const cells = {};
|
|
210
|
+
for (const p of processSteps) {
|
|
211
|
+
// SM-82: filter epics + stories per-ticket. When an epic is hidden
|
|
212
|
+
// but its stories aren't, the surviving stories slide into the
|
|
213
|
+
// cell's loose-list (so they're not orphaned to the backlog).
|
|
214
|
+
const allEpics = core.tickets.epicsInCell(snapshot, r.id, p.id);
|
|
215
|
+
const epicEntries = [];
|
|
216
|
+
const evictedStories = [];
|
|
217
|
+
for (const epic of allEpics) {
|
|
218
|
+
const allStories = core.tickets.storiesInEpic(snapshot, epic.id);
|
|
219
|
+
const visibleStories = allStories.filter(matches);
|
|
220
|
+
if (matches(epic)) {
|
|
221
|
+
epicEntries.push({ epic: epic, stories: visibleStories });
|
|
222
|
+
} else {
|
|
223
|
+
// Epic hidden — evicted stories appear in cell-loose, not backlog.
|
|
224
|
+
for (const s of visibleStories) evictedStories.push(s);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const looseFromCell = core.tickets.looseTicketsInCell(snapshot, r.id, p.id)
|
|
228
|
+
.filter(matches);
|
|
229
|
+
const loose = looseFromCell.concat(evictedStories);
|
|
230
|
+
cells[p.id] = { epics: epicEntries, loose };
|
|
231
|
+
}
|
|
232
|
+
// SM-253: per-release holding zone (release set, no process-step). Epic
|
|
233
|
+
// child stories are filtered here too (where `matches` lives) so an active
|
|
234
|
+
// status/type filter doesn't leak hidden stories into a holding-strip epic.
|
|
235
|
+
const g = partial.get(r.id) || { epics: [], stories: [] };
|
|
236
|
+
const unplaced = {
|
|
237
|
+
epics: g.epics.filter(matches).map(epic => ({
|
|
238
|
+
epic: epic,
|
|
239
|
+
stories: core.tickets.storiesInEpic(snapshot, epic.id).filter(matches)
|
|
240
|
+
})),
|
|
241
|
+
stories: g.stories.filter(matches)
|
|
242
|
+
};
|
|
243
|
+
releasesOut[r.id] = { release: r, cells, unplaced };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// SM-253: the global Backlog section is gone from the Story Map. Release-LESS
|
|
247
|
+
// tickets (orphan stories, backlog epics) are NOT shown here anymore — they
|
|
248
|
+
// live in the Kanban. Release-assigned-but-unplaced tickets now sit in their
|
|
249
|
+
// release's per-row holding zone (releasesOut[rid].unplaced). `partiallyAssigned`
|
|
250
|
+
// stays in the layout for data consumers/tests; the renderer no longer draws it.
|
|
251
|
+
const partiallyAssigned = releases
|
|
252
|
+
.map(r => {
|
|
253
|
+
const g = partial.get(r.id) || { epics: [], stories: [] };
|
|
254
|
+
return {
|
|
255
|
+
release: r,
|
|
256
|
+
epics: g.epics.filter(matches),
|
|
257
|
+
stories: g.stories.filter(matches)
|
|
258
|
+
};
|
|
259
|
+
})
|
|
260
|
+
.filter(g => g.epics.length > 0 || g.stories.length > 0);
|
|
261
|
+
|
|
262
|
+
const backlog = { epics: [], orphans: [], partiallyAssigned };
|
|
263
|
+
|
|
264
|
+
// SM-135 (Schlanke-Säulen-Umbau): jedes Epic ist eine schlanke
|
|
265
|
+
// Ein-Spalten-Säule fester Breite (STORY_CARD_WIDTH_PX). Die Cell-Breite
|
|
266
|
+
// ist damit schlicht die ANZAHL der Säulen × Backbone-Col-Unit, NICHT
|
|
267
|
+
// mehr eine Sub-Col-Summe pro Epic.
|
|
268
|
+
//
|
|
269
|
+
// Säulen pro Cell = #Epics + (lose Tickets vorhanden ? 1 : 0) — die
|
|
270
|
+
// losen Tickets bekommen ihre eigene Säule neben den Epics (SM-136).
|
|
271
|
+
// Multiplier = max über alle Releases (alle Release-Rows teilen sich die
|
|
272
|
+
// Backbone-Spaltenbreite). Stories wachsen innerhalb ihrer Epic-Säule
|
|
273
|
+
// beliebig nach unten (kein Wrap, kein 5er-Cap) und vergrößern damit nur
|
|
274
|
+
// die Höhe, nie die Breite.
|
|
275
|
+
const columnMultipliers = new Map();
|
|
276
|
+
for (const p of processSteps) {
|
|
277
|
+
let maxCols = 1;
|
|
278
|
+
for (const r of releases) {
|
|
279
|
+
const cell = releasesOut[r.id].cells[p.id];
|
|
280
|
+
const looseCol = (cell.loose && cell.loose.length > 0) ? 1 : 0;
|
|
281
|
+
const cols = cell.epics.length + looseCol;
|
|
282
|
+
if (cols > maxCols) maxCols = cols;
|
|
283
|
+
}
|
|
284
|
+
columnMultipliers.set(p.id, Math.max(1, maxCols));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { rows, columns, releases: releasesOut, backlog, columnMultipliers };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- SM-274: Map-Eingang (Bodenstreifen) -----------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Pure layout for the Map-Eingang: collect ALL release-LESS tickets (the work
|
|
294
|
+
* that SM-253 dropped from the map and that the Kanban can't show for epics)
|
|
295
|
+
* and chunk them into column-flow columns of `rows` cards each — cards fill a
|
|
296
|
+
* column top-to-bottom, then the next column starts to the right.
|
|
297
|
+
*
|
|
298
|
+
* items flat list (epics first, then orphan stories), each already
|
|
299
|
+
* sorted by sortOrder within its group.
|
|
300
|
+
* columns 2D array: columns[k] is the k-th vertical column (≤ rows cards).
|
|
301
|
+
* rows the effective row count used for chunking (≥ 1).
|
|
302
|
+
* count items.length.
|
|
303
|
+
*
|
|
304
|
+
* opts.rows number of rows per column (default TRAY_ROWS_DEFAULT).
|
|
305
|
+
* opts.matches optional per-ticket predicate (filter parity with the cells);
|
|
306
|
+
* defaults to "everything visible".
|
|
307
|
+
*/
|
|
308
|
+
function computeUnassignedTrayLayout(snapshot, opts) {
|
|
309
|
+
opts = opts || {};
|
|
310
|
+
const rows = Math.max(1, Math.floor(opts.rows || STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT));
|
|
311
|
+
const matches = (typeof opts.matches === "function") ? opts.matches : (_t) => true;
|
|
312
|
+
const epics = core.tickets.backlogEpics(snapshot).filter(matches);
|
|
313
|
+
const orphans = core.tickets.orphanStories(snapshot).filter(matches);
|
|
314
|
+
const items = epics.concat(orphans);
|
|
315
|
+
const columns = [];
|
|
316
|
+
for (let i = 0; i < items.length; i += rows) columns.push(items.slice(i, i + rows));
|
|
317
|
+
return { items, columns, rows, count: items.length };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* SM-275: how many tray rows fit in the space BELOW the grid, inside the
|
|
322
|
+
* viewport. Pure (testable): given the scroller height, the grid height and
|
|
323
|
+
* the tray header height, return floor(available / cardSlot). Falls back to
|
|
324
|
+
* TRAY_ROWS_DEFAULT when there is no room yet (pre-layout / JSDOM measure = 0)
|
|
325
|
+
* or when the board already fills the viewport (a tall board → compact inbox,
|
|
326
|
+
* the rest wraps to the right with horizontal scroll).
|
|
327
|
+
*/
|
|
328
|
+
function computeTrayRows(scrollerH, gridH, headerH) {
|
|
329
|
+
const cardSlot = STORY_MAP_LAYOUT.CARD_HEIGHT_PX + STORY_MAP_LAYOUT.TICKET_GAP_PX;
|
|
330
|
+
const avail = (scrollerH || 0) - (gridH || 0) - (headerH || 0)
|
|
331
|
+
- STORY_MAP_LAYOUT.TRAY_BODY_PADDING_PX * 2;
|
|
332
|
+
if (avail < cardSlot) return STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT;
|
|
333
|
+
return Math.max(1, Math.floor(avail / cardSlot));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// SM-275: measure the live DOM and apply the viewport-reactive row count to a
|
|
337
|
+
// mounted tray's card grid. The cards are a flat list; grid-auto-flow:column +
|
|
338
|
+
// grid-template-rows do the column wrapping, so only the row template changes —
|
|
339
|
+
// no DOM re-chunk. No-op when the tray is collapsed (no body/grid present).
|
|
340
|
+
function sizeTrayBody(host, tray) {
|
|
341
|
+
if (!host || !tray) return;
|
|
342
|
+
const scroller = host.parentElement; // #view-host (the scroll container)
|
|
343
|
+
const mapGrid = host.querySelector(":scope > .sm-grid");
|
|
344
|
+
// Width is handled purely in CSS now: the strip is a block child of the
|
|
345
|
+
// width:max-content grid, so it fills the full map scroll width on its own.
|
|
346
|
+
// Only the row count is genuinely dynamic (depends on the live viewport).
|
|
347
|
+
const grid = tray.querySelector(".sm-tray-grid");
|
|
348
|
+
if (!grid) return; // collapsed → nothing more to size
|
|
349
|
+
const header = tray.querySelector(".sm-tray-header");
|
|
350
|
+
// The tray is now a child of .sm-grid, so mapGrid.offsetHeight INCLUDES the
|
|
351
|
+
// tray — using it directly would feed the tray's own height back into its
|
|
352
|
+
// row count. Sum the grid's other children (backbone + release rows) instead
|
|
353
|
+
// to get the height of everything ABOVE the tray. Non-circular.
|
|
354
|
+
let releasesH = 0;
|
|
355
|
+
if (mapGrid) {
|
|
356
|
+
for (const child of mapGrid.children) {
|
|
357
|
+
if (child !== tray) releasesH += child.offsetHeight;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const rows = computeTrayRows(
|
|
361
|
+
scroller ? scroller.clientHeight : 0,
|
|
362
|
+
releasesH,
|
|
363
|
+
header ? header.offsetHeight : 0
|
|
364
|
+
);
|
|
365
|
+
grid.style.gridTemplateRows = "repeat(" + rows + ", " + STORY_MAP_LAYOUT.CARD_HEIGHT_PX + "px)";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- Drop logic ------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
function applyEpicDrop(store, epicId, targetProcessStepId, actor, targetReleaseId) {
|
|
371
|
+
const cur = store.get().tickets.find(t => t.id === epicId);
|
|
372
|
+
if (!cur) return;
|
|
373
|
+
// If targetReleaseId is omitted (test-style 3-arg call), keep current.
|
|
374
|
+
const releaseId = targetReleaseId !== undefined
|
|
375
|
+
? (targetReleaseId || null)
|
|
376
|
+
: (cur.position && cur.position.releaseId);
|
|
377
|
+
store.moveTicket(epicId, {
|
|
378
|
+
releaseId,
|
|
379
|
+
processStepId: targetProcessStepId || null,
|
|
380
|
+
epicId: null,
|
|
381
|
+
sortOrder: (cur.position && cur.position.sortOrder) || 0
|
|
382
|
+
}, actor || ACTOR_LOCAL);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Drag-to-Backlog: clears releaseId (and for stories epicId). */
|
|
386
|
+
function applyBacklogDrop(store, ticketId, ticketType, actor) {
|
|
387
|
+
const cur = store.get().tickets.find(t => t.id === ticketId);
|
|
388
|
+
if (!cur) return;
|
|
389
|
+
store.moveTicket(ticketId, {
|
|
390
|
+
releaseId: null,
|
|
391
|
+
processStepId: null,
|
|
392
|
+
epicId: null,
|
|
393
|
+
sortOrder: (cur.position && cur.position.sortOrder) || 0
|
|
394
|
+
}, actor || ACTOR_LOCAL);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function applyStoryDrop(store, storyId, targetEpicId, actor) {
|
|
398
|
+
const cur = store.get().tickets.find(t => t.id === storyId);
|
|
399
|
+
if (!cur) return;
|
|
400
|
+
store.moveTicket(storyId, {
|
|
401
|
+
releaseId: cur.position && cur.position.releaseId,
|
|
402
|
+
processStepId: cur.position && cur.position.processStepId,
|
|
403
|
+
epicId: targetEpicId || null,
|
|
404
|
+
sortOrder: (cur.position && cur.position.sortOrder) || 0
|
|
405
|
+
}, actor || ACTOR_LOCAL);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function applyReleaseReorder(store, orderedReleaseIds, actor) {
|
|
409
|
+
store.reorderReleases(orderedReleaseIds, actor || ACTOR_LOCAL);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function applyProcessStepReorder(store, orderedStepIds, actor) {
|
|
413
|
+
store.reorderProcessSteps(orderedStepIds, actor || ACTOR_LOCAL);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- SM-20 Phase A: Position-Only Fast-Path -------------------------
|
|
417
|
+
//
|
|
418
|
+
// The naive renderer wipes `host.innerHTML` and rebuilds the whole DOM on
|
|
419
|
+
// every `applyRemote`. Even though FLIP smoothly animates ticket-card
|
|
420
|
+
// positions afterwards, the structural shells (.sm-cell, .sm-backbone,
|
|
421
|
+
// .sm-release-row, .sm-backlog-section) get rebuilt every time — the user
|
|
422
|
+
// perceives that as a flash UNDER the animation.
|
|
423
|
+
//
|
|
424
|
+
// Phase A short-circuits the most common applyRemote case (reorder via
|
|
425
|
+
// MCP `reorder_tickets`): if the diff between prev and next snapshot is
|
|
426
|
+
// a pure within-container sort-order change for one or more tickets, we
|
|
427
|
+
// skip the wipe-and-rebuild entirely. Instead we re-order the existing
|
|
428
|
+
// card DOM nodes inside their existing parent containers. FLIP captures
|
|
429
|
+
// its rects beforehand and animates the move just like before — but now
|
|
430
|
+
// the structural shell keeps its DOM identity, so there's no flash.
|
|
431
|
+
//
|
|
432
|
+
// Cross-container moves, ticket additions/deletions, structural changes
|
|
433
|
+
// (new release, new ps, AC edits, …) bail out via `null` return and the
|
|
434
|
+
// caller falls back to the existing full-rerender + animate path.
|
|
435
|
+
|
|
436
|
+
function cssEscape(s) {
|
|
437
|
+
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// SM-52: epic containment is a `contains` link FROM the epic, not
|
|
441
|
+
// `ticket.position.epicId` (which is always null after the migration).
|
|
442
|
+
// Mirrors shared/core.js CONTAINS_LINK_TYPE_ID.
|
|
443
|
+
const CONTAINS_LINK_TYPE_ID = "contains";
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Build a `Map<storyId, epicId>` of current containment from the epics'
|
|
447
|
+
* outgoing `contains` links. The applyRemote fast-path diffs pass this so a
|
|
448
|
+
* (re)assignment registers as a container change. Without it, containerKeyOf
|
|
449
|
+
* falls back to the legacy position.epicId (always null post-SM-52), and a
|
|
450
|
+
* story joining an epic was silently classified as "no change" — the bug
|
|
451
|
+
* where stories never appeared under epics over live-sync.
|
|
452
|
+
*/
|
|
453
|
+
function containerMapOf(snap) {
|
|
454
|
+
const m = new Map();
|
|
455
|
+
for (const t of ((snap && snap.tickets) || [])) {
|
|
456
|
+
if (t.isDeleted || t.type !== "epic" || !t.links) continue;
|
|
457
|
+
for (const l of t.links) {
|
|
458
|
+
if ((l.linkTypeId || l.type) === CONTAINS_LINK_TYPE_ID) m.set(l.targetTicketId, t.id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return m;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Stable key identifying the DOM container a ticket lives in. Two tickets
|
|
466
|
+
* with the same key share a parent in the rendered DOM. `containerMap`
|
|
467
|
+
* (optional Map<storyId, epicId> from containerMapOf) is the canonical SM-52
|
|
468
|
+
* containment source; the position.epicId fallback is legacy-only.
|
|
469
|
+
*/
|
|
470
|
+
function containerKeyOf(ticket, containerMap) {
|
|
471
|
+
const p = ticket.position || {};
|
|
472
|
+
const epicId = (containerMap && containerMap.get(ticket.id))
|
|
473
|
+
|| (typeof p.epicId === "string" ? p.epicId : null);
|
|
474
|
+
if (epicId) return "epic:" + epicId;
|
|
475
|
+
if (p.releaseId && p.processStepId) {
|
|
476
|
+
return (ticket.type === "epic" ? "cell-epics:" : "cell-loose:")
|
|
477
|
+
+ p.releaseId + "/" + p.processStepId;
|
|
478
|
+
}
|
|
479
|
+
if (p.releaseId) return "backlog-rel:" + p.releaseId;
|
|
480
|
+
return "backlog-orphan";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* True iff `a` and `b` differ only in `position` (and meta — updatedAt/
|
|
485
|
+
* version/updatedBy which always advance on writes). Everything that
|
|
486
|
+
* affects rendering OUTSIDE of where the card lives is compared.
|
|
487
|
+
*/
|
|
488
|
+
function ticketEqualsIgnoringPosition(a, b) {
|
|
489
|
+
if (a.title !== b.title) return false;
|
|
490
|
+
if (a.type !== b.type) return false;
|
|
491
|
+
if (a.description !== b.description) return false;
|
|
492
|
+
if (a.status !== b.status) return false;
|
|
493
|
+
if (a.isDeleted !== b.isDeleted) return false;
|
|
494
|
+
// SM-52: `links` carry the `contains` containment edge (epic membership)
|
|
495
|
+
// AND the dependency badges rendered on the card. A change here must force
|
|
496
|
+
// the structural/full-rerender path — otherwise assigning a story to an
|
|
497
|
+
// epic (or any link edit) is invisible over live-sync.
|
|
498
|
+
if (JSON.stringify(a.links || []) !== JSON.stringify(b.links || [])) return false;
|
|
499
|
+
if (JSON.stringify(a.labels || []) !== JSON.stringify(b.labels || [])) return false;
|
|
500
|
+
if (JSON.stringify(a.acceptanceCriteria || []) !== JSON.stringify(b.acceptanceCriteria || [])) return false;
|
|
501
|
+
if (JSON.stringify((a.definitionOfReady && a.definitionOfReady.items) || []) !==
|
|
502
|
+
JSON.stringify((b.definitionOfReady && b.definitionOfReady.items) || [])) return false;
|
|
503
|
+
if (JSON.stringify((a.definitionOfDone && a.definitionOfDone.items) || []) !==
|
|
504
|
+
JSON.stringify((b.definitionOfDone && b.definitionOfDone.items) || [])) return false;
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Returns `Map<containerKey, orderedTicketIds[]>` for the affected
|
|
510
|
+
* containers iff the prev→next diff is a pure within-container sort-order
|
|
511
|
+
* change (no structural changes, no field edits, no cross-container
|
|
512
|
+
* moves, no add/remove). Returns `null` otherwise so the caller falls
|
|
513
|
+
* back to the regular full-rerender path.
|
|
514
|
+
*/
|
|
515
|
+
function diffSortOnlyByContainer(prevSnap, nextSnap) {
|
|
516
|
+
if (!prevSnap || !nextSnap) return null;
|
|
517
|
+
if ((prevSnap.releases || []).length !== (nextSnap.releases || []).length) return null;
|
|
518
|
+
if ((prevSnap.processSteps || []).length !== (nextSnap.processSteps || []).length) return null;
|
|
519
|
+
// Releases/process-steps: any name/order/etc. change → structural rerender.
|
|
520
|
+
for (let i = 0; i < (prevSnap.releases || []).length; i++) {
|
|
521
|
+
if (JSON.stringify(scrubMeta(prevSnap.releases[i])) !==
|
|
522
|
+
JSON.stringify(scrubMeta(nextSnap.releases[i]))) return null;
|
|
523
|
+
}
|
|
524
|
+
for (let i = 0; i < (prevSnap.processSteps || []).length; i++) {
|
|
525
|
+
if (JSON.stringify(scrubMeta(prevSnap.processSteps[i])) !==
|
|
526
|
+
JSON.stringify(scrubMeta(nextSnap.processSteps[i]))) return null;
|
|
527
|
+
}
|
|
528
|
+
if ((prevSnap.tickets || []).length !== (nextSnap.tickets || []).length) return null;
|
|
529
|
+
const prevMap = containerMapOf(prevSnap); // SM-52 containment
|
|
530
|
+
const nextMap = containerMapOf(nextSnap);
|
|
531
|
+
const prevById = new Map((prevSnap.tickets || []).map(t => [t.id, t]));
|
|
532
|
+
const affected = new Set();
|
|
533
|
+
for (const next of (nextSnap.tickets || [])) {
|
|
534
|
+
const prev = prevById.get(next.id);
|
|
535
|
+
if (!prev) return null; // added ticket = structural
|
|
536
|
+
if (!ticketEqualsIgnoringPosition(prev, next)) return null;
|
|
537
|
+
const prevKey = containerKeyOf(prev, prevMap);
|
|
538
|
+
const nextKey = containerKeyOf(next, nextMap);
|
|
539
|
+
if (prevKey !== nextKey) return null; // cross-container move = full rerender
|
|
540
|
+
const prevOrder = (prev.position && prev.position.sortOrder) || 0;
|
|
541
|
+
const nextOrder = (next.position && next.position.sortOrder) || 0;
|
|
542
|
+
if (prevOrder !== nextOrder) affected.add(nextKey);
|
|
543
|
+
}
|
|
544
|
+
if (affected.size === 0) return new Map(); // truly nothing changed
|
|
545
|
+
const groups = new Map();
|
|
546
|
+
for (const key of affected) {
|
|
547
|
+
const ids = (nextSnap.tickets || [])
|
|
548
|
+
.filter(t => !t.isDeleted && containerKeyOf(t, nextMap) === key)
|
|
549
|
+
.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
|
|
550
|
+
- ((b.position && b.position.sortOrder) || 0))
|
|
551
|
+
.map(t => t.id);
|
|
552
|
+
groups.set(key, ids);
|
|
553
|
+
}
|
|
554
|
+
return groups;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function scrubMeta(entity) {
|
|
558
|
+
const out = {};
|
|
559
|
+
for (const k of Object.keys(entity)) {
|
|
560
|
+
if (k === "updatedAt" || k === "updatedBy" || k === "version" || k === "createdAt" || k === "createdBy") continue;
|
|
561
|
+
out[k] = entity[k];
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Locate the existing DOM container for a given container-key. Returns
|
|
568
|
+
* `null` if the container isn't currently in the DOM (e.g. the cell-loose
|
|
569
|
+
* host doesn't exist because the cell had no loose tickets) — caller
|
|
570
|
+
* then falls back to a full rerender.
|
|
571
|
+
*/
|
|
572
|
+
function findContainerForKey(host, key) {
|
|
573
|
+
if (key.indexOf("epic:") === 0) {
|
|
574
|
+
const epicId = key.slice("epic:".length);
|
|
575
|
+
const card = host.querySelector('.sm-epic-card[data-ticket-id="' + cssEscape(epicId) + '"]');
|
|
576
|
+
return card ? card.querySelector(".sm-stories-grid") : null;
|
|
577
|
+
}
|
|
578
|
+
if (key.indexOf("cell-epics:") === 0 || key.indexOf("cell-loose:") === 0) {
|
|
579
|
+
const isEpics = key.indexOf("cell-epics:") === 0;
|
|
580
|
+
const path = key.slice(isEpics ? "cell-epics:".length : "cell-loose:".length);
|
|
581
|
+
const slash = path.indexOf("/");
|
|
582
|
+
const releaseId = path.slice(0, slash);
|
|
583
|
+
const psId = path.slice(slash + 1);
|
|
584
|
+
const cell = host.querySelector(
|
|
585
|
+
'.sm-cell[data-release-id="' + cssEscape(releaseId) +
|
|
586
|
+
'"][data-process-step-id="' + cssEscape(psId) + '"]'
|
|
587
|
+
);
|
|
588
|
+
return cell ? cell.querySelector(isEpics ? ".sm-cell-epics" : ".sm-cell-loose") : null;
|
|
589
|
+
}
|
|
590
|
+
// backlog-* — Phase A bails so the existing rerender handles them.
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* SM-20 Phase B — detect commits whose only effect on the layout is
|
|
596
|
+
* changing per-ticket CONTENT fields (title/status/AC/DoR/DoD/labels)
|
|
597
|
+
* without changing positions, structure, or the ticket set. Returns a
|
|
598
|
+
* `Set<ticketId>` of changed cards, or `null` to bail.
|
|
599
|
+
*
|
|
600
|
+
* Used together with Phase A so applyRemote-driven `ticket_update`,
|
|
601
|
+
* `check_dod_item`, `change_ticket_status`, etc. don't trigger a full
|
|
602
|
+
* host.innerHTML rebuild — we patch the existing card DOM in place.
|
|
603
|
+
*/
|
|
604
|
+
function diffCardContentOnly(prevSnap, nextSnap) {
|
|
605
|
+
if (!prevSnap || !nextSnap) return null;
|
|
606
|
+
if ((prevSnap.releases || []).length !== (nextSnap.releases || []).length) return null;
|
|
607
|
+
if ((prevSnap.processSteps || []).length !== (nextSnap.processSteps || []).length) return null;
|
|
608
|
+
for (let i = 0; i < (prevSnap.releases || []).length; i++) {
|
|
609
|
+
if (JSON.stringify(scrubMeta(prevSnap.releases[i])) !==
|
|
610
|
+
JSON.stringify(scrubMeta(nextSnap.releases[i]))) return null;
|
|
611
|
+
}
|
|
612
|
+
for (let i = 0; i < (prevSnap.processSteps || []).length; i++) {
|
|
613
|
+
if (JSON.stringify(scrubMeta(prevSnap.processSteps[i])) !==
|
|
614
|
+
JSON.stringify(scrubMeta(nextSnap.processSteps[i]))) return null;
|
|
615
|
+
}
|
|
616
|
+
if ((prevSnap.tickets || []).length !== (nextSnap.tickets || []).length) return null;
|
|
617
|
+
const prevMap = containerMapOf(prevSnap); // SM-52 containment
|
|
618
|
+
const nextMap = containerMapOf(nextSnap);
|
|
619
|
+
const prevById = new Map((prevSnap.tickets || []).map(t => [t.id, t]));
|
|
620
|
+
const changed = new Set();
|
|
621
|
+
for (const next of (nextSnap.tickets || [])) {
|
|
622
|
+
const prev = prevById.get(next.id);
|
|
623
|
+
if (!prev) return null; // add → structural
|
|
624
|
+
// isDeleted toggling is a STRUCTURAL change — the card must enter or
|
|
625
|
+
// leave the DOM, which Phase B's in-place patch can't do. Defer to
|
|
626
|
+
// the full-rerender + morph path, which inserts/removes nodes.
|
|
627
|
+
if (prev.isDeleted !== next.isDeleted) return null;
|
|
628
|
+
// Container must be IDENTICAL — otherwise the card belongs under a
|
|
629
|
+
// different parent (cell, epic, backlog) and Phase A (or full rerender)
|
|
630
|
+
// handles the move. SM-52: containment comes from the contains-link map.
|
|
631
|
+
if (containerKeyOf(prev, prevMap) !== containerKeyOf(next, nextMap)) return null;
|
|
632
|
+
const prevOrder = (prev.position && prev.position.sortOrder) || 0;
|
|
633
|
+
const nextOrder = (next.position && next.position.sortOrder) || 0;
|
|
634
|
+
if (prevOrder !== nextOrder) return null; // pure position change → Phase A
|
|
635
|
+
// Compare every visible card surface. Anything else → bail
|
|
636
|
+
// (e.g. labels change, comments — keep this list aligned with
|
|
637
|
+
// ticketEqualsIgnoringPosition so we don't miss something the card
|
|
638
|
+
// doesn't render today but might tomorrow).
|
|
639
|
+
const samples = ["title", "type", "status", "description"];
|
|
640
|
+
let cardChanged = false;
|
|
641
|
+
for (const k of samples) {
|
|
642
|
+
if (prev[k] !== next[k]) { cardChanged = true; break; }
|
|
643
|
+
}
|
|
644
|
+
if (!cardChanged) {
|
|
645
|
+
// SM-52: links changed but container didn't → a dependency-badge edit
|
|
646
|
+
// on a card whose epic membership is unchanged. Re-patch the card.
|
|
647
|
+
if (JSON.stringify(prev.links || []) !== JSON.stringify(next.links || [])) cardChanged = true;
|
|
648
|
+
else if (JSON.stringify(prev.labels || []) !== JSON.stringify(next.labels || [])) cardChanged = true;
|
|
649
|
+
else if (JSON.stringify(prev.acceptanceCriteria || []) !== JSON.stringify(next.acceptanceCriteria || [])) cardChanged = true;
|
|
650
|
+
else if (JSON.stringify((prev.definitionOfReady && prev.definitionOfReady.items) || []) !==
|
|
651
|
+
JSON.stringify((next.definitionOfReady && next.definitionOfReady.items) || [])) cardChanged = true;
|
|
652
|
+
else if (JSON.stringify((prev.definitionOfDone && prev.definitionOfDone.items) || []) !==
|
|
653
|
+
JSON.stringify((next.definitionOfDone && next.definitionOfDone.items) || [])) cardChanged = true;
|
|
654
|
+
}
|
|
655
|
+
if (cardChanged) changed.add(next.id);
|
|
656
|
+
}
|
|
657
|
+
return changed; // may be empty for a no-op echo
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Patch every card DOM node listed in `ticketIds` to match the data in
|
|
662
|
+
* `snap`. Returns `true` if every card was found in the DOM and patched;
|
|
663
|
+
* `false` if any card is missing (caller falls back to full rerender).
|
|
664
|
+
*/
|
|
665
|
+
function applyCardContentPatch(host, ticketIds, snap, opts) {
|
|
666
|
+
const byId = new Map((snap.tickets || []).map(t => [t.id, t]));
|
|
667
|
+
for (const id of ticketIds) {
|
|
668
|
+
const ticket = byId.get(id);
|
|
669
|
+
if (!ticket) return false;
|
|
670
|
+
// Epic cards have their own DOM structure (.sm-epic-card) — Phase B
|
|
671
|
+
// skips them by bailing whenever an epic is in the changed set. They
|
|
672
|
+
// still get a full rerender on edits; common edits hit story cards.
|
|
673
|
+
if (ticket.type === "epic") return false;
|
|
674
|
+
const card = host.querySelector('.sm-story-card[data-ticket-id="' + cssEscape(id) + '"]');
|
|
675
|
+
if (!card) return false;
|
|
676
|
+
rendererCard.patchTicketCardInPlace(card, ticket,
|
|
677
|
+
_linkIndex && _linkIndex.get(ticket.id) || null,
|
|
678
|
+
_lastExecIndex && _lastExecIndex.get(ticket.id) || null,
|
|
679
|
+
{ onTicketClick: opts && opts.onTicketClick });
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Re-arrange the existing card DOM nodes inside each affected container
|
|
686
|
+
* so that their order matches `groups`. Returns `false` if any required
|
|
687
|
+
* container or card can't be found in the current DOM (caller falls back
|
|
688
|
+
* to the regular rerender then).
|
|
689
|
+
*/
|
|
690
|
+
function applySortOnlyReshuffle(host, groups) {
|
|
691
|
+
for (const [key, orderedIds] of groups) {
|
|
692
|
+
const container = findContainerForKey(host, key);
|
|
693
|
+
if (!container) return false;
|
|
694
|
+
for (let i = 0; i < orderedIds.length; i++) {
|
|
695
|
+
const id = orderedIds[i];
|
|
696
|
+
const card = container.querySelector(
|
|
697
|
+
':scope > [data-ticket-id="' + cssEscape(id) + '"]'
|
|
698
|
+
);
|
|
699
|
+
if (!card) return false;
|
|
700
|
+
const slot = container.children[i];
|
|
701
|
+
if (slot !== card) container.insertBefore(card, slot || null);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- SM-20 Phase C: morphTree DOM reconciler ------------------------
|
|
708
|
+
//
|
|
709
|
+
// Naive renderInto builds a fresh grid off-DOM and replaces the host's
|
|
710
|
+
// entire subtree (`host.innerHTML = ""; host.appendChild(grid)`). Phase
|
|
711
|
+
// A/B short-circuit the most common pure-reorder and pure-content cases,
|
|
712
|
+
// but `ticket_create`, `release_create`, label-rename, and the rare
|
|
713
|
+
// mixed-change still take the slow path. Phase C swaps the slow path
|
|
714
|
+
// for an in-place morph: the existing `.sm-grid` is mutated to match
|
|
715
|
+
// the freshly-built one, preserving DOM identity for every node whose
|
|
716
|
+
// stable key matches. Listeners, dnd registrations and CSS state all
|
|
717
|
+
// survive — the user no longer sees a structural refresh-flash.
|
|
718
|
+
//
|
|
719
|
+
// `morphTree` is a lightweight morphdom: it walks two element trees in
|
|
720
|
+
// parallel, pairs children by `keyOf`, reuses paired nodes, inserts new
|
|
721
|
+
// children where the new tree introduces them, removes old children
|
|
722
|
+
// that are absent in the new tree, and recursively morphs the paired
|
|
723
|
+
// pair. For leaf elements (no element children, just text), it copies
|
|
724
|
+
// textContent directly. Attributes are diffed and patched.
|
|
725
|
+
//
|
|
726
|
+
// Keys we recognize (in priority order):
|
|
727
|
+
// data-ticket-id — story + epic cards
|
|
728
|
+
// data-process-step-id — backbone columns
|
|
729
|
+
// data-release-id — release rows + labels
|
|
730
|
+
// data-section — singleton structural blocks we tag below
|
|
731
|
+
// .sm-cell[data-release-id][data-process-step-id]
|
|
732
|
+
// — cells (combined key)
|
|
733
|
+
|
|
734
|
+
function keyOf(el) {
|
|
735
|
+
if (!el || el.nodeType !== 1) return null;
|
|
736
|
+
const ds = el.dataset || {};
|
|
737
|
+
const cl = el.classList;
|
|
738
|
+
if (!cl) return null;
|
|
739
|
+
// .sm-release-row and .sm-release-label-row BOTH carry data-release-id —
|
|
740
|
+
// disambiguate by class before falling through to the generic dataset
|
|
741
|
+
// keys below.
|
|
742
|
+
if (cl.contains("sm-release-label-row") && ds.releaseId) return "rel-label-row:" + ds.releaseId;
|
|
743
|
+
if (cl.contains("sm-release-cells") && ds.releaseId) return "rel-cells-row:" + ds.releaseId;
|
|
744
|
+
if (cl.contains("sm-release-unplaced-row") && ds.releaseId) return "rel-unplaced:" + ds.releaseId; // SM-253
|
|
745
|
+
if (cl.contains("sm-release-label") && ds.releaseId) return "rel-label:" + ds.releaseId;
|
|
746
|
+
if (cl.contains("sm-cell") && ds.releaseId && ds.processStepId) {
|
|
747
|
+
// SM-272: a collapsed cell renders an "N epics" indicator instead of the
|
|
748
|
+
// epic cards — give it a DISTINCT key so the morph fully swaps the node
|
|
749
|
+
// (clean content change + fresh/no drag listeners) instead of partial-
|
|
750
|
+
// morphing card children into a count.
|
|
751
|
+
return (cl.contains("sm-cell-collapsed") ? "cell-collapsed:" : "cell:")
|
|
752
|
+
+ ds.releaseId + "/" + ds.processStepId;
|
|
753
|
+
}
|
|
754
|
+
// SM-79 followup: shadow cards (live-preview placeholders during a
|
|
755
|
+
// drag projection) carry the SAME ticket-id as the real card. Without
|
|
756
|
+
// a key distinction, morphTree pairs the shadow node with the new real
|
|
757
|
+
// node after a drop — reusing the shadow's DOM (which has NO drag
|
|
758
|
+
// listener, see renderer-card.js l. 194 "if (isShadow) return card")
|
|
759
|
+
// and discarding the freshly built real card. Result: the post-drop
|
|
760
|
+
// card is undraggable and the SECOND drag silently no-ops. Give shadow
|
|
761
|
+
// cards their own key so morphTree treats them as distinct entities;
|
|
762
|
+
// when the shadow→real transition happens at drop, the shadow is
|
|
763
|
+
// REMOVED and the real card is INSERTED fresh with its listener.
|
|
764
|
+
if (ds.ticketId) {
|
|
765
|
+
return cl.contains("sm-card-shadow")
|
|
766
|
+
? "ticket-shadow:" + ds.ticketId
|
|
767
|
+
: "ticket:" + ds.ticketId;
|
|
768
|
+
}
|
|
769
|
+
if (ds.processStepId) return (cl.contains("sm-col-collapsed") ? "ps-collapsed:" : "ps:") + ds.processStepId;
|
|
770
|
+
if (ds.releaseId) return "rel:" + ds.releaseId;
|
|
771
|
+
if (ds.section) return "section:" + ds.section;
|
|
772
|
+
// Last-resort: class-based singleton keys.
|
|
773
|
+
if (cl.contains("sm-backbone")) return "section:backbone";
|
|
774
|
+
if (cl.contains("sm-backbone-corner")) return "section:backbone-corner";
|
|
775
|
+
if (cl.contains("sm-add-col")) return "section:add-col";
|
|
776
|
+
if (cl.contains("sm-add-release-btn")) return "section:add-release-btn";
|
|
777
|
+
if (cl.contains("sm-stories-grid")) return "stories-grid";
|
|
778
|
+
if (cl.contains("sm-cell-epics")) return "cell-epics";
|
|
779
|
+
if (cl.contains("sm-cell-loose")) return "cell-loose";
|
|
780
|
+
if (cl.contains("sm-add-ticket")) return "cell-add-ticket";
|
|
781
|
+
if (cl.contains("sm-card-info")) return "card-info";
|
|
782
|
+
if (cl.contains("sm-card-meta")) return "card-meta";
|
|
783
|
+
if (cl.contains("sm-story-key")) return "story-key";
|
|
784
|
+
if (cl.contains("sm-story-title")) return "story-title";
|
|
785
|
+
if (cl.contains("sm-card-status")) return "card-status";
|
|
786
|
+
if (cl.contains("sm-badge-dor")) return "badge-dor";
|
|
787
|
+
if (cl.contains("sm-badge-dod")) return "badge-dod";
|
|
788
|
+
if (cl.contains("sm-col-grip")) return "col-grip";
|
|
789
|
+
if (cl.contains("sm-col-label")) return "col-label";
|
|
790
|
+
if (cl.contains("sm-epic-header")) return "epic-header";
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Copy attributes + dataset from `newEl` onto `oldEl`. Attributes
|
|
796
|
+
* present on `oldEl` but not on `newEl` are removed. Inline `style`
|
|
797
|
+
* is overwritten wholesale via `style.cssText`.
|
|
798
|
+
*/
|
|
799
|
+
function morphAttrs(oldEl, newEl) {
|
|
800
|
+
if (oldEl.tagName !== newEl.tagName) return false; // signal: can't morph
|
|
801
|
+
// dataset
|
|
802
|
+
const newDs = newEl.dataset || {};
|
|
803
|
+
const oldDs = oldEl.dataset || {};
|
|
804
|
+
for (const k of Object.keys(newDs)) {
|
|
805
|
+
if (k === "birthRender") continue; // SM-20 instrumentation — never overwrite
|
|
806
|
+
if (oldDs[k] !== newDs[k]) oldEl.dataset[k] = newDs[k];
|
|
807
|
+
}
|
|
808
|
+
for (const k of Object.keys(oldDs)) {
|
|
809
|
+
if (k === "birthRender") continue; // SM-20 instrumentation — never delete
|
|
810
|
+
if (!(k in newDs)) delete oldEl.dataset[k];
|
|
811
|
+
}
|
|
812
|
+
// className. SM-79: preserve transient drag-state classes that the
|
|
813
|
+
// dnd layer toggles imperatively — they are NOT part of the freshly-
|
|
814
|
+
// rendered off-DOM tree, so a naive `oldEl.className = newEl.className`
|
|
815
|
+
// strips them mid-drag and the user never sees the drop-highlight or
|
|
816
|
+
// the dragging-dim.
|
|
817
|
+
if (oldEl.className !== newEl.className) {
|
|
818
|
+
const preserved = [];
|
|
819
|
+
if (oldEl.classList.contains("sm-drop-target")) preserved.push("sm-drop-target");
|
|
820
|
+
if (oldEl.classList.contains("sm-dragging")) preserved.push("sm-dragging");
|
|
821
|
+
oldEl.className = newEl.className;
|
|
822
|
+
for (const c of preserved) oldEl.classList.add(c);
|
|
823
|
+
}
|
|
824
|
+
// Other attributes (excluding class + style which we handle separately)
|
|
825
|
+
for (const attr of newEl.attributes) {
|
|
826
|
+
const name = attr.name;
|
|
827
|
+
if (name === "class" || name === "style") continue;
|
|
828
|
+
if (name.startsWith("data-")) continue; // already handled via dataset
|
|
829
|
+
if (oldEl.getAttribute(name) !== attr.value) oldEl.setAttribute(name, attr.value);
|
|
830
|
+
}
|
|
831
|
+
for (const attr of Array.from(oldEl.attributes)) {
|
|
832
|
+
const name = attr.name;
|
|
833
|
+
if (name === "class" || name === "style") continue;
|
|
834
|
+
if (name.startsWith("data-")) continue;
|
|
835
|
+
if (!newEl.hasAttribute(name)) oldEl.removeAttribute(name);
|
|
836
|
+
}
|
|
837
|
+
// style: overwrite whole cssText (cheaper than per-property diff for our cases)
|
|
838
|
+
if (oldEl.style.cssText !== newEl.style.cssText) oldEl.style.cssText = newEl.style.cssText;
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Recursively morph `oldEl` to match `newEl`. Reuses DOM identity for
|
|
844
|
+
* keyed children. Returns the morphed element (which is `oldEl`).
|
|
845
|
+
*
|
|
846
|
+
* The two roots must already be paired by the caller — typically by tag
|
|
847
|
+
* or by the host directly. For child reconciliation, we use `keyOf`.
|
|
848
|
+
*/
|
|
849
|
+
function morphTree(oldEl, newEl) {
|
|
850
|
+
if (!oldEl || !newEl) return oldEl;
|
|
851
|
+
if (oldEl.nodeType !== 1 || newEl.nodeType !== 1) return oldEl;
|
|
852
|
+
if (oldEl.tagName !== newEl.tagName) {
|
|
853
|
+
// Can't morph different tag types — replace entirely.
|
|
854
|
+
if (oldEl.parentNode) oldEl.parentNode.replaceChild(newEl, oldEl);
|
|
855
|
+
return newEl;
|
|
856
|
+
}
|
|
857
|
+
morphAttrs(oldEl, newEl);
|
|
858
|
+
// Leaf with text content only — no element children — copy text wholesale.
|
|
859
|
+
if (newEl.children.length === 0) {
|
|
860
|
+
if (oldEl.textContent !== newEl.textContent) oldEl.textContent = newEl.textContent;
|
|
861
|
+
return oldEl;
|
|
862
|
+
}
|
|
863
|
+
// Build a key→node map of old children for quick lookup.
|
|
864
|
+
const oldByKey = new Map();
|
|
865
|
+
const oldUnkeyed = [];
|
|
866
|
+
for (const child of Array.from(oldEl.children)) {
|
|
867
|
+
const k = keyOf(child);
|
|
868
|
+
if (k != null) oldByKey.set(k, child);
|
|
869
|
+
else oldUnkeyed.push(child);
|
|
870
|
+
}
|
|
871
|
+
const seenKeys = new Set();
|
|
872
|
+
const seenUnkeyed = new Set(); // tracks which unkeyed olds got paired
|
|
873
|
+
let unkeyedIdx = 0;
|
|
874
|
+
// Walk new children left-to-right; insert/move into oldEl at the right spot.
|
|
875
|
+
let cursor = oldEl.firstChild;
|
|
876
|
+
for (const newChild of Array.from(newEl.children)) {
|
|
877
|
+
const k = keyOf(newChild);
|
|
878
|
+
let pair = null;
|
|
879
|
+
if (k != null && oldByKey.has(k)) {
|
|
880
|
+
pair = oldByKey.get(k);
|
|
881
|
+
seenKeys.add(k);
|
|
882
|
+
} else if (k == null && unkeyedIdx < oldUnkeyed.length) {
|
|
883
|
+
const candidate = oldUnkeyed[unkeyedIdx++];
|
|
884
|
+
// Position-paired unkeyed nodes must share tag name; on mismatch
|
|
885
|
+
// we DON'T pair (leave candidate for the cleanup loop to remove).
|
|
886
|
+
if (candidate.tagName === newChild.tagName) {
|
|
887
|
+
pair = candidate;
|
|
888
|
+
seenUnkeyed.add(candidate);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (pair) {
|
|
892
|
+
// Move pair into the cursor position if it's not already there.
|
|
893
|
+
if (pair !== cursor) oldEl.insertBefore(pair, cursor);
|
|
894
|
+
morphTree(pair, newChild);
|
|
895
|
+
cursor = pair.nextSibling;
|
|
896
|
+
} else {
|
|
897
|
+
// Brand-new child — insert before cursor (or append if cursor is null).
|
|
898
|
+
oldEl.insertBefore(newChild, cursor);
|
|
899
|
+
// cursor stays the same (newChild is now before it).
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// Remove any old keyed children that weren't paired.
|
|
903
|
+
for (const [k, oldChild] of oldByKey) {
|
|
904
|
+
if (!seenKeys.has(k) && oldChild.parentNode === oldEl) {
|
|
905
|
+
oldEl.removeChild(oldChild);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Remove every old unkeyed child that didn't end up paired — covers both
|
|
909
|
+
// "advanced past it due to tag mismatch" and "ran out of new children".
|
|
910
|
+
for (const child of oldUnkeyed) {
|
|
911
|
+
if (!seenUnkeyed.has(child) && child.parentNode === oldEl) {
|
|
912
|
+
oldEl.removeChild(child);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return oldEl;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---- SM-20 instrumentation (opt-in) ----------------------------------
|
|
919
|
+
//
|
|
920
|
+
// OFF by default. Toggle in DevTools console:
|
|
921
|
+
// window.STORYMAP_DEBUG_RENDER = true // enable
|
|
922
|
+
// window.STORYMAP_DEBUG_RENDER = false // disable
|
|
923
|
+
// (Takes effect on the NEXT renderInto — refresh isn't needed.)
|
|
924
|
+
//
|
|
925
|
+
// When enabled, every keyed structural node gets a `data-birth-render`
|
|
926
|
+
// attribute on first creation. The Phase-C morph deliberately skips this
|
|
927
|
+
// attribute when copying dataset (see morphAttrs), so a survivor keeps
|
|
928
|
+
// its original birth-render while a fresh replacement shows the current
|
|
929
|
+
// counter. After each render, a console summary line lists per-entity-
|
|
930
|
+
// type preserved/fresh counts so a real DOM rebuild stands out.
|
|
931
|
+
//
|
|
932
|
+
// Found Phase D of SM-20 (FLIP-selector-collision) with this — keep it
|
|
933
|
+
// around for future investigations rather than ripping it out.
|
|
934
|
+
let _renderCounter = 0;
|
|
935
|
+
|
|
936
|
+
function debugRenderOn() {
|
|
937
|
+
return typeof window !== "undefined" && !!window.STORYMAP_DEBUG_RENDER;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function tagBirth(node) {
|
|
941
|
+
if (!debugRenderOn()) return node;
|
|
942
|
+
if (!node || node.nodeType !== 1) return node;
|
|
943
|
+
if (node.dataset && !node.dataset.birthRender) {
|
|
944
|
+
node.dataset.birthRender = String(_renderCounter);
|
|
945
|
+
}
|
|
946
|
+
return node;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function instrumentationReport(host, renderId) {
|
|
950
|
+
if (!debugRenderOn()) return;
|
|
951
|
+
if (typeof console === "undefined" || typeof console.log !== "function") return;
|
|
952
|
+
const groups = [
|
|
953
|
+
[".sm-grid", "grid"],
|
|
954
|
+
[".sm-backbone", "backbone"],
|
|
955
|
+
[".sm-backbone-col", "ps-cols"],
|
|
956
|
+
[".sm-release-row", "rel-rows"],
|
|
957
|
+
[".sm-release-label-row", "label-rows"],
|
|
958
|
+
[".sm-cell", "cells"],
|
|
959
|
+
[".sm-release-unplaced-row", "rel-unplaced"],
|
|
960
|
+
[".sm-epic-card", "epic-cards"],
|
|
961
|
+
[".sm-story-card", "story-cards"]
|
|
962
|
+
];
|
|
963
|
+
const rid = String(renderId);
|
|
964
|
+
const lines = [];
|
|
965
|
+
for (const [sel, label] of groups) {
|
|
966
|
+
const nodes = host.querySelectorAll(sel);
|
|
967
|
+
if (nodes.length === 0) continue;
|
|
968
|
+
let preserved = 0, fresh = 0;
|
|
969
|
+
for (const n of nodes) {
|
|
970
|
+
if (n.dataset.birthRender === rid) fresh++;
|
|
971
|
+
else preserved++;
|
|
972
|
+
}
|
|
973
|
+
lines.push(label + ": " + preserved + "p/" + fresh + "f");
|
|
974
|
+
}
|
|
975
|
+
console.log("[SM-20] render #" + renderId + " — " + lines.join(", "));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ---- DOM helper ------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
function el(tag, props, children) {
|
|
981
|
+
const e = document.createElement(tag);
|
|
982
|
+
if (props) {
|
|
983
|
+
for (const k of Object.keys(props)) {
|
|
984
|
+
if (k === "class") e.className = props[k];
|
|
985
|
+
else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
|
|
986
|
+
else if (k === "style") Object.assign(e.style, props[k]);
|
|
987
|
+
else if (k === "text") e.textContent = props[k];
|
|
988
|
+
else if (k === "html") e.innerHTML = props[k];
|
|
989
|
+
else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
990
|
+
else if (k === "draggable") e.draggable = !!props[k];
|
|
991
|
+
else e.setAttribute(k, props[k]);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
995
|
+
return e;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ---- Card renderers --------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
// Story-Karten kommen aus der geteilten renderer-card.js — wir reichen
|
|
1001
|
+
// Story-Map-spezifische Optionen (fixe Card-Breite, projection-clear in
|
|
1002
|
+
// onEnd) durch und nutzen das gleiche DOM-Markup wie Kanban.
|
|
1003
|
+
function renderStoryCard(t, opts, isShadow, widthOverride) {
|
|
1004
|
+
return tagBirth(rendererCard.renderTicketCard(t, {
|
|
1005
|
+
onTicketClick: opts && opts.onTicketClick,
|
|
1006
|
+
// SM-158: ticket-key links to the full-page editor.
|
|
1007
|
+
projectId: currentSnapshot && currentSnapshot.project && currentSnapshot.project.id,
|
|
1008
|
+
dragType: isShadow ? null : DRAG_TYPES.STORY,
|
|
1009
|
+
dragId: t.id,
|
|
1010
|
+
dragOnEnd: isShadow ? null : (() => { if (_dragProjection) setDragProjection(null); }),
|
|
1011
|
+
isShadow: isShadow,
|
|
1012
|
+
width: typeof widthOverride === "number" ? widthOverride : STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX,
|
|
1013
|
+
linkInfo: _linkIndex && _linkIndex.get(t.id) || null,
|
|
1014
|
+
// SM-60: test-definition cards get the last-execution badge.
|
|
1015
|
+
lastExecution: _lastExecIndex && _lastExecIndex.get(t.id) || null
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Build the effective story-list for an epic given the active drag
|
|
1021
|
+
* projection: origin removed if its current epic equals this one, and
|
|
1022
|
+
* the dragged ticket inserted as a shadow at insertionIndex if this
|
|
1023
|
+
* epic is the projection target.
|
|
1024
|
+
*/
|
|
1025
|
+
function effectiveStoriesFor(epic, baseStories, snapshot) {
|
|
1026
|
+
if (!_dragProjection) return baseStories.map(s => ({ ticket: s, shadow: false }));
|
|
1027
|
+
const out = baseStories
|
|
1028
|
+
.filter(s => s.id !== _dragProjection.ticketId)
|
|
1029
|
+
.map(s => ({ ticket: s, shadow: false }));
|
|
1030
|
+
const tgt = _dragProjection.target;
|
|
1031
|
+
if (tgt && tgt.type === "epic" && tgt.epicId === epic.id) {
|
|
1032
|
+
const draggedTicket = (snapshot.tickets || []).find(t => t.id === _dragProjection.ticketId);
|
|
1033
|
+
if (draggedTicket) {
|
|
1034
|
+
const idx = Math.max(0, Math.min(out.length, _dragProjection.insertionIndex || 0));
|
|
1035
|
+
out.splice(idx, 0, { ticket: draggedTicket, shadow: true });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function renderEpicCard(entry, opts) {
|
|
1042
|
+
const epic = entry.epic;
|
|
1043
|
+
const isShadow = !!entry.shadow;
|
|
1044
|
+
// SM-84: epic-level collapse mirrors SM-83's release-level collapse.
|
|
1045
|
+
// opts.collapsedEpics is a Set<epicId>; shadow epics (live-drag preview)
|
|
1046
|
+
// are never collapsed regardless of their real state — the user is
|
|
1047
|
+
// currently interacting with them and needs to see the destination.
|
|
1048
|
+
const isCollapsed = !isShadow
|
|
1049
|
+
&& !!(opts && opts.collapsedEpics && opts.collapsedEpics.has && opts.collapsedEpics.has(epic.id));
|
|
1050
|
+
const card = tagBirth(el("div", {
|
|
1051
|
+
class: "sm-epic-card sm-cluster-plum sm-status-" + epic.status
|
|
1052
|
+
+ (isShadow ? " sm-card-shadow" : "")
|
|
1053
|
+
+ (isCollapsed ? " sm-epic-card-collapsed" : ""),
|
|
1054
|
+
dataset: { ticketId: epic.id, ticketType: "epic" }
|
|
1055
|
+
}));
|
|
1056
|
+
const header = el("div", { class: "sm-epic-header" });
|
|
1057
|
+
const info = el("div", { class: "sm-epic-info" });
|
|
1058
|
+
info.appendChild(el("span", { class: "sm-epic-title", text: epic.title }));
|
|
1059
|
+
// (SM-238's compact epic-header "X/Y" badge was removed — it crowded the
|
|
1060
|
+
// header next to the "+"/chevron and confused the add affordance. Release
|
|
1061
|
+
// progress is shown by the per-release X/Y badge, which is enough.)
|
|
1062
|
+
if (epic.ticketKey) {
|
|
1063
|
+
// SM-158: epic key links to the full-page editor too. Reuse the shared
|
|
1064
|
+
// helper but keep the .sm-epic-key class for existing styling/selectors.
|
|
1065
|
+
const pid = currentSnapshot && currentSnapshot.project && currentSnapshot.project.id;
|
|
1066
|
+
const keyEl = rendererCard.buildTicketKeyEl(epic.ticketKey, pid);
|
|
1067
|
+
keyEl.classList.add("sm-epic-key");
|
|
1068
|
+
info.appendChild(keyEl);
|
|
1069
|
+
}
|
|
1070
|
+
header.appendChild(info);
|
|
1071
|
+
// SM-49: link badges on epic header (epics participate in dependencies too).
|
|
1072
|
+
const epicLinkInfo = _linkIndex && _linkIndex.get(epic.id) || null;
|
|
1073
|
+
if (epicLinkInfo && (epicLinkInfo.forward > 0 || epicLinkInfo.backward > 0)) {
|
|
1074
|
+
const badges = el("div", { class: "sm-epic-badges" });
|
|
1075
|
+
if (epicLinkInfo.forward > 0) {
|
|
1076
|
+
const sem = rendererCard.dominantSemantic(epicLinkInfo.forwardSemantics);
|
|
1077
|
+
badges.appendChild(rendererCard.buildLinkBadge("forward", epicLinkInfo.forward, sem, epicLinkInfo.forwardTargetKeys, epicLinkInfo));
|
|
1078
|
+
}
|
|
1079
|
+
if (epicLinkInfo.backward > 0) {
|
|
1080
|
+
const sem = rendererCard.dominantSemantic(epicLinkInfo.backwardSemantics);
|
|
1081
|
+
badges.appendChild(rendererCard.buildLinkBadge("backward", epicLinkInfo.backward, sem, epicLinkInfo.backwardSourceKeys, epicLinkInfo));
|
|
1082
|
+
}
|
|
1083
|
+
header.appendChild(badges);
|
|
1084
|
+
}
|
|
1085
|
+
// SM-84: collapse chevron at the rightmost end of the header — sits AFTER
|
|
1086
|
+
// the link-badges so the badges keep their familiar position. ▼ = expanded,
|
|
1087
|
+
// ▶ = collapsed. Click stopPropagation so the surrounding card's dblclick-
|
|
1088
|
+
// to-edit handler doesn't fire. Shadows skip the chevron — they're drag
|
|
1089
|
+
// preview, never user-interactive.
|
|
1090
|
+
if (!isShadow) {
|
|
1091
|
+
// SM-138: Add-„+" im Header (oben rechts) statt am Karten-Boden — so
|
|
1092
|
+
// verbraucht der Add-Affordance keine Karten-Zeile und bricht das Raster
|
|
1093
|
+
// nicht. Öffnet onAddTicket mit position.epicId vorbelegt.
|
|
1094
|
+
const addBtn = el("button", {
|
|
1095
|
+
class: "sm-epic-add",
|
|
1096
|
+
type: "button",
|
|
1097
|
+
title: "Add story under this epic",
|
|
1098
|
+
text: "+"
|
|
1099
|
+
});
|
|
1100
|
+
addBtn.addEventListener("click", (ev) => {
|
|
1101
|
+
ev.stopPropagation();
|
|
1102
|
+
if (opts && typeof opts.onAddTicket === "function") {
|
|
1103
|
+
opts.onAddTicket({
|
|
1104
|
+
releaseId: epic.position && epic.position.releaseId,
|
|
1105
|
+
processStepId: epic.position && epic.position.processStepId,
|
|
1106
|
+
epicId: epic.id,
|
|
1107
|
+
type: "user-story"
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
header.appendChild(addBtn);
|
|
1112
|
+
const chevron = el("button", {
|
|
1113
|
+
class: "sm-epic-chevron",
|
|
1114
|
+
type: "button",
|
|
1115
|
+
title: isCollapsed ? "Expand epic" : "Collapse epic",
|
|
1116
|
+
text: isCollapsed ? "▶" : "▼"
|
|
1117
|
+
});
|
|
1118
|
+
chevron.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
|
|
1119
|
+
chevron.addEventListener("click", (ev) => {
|
|
1120
|
+
ev.stopPropagation();
|
|
1121
|
+
if (opts && typeof opts.onToggleEpicCollapsed === "function") {
|
|
1122
|
+
opts.onToggleEpicCollapsed(epic.id);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
header.appendChild(chevron);
|
|
1126
|
+
}
|
|
1127
|
+
card.appendChild(header);
|
|
1128
|
+
|
|
1129
|
+
const effStories = effectiveStoriesFor(epic, entry.stories || [], currentSnapshot);
|
|
1130
|
+
// SM-135 (Schlanke-Säulen-Umbau): jedes Epic ist eine schlanke
|
|
1131
|
+
// Ein-Spalten-Säule fester Breite (= STORY_CARD_WIDTH_PX, exakt die
|
|
1132
|
+
// Breite eines losen Tickets). Stories stapeln einspaltig vertikal und
|
|
1133
|
+
// wachsen beliebig nach unten — KEIN Sub-Spalten-Wrap, KEIN 5er-Cap mehr.
|
|
1134
|
+
// Damit fluchten Header und jede Story-Zeile über alle Epic-Säulen einer
|
|
1135
|
+
// Release-Row hinweg (Ausrichtung by construction).
|
|
1136
|
+
const epicW = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX;
|
|
1137
|
+
card.style.width = epicW + "px";
|
|
1138
|
+
if (effStories.length > 0) {
|
|
1139
|
+
// Eine Spalte: inside-width = epicW − 2×Grid-Padding (kein inter-col-Gap,
|
|
1140
|
+
// weil es nur eine Spalte gibt; der vertikale Abstand kommt vom grid-gap).
|
|
1141
|
+
const insideStoryW = epicW - 2 * STORY_MAP_LAYOUT.STORIES_GRID_PADDING_PX;
|
|
1142
|
+
const grid = el("div", {
|
|
1143
|
+
class: "sm-stories-grid" + (isCollapsed ? " sm-stories-grid-collapsed" : "")
|
|
1144
|
+
});
|
|
1145
|
+
grid.style.gridAutoFlow = "row";
|
|
1146
|
+
grid.style.gridTemplateColumns = insideStoryW + "px";
|
|
1147
|
+
// Feste Karten-Höhe pro Zeile; gridAutoRows wächst beliebig mit der
|
|
1148
|
+
// Story-Zahl (kein repeat(N)-Cap, kein reservierter Leerraum).
|
|
1149
|
+
grid.style.gridAutoRows = "var(--sm-card-h)";
|
|
1150
|
+
// SM-84: collapsed on initial render starts at max-height:0 (mirrors
|
|
1151
|
+
// the SM-83 cells-row pattern). Toggling owns the transition; this is
|
|
1152
|
+
// just the "already collapsed at page-load" starting state.
|
|
1153
|
+
if (isCollapsed) grid.style.maxHeight = "0px";
|
|
1154
|
+
for (const it of effStories) grid.appendChild(renderStoryCard(it.ticket, opts, it.shadow, insideStoryW));
|
|
1155
|
+
card.appendChild(grid);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// SM-138: Der „+ Add"-Button wanderte vom Karten-Boden in den Epic-Header
|
|
1159
|
+
// (oben rechts, siehe oben) — kein Boden-Button mehr.
|
|
1160
|
+
|
|
1161
|
+
// Double-click on header opens the epic-detail modal. Single-click stays
|
|
1162
|
+
// a no-op so it doesn't compete with the drag-gesture threshold.
|
|
1163
|
+
header.addEventListener("dblclick", (ev) => {
|
|
1164
|
+
ev.stopPropagation();
|
|
1165
|
+
if (opts && typeof opts.onTicketClick === "function") opts.onTicketClick(epic.id);
|
|
1166
|
+
});
|
|
1167
|
+
if (isShadow) return card; // preview-only — no DnD on shadows
|
|
1168
|
+
dnd.enableDraggable(card, {
|
|
1169
|
+
dragType: DRAG_TYPES.EPIC,
|
|
1170
|
+
dragId: epic.id,
|
|
1171
|
+
onEnd: () => { if (_dragProjection) setDragProjection(null); }
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// The epic card itself is a drop-target for stories (re-assign to this epic).
|
|
1175
|
+
dnd.enableDropTarget(card, {
|
|
1176
|
+
accepts: [DRAG_TYPES.STORY],
|
|
1177
|
+
onEnter: (el) => el.classList.add("sm-drop-target"),
|
|
1178
|
+
onLeave: (el) => el.classList.remove("sm-drop-target"),
|
|
1179
|
+
onMove: ({ id, clientY }) => {
|
|
1180
|
+
// Compute insertion index from cursor Y vs. existing story rects.
|
|
1181
|
+
const grid = card.querySelector(".sm-stories-grid");
|
|
1182
|
+
let insertionIndex = 0;
|
|
1183
|
+
if (grid) {
|
|
1184
|
+
const cards = Array.from(grid.querySelectorAll(".sm-story-card"));
|
|
1185
|
+
// Exclude the current shadow card from the index search.
|
|
1186
|
+
const realCards = cards.filter(c => !c.classList.contains("sm-card-shadow"));
|
|
1187
|
+
insertionIndex = realCards.length;
|
|
1188
|
+
for (let i = 0; i < realCards.length; i++) {
|
|
1189
|
+
const r = realCards[i].getBoundingClientRect();
|
|
1190
|
+
if (clientY < r.top + r.height / 2) { insertionIndex = i; break; }
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
setDragProjection({
|
|
1194
|
+
ticketId: id,
|
|
1195
|
+
target: { type: "epic", epicId: epic.id },
|
|
1196
|
+
insertionIndex: insertionIndex
|
|
1197
|
+
});
|
|
1198
|
+
},
|
|
1199
|
+
onDrop: ({ type, id }) => {
|
|
1200
|
+
if (type === DRAG_TYPES.STORY && typeof opts.onStoryDrop === "function") {
|
|
1201
|
+
const idx = (_dragProjection && _dragProjection.target
|
|
1202
|
+
&& _dragProjection.target.type === "epic"
|
|
1203
|
+
&& _dragProjection.target.epicId === epic.id)
|
|
1204
|
+
? _dragProjection.insertionIndex : undefined;
|
|
1205
|
+
opts.onStoryDrop(id, epic.id, idx);
|
|
1206
|
+
// SM-84: dropping a story on a collapsed epic auto-expands the
|
|
1207
|
+
// epic so the user sees the new story land. Without this the
|
|
1208
|
+
// story would silently sit inside the still-collapsed grid.
|
|
1209
|
+
if (isCollapsed && opts && typeof opts.onToggleEpicCollapsed === "function") {
|
|
1210
|
+
opts.onToggleEpicCollapsed(epic.id);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
setDragProjection(null);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
return card;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ---- Cell / Row / Backbone renderers ---------------------------------
|
|
1221
|
+
|
|
1222
|
+
// SM-272: compact cell for a collapsed column — just an "N epics" indicator
|
|
1223
|
+
// (N = epics mapped into this Release×Step cell). No cards, no add-button, no
|
|
1224
|
+
// drop target; the column is a narrow overview strip until expanded again.
|
|
1225
|
+
function renderCollapsedCell(releaseId, processStepId, cellData) {
|
|
1226
|
+
const nEpics = ((cellData && cellData.epics) || []).length;
|
|
1227
|
+
const nLoose = ((cellData && cellData.loose) || []).length;
|
|
1228
|
+
const cell = tagBirth(el("div", {
|
|
1229
|
+
class: "sm-cell sm-cell-collapsed",
|
|
1230
|
+
dataset: { releaseId, processStepId }
|
|
1231
|
+
}));
|
|
1232
|
+
// Show the epic count, plus a loose-ticket marker so a cell that holds only
|
|
1233
|
+
// loose (epic-less) work doesn't collapse to an empty strip (review).
|
|
1234
|
+
const parts = [];
|
|
1235
|
+
if (nEpics > 0) parts.push(nEpics + " epic" + (nEpics === 1 ? "" : "s"));
|
|
1236
|
+
if (nLoose > 0) parts.push(nLoose + " loose");
|
|
1237
|
+
if (parts.length) {
|
|
1238
|
+
cell.appendChild(el("span", {
|
|
1239
|
+
class: "sm-cell-collapsed-count",
|
|
1240
|
+
text: parts.join(" · "),
|
|
1241
|
+
title: parts.join(" · ") + " in this cell — expand the column to see them"
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
return cell;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function renderCell(releaseId, processStepId, cellData, opts) {
|
|
1248
|
+
const epicEntries = (cellData && cellData.epics) || [];
|
|
1249
|
+
const looseList = effectiveLooseFor(releaseId, processStepId, (cellData && cellData.loose) || [], currentSnapshot);
|
|
1250
|
+
const cell = tagBirth(el("div", {
|
|
1251
|
+
class: "sm-cell",
|
|
1252
|
+
dataset: { releaseId, processStepId }
|
|
1253
|
+
}));
|
|
1254
|
+
// SM-136 (Schlanke-Säulen-Umbau): Epic-Säulen UND die Loose-Säule stehen
|
|
1255
|
+
// in EINER horizontalen, nicht-wrappenden Reihe (.sm-cell-epics) neben-
|
|
1256
|
+
// einander. Die Reihe wird auch dann gebaut, wenn es nur lose Tickets gibt.
|
|
1257
|
+
const hasEpics = epicEntries.length > 0;
|
|
1258
|
+
const hasLoose = looseList.length > 0;
|
|
1259
|
+
if (hasEpics || hasLoose) {
|
|
1260
|
+
const row = el("div", { class: "sm-cell-epics" });
|
|
1261
|
+
for (const entry of epicEntries) row.appendChild(renderEpicCard(entry, opts));
|
|
1262
|
+
if (hasLoose) {
|
|
1263
|
+
// Lose Tickets bekommen ihre EIGENE schlanke Säule rechts neben den
|
|
1264
|
+
// Epics — mit einem Header-Band auf Epic-Header-Höhe, damit die erste
|
|
1265
|
+
// lose Karte exakt mit der ersten Story-Zeile der Epics fluchtet
|
|
1266
|
+
// (Baseline by construction). Single-Column, wächst nach unten.
|
|
1267
|
+
const looseCard = el("div", { class: "sm-loose-card" });
|
|
1268
|
+
looseCard.style.width = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX + "px";
|
|
1269
|
+
const looseHeader = el("div", { class: "sm-loose-header" });
|
|
1270
|
+
looseHeader.appendChild(el("span", { class: "sm-loose-title", text: "Unassigned" }));
|
|
1271
|
+
looseCard.appendChild(looseHeader);
|
|
1272
|
+
const insideW = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX
|
|
1273
|
+
- 2 * STORY_MAP_LAYOUT.STORIES_GRID_PADDING_PX;
|
|
1274
|
+
const looseHost = el("div", {
|
|
1275
|
+
class: "sm-cell-loose",
|
|
1276
|
+
title: "Loose tickets — not bound to any epic in this cell"
|
|
1277
|
+
});
|
|
1278
|
+
looseHost.style.gridTemplateColumns = insideW + "px";
|
|
1279
|
+
for (const it of looseList) {
|
|
1280
|
+
looseHost.appendChild(renderStoryCard(it.ticket, opts, it.shadow, insideW));
|
|
1281
|
+
}
|
|
1282
|
+
looseCard.appendChild(looseHost);
|
|
1283
|
+
row.appendChild(looseCard);
|
|
1284
|
+
}
|
|
1285
|
+
cell.appendChild(row);
|
|
1286
|
+
}
|
|
1287
|
+
// Ghost-style add button at the bottom of every cell (sticky to bottom).
|
|
1288
|
+
const add = el("button", {
|
|
1289
|
+
class: "sm-add-ticket",
|
|
1290
|
+
title: "Add ticket in this cell",
|
|
1291
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Item</span>'
|
|
1292
|
+
});
|
|
1293
|
+
add.addEventListener("click", (ev) => {
|
|
1294
|
+
ev.stopPropagation();
|
|
1295
|
+
if (opts && typeof opts.onAddTicket === "function") {
|
|
1296
|
+
opts.onAddTicket({ releaseId, processStepId });
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
cell.appendChild(add);
|
|
1300
|
+
|
|
1301
|
+
// Cells accept BOTH epic- and story-drops.
|
|
1302
|
+
// - Epic on cell: move into this (release, processStep) at projected
|
|
1303
|
+
// insertion index — works for inter-cell move AND intra-cell reorder.
|
|
1304
|
+
// - Story on cell (but not on an epic-card): place as loose ticket in
|
|
1305
|
+
// the cell — clears epicId, keeps releaseId+processStepId.
|
|
1306
|
+
dnd.enableDropTarget(cell, {
|
|
1307
|
+
accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
|
|
1308
|
+
onEnter: (el) => el.classList.add("sm-drop-target"),
|
|
1309
|
+
onLeave: (el) => el.classList.remove("sm-drop-target"),
|
|
1310
|
+
onMove: ({ type, id, clientX, clientY }) => {
|
|
1311
|
+
if (type === DRAG_TYPES.EPIC) {
|
|
1312
|
+
// Insertion index across the side-by-side epic row (.sm-cell-epics).
|
|
1313
|
+
// Compare against real (non-shadow) epic-card mid-X.
|
|
1314
|
+
const host = cell.querySelector(".sm-cell-epics");
|
|
1315
|
+
let insertionIndex = 0;
|
|
1316
|
+
if (host) {
|
|
1317
|
+
const real = Array.from(host.children).filter(c => !c.classList.contains("sm-card-shadow"));
|
|
1318
|
+
insertionIndex = real.length;
|
|
1319
|
+
for (let i = 0; i < real.length; i++) {
|
|
1320
|
+
const r = real[i].getBoundingClientRect();
|
|
1321
|
+
if (clientX < r.left + r.width / 2) { insertionIndex = i; break; }
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
setDragProjection({
|
|
1325
|
+
ticketId: id,
|
|
1326
|
+
target: { type: "cell-epics", releaseId, processStepId },
|
|
1327
|
+
insertionIndex: insertionIndex
|
|
1328
|
+
});
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (type !== DRAG_TYPES.STORY) return;
|
|
1332
|
+
// Insertion index over the loose-list only.
|
|
1333
|
+
const host = cell.querySelector(".sm-cell-loose");
|
|
1334
|
+
let insertionIndex = 0;
|
|
1335
|
+
if (host) {
|
|
1336
|
+
const real = Array.from(host.children).filter(c => !c.classList.contains("sm-card-shadow"));
|
|
1337
|
+
insertionIndex = real.length;
|
|
1338
|
+
for (let i = 0; i < real.length; i++) {
|
|
1339
|
+
const r = real[i].getBoundingClientRect();
|
|
1340
|
+
if (clientY < r.top + r.height / 2) { insertionIndex = i; break; }
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
setDragProjection({
|
|
1344
|
+
ticketId: id,
|
|
1345
|
+
target: { type: "cell", releaseId, processStepId },
|
|
1346
|
+
insertionIndex: insertionIndex
|
|
1347
|
+
});
|
|
1348
|
+
},
|
|
1349
|
+
onDrop: ({ type, id }) => {
|
|
1350
|
+
if (type === DRAG_TYPES.EPIC && typeof opts.onEpicDrop === "function") {
|
|
1351
|
+
const idx = (_dragProjection && _dragProjection.target
|
|
1352
|
+
&& _dragProjection.target.type === "cell-epics"
|
|
1353
|
+
&& _dragProjection.target.releaseId === releaseId
|
|
1354
|
+
&& _dragProjection.target.processStepId === processStepId)
|
|
1355
|
+
? _dragProjection.insertionIndex : undefined;
|
|
1356
|
+
opts.onEpicDrop(id, processStepId, releaseId, idx);
|
|
1357
|
+
} else if (type === DRAG_TYPES.STORY && typeof opts.onCellStoryDrop === "function") {
|
|
1358
|
+
const idx = (_dragProjection && _dragProjection.target
|
|
1359
|
+
&& _dragProjection.target.type === "cell"
|
|
1360
|
+
&& _dragProjection.target.releaseId === releaseId
|
|
1361
|
+
&& _dragProjection.target.processStepId === processStepId)
|
|
1362
|
+
? _dragProjection.insertionIndex : undefined;
|
|
1363
|
+
opts.onCellStoryDrop(id, releaseId, processStepId, idx);
|
|
1364
|
+
}
|
|
1365
|
+
setDragProjection(null);
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
return cell;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Build the effective loose-list for a cell given the active drag
|
|
1373
|
+
* projection: origin removed if its current cell equals this one, and
|
|
1374
|
+
* the dragged ticket inserted as a shadow at insertionIndex if this
|
|
1375
|
+
* cell is the projection target.
|
|
1376
|
+
*/
|
|
1377
|
+
function effectiveLooseFor(releaseId, processStepId, base, snapshot) {
|
|
1378
|
+
const proj = _dragProjection;
|
|
1379
|
+
if (!proj) return base.map(t => ({ ticket: t, shadow: false }));
|
|
1380
|
+
const out = base
|
|
1381
|
+
.filter(t => t.id !== proj.ticketId)
|
|
1382
|
+
.map(t => ({ ticket: t, shadow: false }));
|
|
1383
|
+
const tgt = proj.target;
|
|
1384
|
+
if (tgt && tgt.type === "cell"
|
|
1385
|
+
&& tgt.releaseId === releaseId
|
|
1386
|
+
&& tgt.processStepId === processStepId) {
|
|
1387
|
+
const draggedTicket = (snapshot.tickets || []).find(t => t.id === proj.ticketId);
|
|
1388
|
+
if (draggedTicket && draggedTicket.type !== "epic") {
|
|
1389
|
+
const idx = Math.max(0, Math.min(out.length, proj.insertionIndex || 0));
|
|
1390
|
+
out.splice(idx, 0, { ticket: draggedTicket, shadow: true });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Erzeugt `grid-template-columns`-String für Backbone und Cells-Row:
|
|
1398
|
+
* pro PS-Spalte eine `calc(multiplier * var(--sm-backbone-col-w))`-
|
|
1399
|
+
* Sektion + Add-Column-Slot am rechten Rand (variable Breite). Es gibt
|
|
1400
|
+
* KEINE führende Release-Label-Spalte mehr — das Release-Label sitzt in
|
|
1401
|
+
* einer eigenen Zeile unter den Cells.
|
|
1402
|
+
*/
|
|
1403
|
+
function gridTemplateForColumns(columns, columnMultipliers, withAddSlot, collapsedColumns) {
|
|
1404
|
+
const parts = [];
|
|
1405
|
+
for (const c of columns) {
|
|
1406
|
+
// SM-272: a collapsed column is a fixed narrow strip — NOT the epic-count-
|
|
1407
|
+
// driven columnMultiplier width that made busy columns run very wide.
|
|
1408
|
+
if (collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id)) {
|
|
1409
|
+
parts.push(STORY_MAP_LAYOUT.COLLAPSED_COL_WIDTH_PX + "px");
|
|
1410
|
+
} else {
|
|
1411
|
+
const m = (columnMultipliers && columnMultipliers.get(c.id)) || 1;
|
|
1412
|
+
parts.push("calc(" + m + " * var(--sm-backbone-col-w))");
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if (withAddSlot) parts.push("auto");
|
|
1416
|
+
return parts.join(" ");
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// SM-245: compute the new process-step order from the CURRENT snapshot (the
|
|
1420
|
+
// source of truth) + the drag projection's beforeId — NOT from a captured
|
|
1421
|
+
// `columns` array. The Phase-C morph reuses backbone columns by ps:id and
|
|
1422
|
+
// keeps their ORIGINAL dnd registration, so a column's onDrop closure holds
|
|
1423
|
+
// the pre-drag column order; reading it persisted a stale (no-op) order and
|
|
1424
|
+
// the column snapped back. Ticket-reorder (onStoryDrop) is robust for exactly
|
|
1425
|
+
// this reason — it reads store.get(). Pure + exported for tests.
|
|
1426
|
+
function processStepOrder(snapshot) {
|
|
1427
|
+
return ((snapshot && snapshot.processSteps) || [])
|
|
1428
|
+
.filter(p => !p.isDeleted)
|
|
1429
|
+
.slice()
|
|
1430
|
+
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
|
1431
|
+
.map(p => p.id);
|
|
1432
|
+
}
|
|
1433
|
+
function sameIdOrder(a, b) {
|
|
1434
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1435
|
+
}
|
|
1436
|
+
function reorderedProcessStepIds(snapshot, draggedId, beforeId) {
|
|
1437
|
+
const out = processStepOrder(snapshot).filter(id => id !== draggedId);
|
|
1438
|
+
if (beforeId == null) { out.push(draggedId); return out; }
|
|
1439
|
+
const at = out.indexOf(beforeId);
|
|
1440
|
+
if (at < 0) out.push(draggedId);
|
|
1441
|
+
else out.splice(at, 0, draggedId);
|
|
1442
|
+
return out;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function renderBackbone(columns, columnMultipliers, opts) {
|
|
1446
|
+
const collapsedColumns = (opts && opts.collapsedColumns) || null;
|
|
1447
|
+
const backbone = tagBirth(el("div", { class: "sm-backbone" }));
|
|
1448
|
+
backbone.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
|
|
1449
|
+
// No corner spacer anymore — there's no release-label column on the left.
|
|
1450
|
+
const proj = _dragProjection;
|
|
1451
|
+
const isPsProjection = proj && proj.target && proj.target.type === "process-step";
|
|
1452
|
+
for (const c of columns) {
|
|
1453
|
+
const isShadow = isPsProjection && proj.ticketId === c.id;
|
|
1454
|
+
const isColCollapsed = !!(collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id));
|
|
1455
|
+
const col = tagBirth(el("div", {
|
|
1456
|
+
class: "sm-backbone-col" + (isShadow ? " sm-col-shadow" : "") + (isColCollapsed ? " sm-col-collapsed" : ""),
|
|
1457
|
+
dataset: { processStepId: c.id },
|
|
1458
|
+
title: "Click to edit · drag to reorder"
|
|
1459
|
+
}));
|
|
1460
|
+
// SM-272: collapse chevron (▼ expanded / ▶ collapsed) at the column head.
|
|
1461
|
+
// stopPropagation so it doesn't open the edit dialog; toggles the per-
|
|
1462
|
+
// project collapsedColumns set + re-renders (width + cell content change).
|
|
1463
|
+
const colChevron = el("button", {
|
|
1464
|
+
class: "sm-col-chevron",
|
|
1465
|
+
type: "button",
|
|
1466
|
+
title: isColCollapsed ? "Expand column" : "Collapse column",
|
|
1467
|
+
text: isColCollapsed ? "▶" : "▼"
|
|
1468
|
+
});
|
|
1469
|
+
colChevron.setAttribute("aria-expanded", isColCollapsed ? "false" : "true");
|
|
1470
|
+
colChevron.addEventListener("click", (ev) => {
|
|
1471
|
+
ev.stopPropagation();
|
|
1472
|
+
if (opts && typeof opts.onToggleColumnCollapsed === "function") opts.onToggleColumnCollapsed(c.id);
|
|
1473
|
+
if (typeof _rerender === "function") _rerender();
|
|
1474
|
+
});
|
|
1475
|
+
col.appendChild(colChevron);
|
|
1476
|
+
// Grip-handle as a visual cue that the column is draggable.
|
|
1477
|
+
col.appendChild(el("span", { class: "sm-col-grip", text: "⋮⋮" }));
|
|
1478
|
+
col.appendChild(el("span", { class: "sm-col-label", text: c.name }));
|
|
1479
|
+
col.addEventListener("click", (ev) => {
|
|
1480
|
+
ev.stopPropagation();
|
|
1481
|
+
if (opts && typeof opts.onProcessStepClick === "function") opts.onProcessStepClick(c.id);
|
|
1482
|
+
});
|
|
1483
|
+
dnd.enableDraggable(col, {
|
|
1484
|
+
dragType: DRAG_TYPES.PROCESS_STEP,
|
|
1485
|
+
dragId: c.id,
|
|
1486
|
+
onEnd: () => { if (_dragProjection) setDragProjection(null); }
|
|
1487
|
+
});
|
|
1488
|
+
dnd.enableDropTarget(col, {
|
|
1489
|
+
accepts: [DRAG_TYPES.PROCESS_STEP],
|
|
1490
|
+
onEnter: (el) => el.classList.add("sm-col-drop-target"),
|
|
1491
|
+
onLeave: (el) => el.classList.remove("sm-col-drop-target"),
|
|
1492
|
+
onMove: ({ id, clientX }) => {
|
|
1493
|
+
// Project insertion based on cursor-X vs. column-rect midpoint:
|
|
1494
|
+
// left half → insert before this col, right half → insert before
|
|
1495
|
+
// the NEXT col (or append if this is the last column).
|
|
1496
|
+
const rect = col.getBoundingClientRect();
|
|
1497
|
+
const insertBefore = clientX < rect.left + rect.width / 2;
|
|
1498
|
+
let beforeId;
|
|
1499
|
+
if (insertBefore) {
|
|
1500
|
+
beforeId = c.id;
|
|
1501
|
+
} else {
|
|
1502
|
+
const idx = columns.findIndex(x => x.id === c.id);
|
|
1503
|
+
const next = columns[idx + 1];
|
|
1504
|
+
beforeId = next ? next.id : null;
|
|
1505
|
+
}
|
|
1506
|
+
// Dragging col over its own slot → no-op projection (beforeId same as id).
|
|
1507
|
+
if (beforeId === id) return;
|
|
1508
|
+
setDragProjection({
|
|
1509
|
+
ticketId: id,
|
|
1510
|
+
target: { type: "process-step", beforeId }
|
|
1511
|
+
});
|
|
1512
|
+
},
|
|
1513
|
+
onDrop: ({ id }) => {
|
|
1514
|
+
if (typeof opts.onProcessStepReorder !== "function") { setDragProjection(null); return; }
|
|
1515
|
+
// SM-245: recompute from the live snapshot + projection beforeId, NOT
|
|
1516
|
+
// from the captured `columns` (a morph-reused stale closure may hold
|
|
1517
|
+
// the pre-drag order → no-op → snap-back).
|
|
1518
|
+
const proj = _dragProjection;
|
|
1519
|
+
if (!proj || !proj.target || proj.target.type !== "process-step") { setDragProjection(null); return; }
|
|
1520
|
+
const order = reorderedProcessStepIds(currentSnapshot, id, proj.target.beforeId);
|
|
1521
|
+
// Skip a no-op drop (order unchanged) so it doesn't create a redundant
|
|
1522
|
+
// undo entry / persist (review [minor]).
|
|
1523
|
+
if (order.includes(id) && !sameIdOrder(order, processStepOrder(currentSnapshot))) {
|
|
1524
|
+
opts.onProcessStepReorder(order);
|
|
1525
|
+
}
|
|
1526
|
+
setDragProjection(null);
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
backbone.appendChild(col);
|
|
1530
|
+
}
|
|
1531
|
+
if (columns.length === 0) {
|
|
1532
|
+
backbone.appendChild(el("div", { class: "sm-backbone-col sm-empty", text: "(no process steps)" }));
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Add-Column-Dropzone am rechten Rand: Click → +Process-Step-Dialog,
|
|
1536
|
+
// ODER Drop von Epic → +Process-Step-Dialog + automatischer Move des
|
|
1537
|
+
// Epics in die neue Column.
|
|
1538
|
+
const addCol = el("div", {
|
|
1539
|
+
class: "sm-backbone-col sm-add-col",
|
|
1540
|
+
title: "Click to add a new process step, or drag an epic here"
|
|
1541
|
+
});
|
|
1542
|
+
addCol.appendChild(el("span", { class: "sm-add-icon", text: "+" }));
|
|
1543
|
+
addCol.appendChild(el("span", { class: "sm-add-col-label", text: "Add step" }));
|
|
1544
|
+
addCol.addEventListener("click", (ev) => {
|
|
1545
|
+
ev.stopPropagation();
|
|
1546
|
+
if (opts && typeof opts.onAddProcessStep === "function") opts.onAddProcessStep();
|
|
1547
|
+
});
|
|
1548
|
+
dnd.enableDropTarget(addCol, {
|
|
1549
|
+
accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY, DRAG_TYPES.PROCESS_STEP],
|
|
1550
|
+
onEnter: (el) => el.classList.add("sm-drop-target"),
|
|
1551
|
+
onLeave: (el) => el.classList.remove("sm-drop-target"),
|
|
1552
|
+
onMove: ({ type, id }) => {
|
|
1553
|
+
// PS drop on add-col = "insert at end". Project accordingly so the
|
|
1554
|
+
// user sees the column slide to the end in real time.
|
|
1555
|
+
if (type !== DRAG_TYPES.PROCESS_STEP) return;
|
|
1556
|
+
setDragProjection({
|
|
1557
|
+
ticketId: id,
|
|
1558
|
+
target: { type: "process-step", beforeId: null }
|
|
1559
|
+
});
|
|
1560
|
+
},
|
|
1561
|
+
onDrop: ({ type, id }) => {
|
|
1562
|
+
if (type === DRAG_TYPES.PROCESS_STEP) {
|
|
1563
|
+
if (typeof opts.onProcessStepReorder === "function") {
|
|
1564
|
+
// SM-245: drop on the add-col = append. Recompute from the live
|
|
1565
|
+
// snapshot (robust against a morph-reused stale closure).
|
|
1566
|
+
const order = reorderedProcessStepIds(currentSnapshot, id, null);
|
|
1567
|
+
if (order.includes(id) && !sameIdOrder(order, processStepOrder(currentSnapshot))) {
|
|
1568
|
+
opts.onProcessStepReorder(order);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
setDragProjection(null);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if (typeof opts.onAddProcessStepWithTicket === "function") {
|
|
1575
|
+
opts.onAddProcessStepWithTicket(id, type);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
backbone.appendChild(addCol);
|
|
1580
|
+
|
|
1581
|
+
return backbone;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** Cells-Zeile pro Release — nur die Cells, kein Label mehr. */
|
|
1585
|
+
function renderReleaseCellsRow(row, releaseData, columns, columnMultipliers, opts, isCollapsed) {
|
|
1586
|
+
const collapsedColumns = (opts && opts.collapsedColumns) || null;
|
|
1587
|
+
const releaseStatus = (row.entity && row.entity.status) || null;
|
|
1588
|
+
const r = tagBirth(el("div", {
|
|
1589
|
+
class: "sm-release-row sm-release-cells" + (isCollapsed ? " sm-release-collapsed-cells" : ""),
|
|
1590
|
+
dataset: { releaseId: row.id, status: releaseStatus || "" }
|
|
1591
|
+
}));
|
|
1592
|
+
r.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
|
|
1593
|
+
// SM-83: collapsed releases on initial render get an inline max-height:0
|
|
1594
|
+
// so the row doesn't paint at full height before JS hooks up. Toggling
|
|
1595
|
+
// owns the transition animation; this is just the "already collapsed
|
|
1596
|
+
// at page-load" starting state.
|
|
1597
|
+
if (isCollapsed) r.style.maxHeight = "0px";
|
|
1598
|
+
if (columns.length === 0) {
|
|
1599
|
+
r.appendChild(el("div", { class: "sm-cell sm-empty", text: "Add process steps to map epics into columns." }));
|
|
1600
|
+
} else {
|
|
1601
|
+
for (const c of columns) {
|
|
1602
|
+
const cellData = releaseData.cells[c.id] || { epics: [], loose: [] };
|
|
1603
|
+
// SM-272: a collapsed column shows a compact epic-count per cell.
|
|
1604
|
+
if (collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id)) {
|
|
1605
|
+
r.appendChild(renderCollapsedCell(row.id, c.id, cellData));
|
|
1606
|
+
} else {
|
|
1607
|
+
r.appendChild(renderCell(row.id, c.id, cellData, opts));
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
// Filler on the right to occupy the add-step slot from the backbone.
|
|
1612
|
+
r.appendChild(el("div", { class: "sm-row-filler" }));
|
|
1613
|
+
return r;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Label-Zeile pro Release: linksbündiges Release-Label (klickbar →
|
|
1618
|
+
* Edit-Dialog). Wenn `showAddRelease === true`, kommt zusätzlich ein
|
|
1619
|
+
* `+ Add Release`-Button daneben. SM-252: das ist jetzt die OBERSTE
|
|
1620
|
+
* Release-Zeile (neueste-oben), nicht mehr die unterste.
|
|
1621
|
+
*
|
|
1622
|
+
* SM-83: Chevron-Toggle vor dem Label klappt die Cells-Row der Release
|
|
1623
|
+
* ein/aus. Collapsed-State ist View-only (lebt in opts.collapsedReleases),
|
|
1624
|
+
* Persistenz via main.js (localStorage).
|
|
1625
|
+
*/
|
|
1626
|
+
function renderReleaseLabelRow(row, showAddRelease, opts, isCollapsed) {
|
|
1627
|
+
// SM-80: surface release.status as data-status so CSS can dim completed
|
|
1628
|
+
// releases and a pill can sit next to the label.
|
|
1629
|
+
const releaseStatus = (row.entity && row.entity.status) || null;
|
|
1630
|
+
// SM-169: the header (chevron + name + pill) is built by the SHARED
|
|
1631
|
+
// component so the Kanban swimlanes render releases identically.
|
|
1632
|
+
const trailing = [];
|
|
1633
|
+
if (showAddRelease) {
|
|
1634
|
+
const add = el("button", {
|
|
1635
|
+
class: "sm-add-release-btn",
|
|
1636
|
+
title: "Add another release on top",
|
|
1637
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Release</span>'
|
|
1638
|
+
});
|
|
1639
|
+
add.addEventListener("click", (ev) => {
|
|
1640
|
+
ev.stopPropagation();
|
|
1641
|
+
if (opts && typeof opts.onAddRelease === "function") opts.onAddRelease();
|
|
1642
|
+
});
|
|
1643
|
+
trailing.push(add);
|
|
1644
|
+
}
|
|
1645
|
+
const rowEl = tagBirth(rendererCard.renderReleaseLabelRow({
|
|
1646
|
+
id: row.id,
|
|
1647
|
+
name: row.name,
|
|
1648
|
+
status: releaseStatus,
|
|
1649
|
+
progress: row.progress, // SM-239
|
|
1650
|
+
collapsed: isCollapsed,
|
|
1651
|
+
onToggleCollapsed: (id) => {
|
|
1652
|
+
if (opts && typeof opts.onToggleReleaseCollapsed === "function") opts.onToggleReleaseCollapsed(id);
|
|
1653
|
+
},
|
|
1654
|
+
onLabelClick: (id) => {
|
|
1655
|
+
if (opts && typeof opts.onReleaseClick === "function") opts.onReleaseClick(id);
|
|
1656
|
+
},
|
|
1657
|
+
trailing
|
|
1658
|
+
}));
|
|
1659
|
+
// SM-253: the release header is a drop target — dropping a card here
|
|
1660
|
+
// re-homes it to THIS release as unplaced (release set, no process-step),
|
|
1661
|
+
// so it shows in the release's holding strip. This is the always-present
|
|
1662
|
+
// unplace target (the holding strip only renders when non-empty). Clicks
|
|
1663
|
+
// still open the edit dialog (dnd uses a drag threshold).
|
|
1664
|
+
dnd.enableDropTarget(rowEl, {
|
|
1665
|
+
accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
|
|
1666
|
+
onEnter: (el) => el.classList.add("sm-drop-target"),
|
|
1667
|
+
onLeave: (el) => el.classList.remove("sm-drop-target"),
|
|
1668
|
+
onDrop: ({ id, type }) => {
|
|
1669
|
+
if (opts && typeof opts.onReleaseUnplacedDrop === "function") opts.onReleaseUnplacedDrop(id, type, row.id);
|
|
1670
|
+
setDragProjection(null);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
return rowEl;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// SM-253: per-release holding strip for release-assigned-but-unplaced tickets
|
|
1677
|
+
// (release set, no process-step). Cards are drag SOURCES (drag into a cell to
|
|
1678
|
+
// place them); the strip is also a drop target (re-home here, unplaced).
|
|
1679
|
+
// Rendered only when the release has such tickets — no empty-strip clutter.
|
|
1680
|
+
function renderReleaseUnplacedRow(row, unplaced, opts) {
|
|
1681
|
+
const strip = tagBirth(el("div", { class: "sm-release-unplaced-row", dataset: { releaseId: row.id } }));
|
|
1682
|
+
strip.appendChild(el("div", { class: "sm-release-unplaced-label",
|
|
1683
|
+
text: "Unplaced — drag into a step" }));
|
|
1684
|
+
const list = el("div", { class: "sm-release-unplaced-list" });
|
|
1685
|
+
for (const e of unplaced.epics) {
|
|
1686
|
+
list.appendChild(renderEpicCard({ epic: e.epic, stories: e.stories }, opts));
|
|
1687
|
+
}
|
|
1688
|
+
for (const story of unplaced.stories) {
|
|
1689
|
+
list.appendChild(renderStoryCard(story, opts));
|
|
1690
|
+
}
|
|
1691
|
+
strip.appendChild(list);
|
|
1692
|
+
dnd.enableDropTarget(strip, {
|
|
1693
|
+
accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
|
|
1694
|
+
onEnter: (el) => el.classList.add("sm-drop-target"),
|
|
1695
|
+
onLeave: (el) => el.classList.remove("sm-drop-target"),
|
|
1696
|
+
onDrop: ({ id, type }) => {
|
|
1697
|
+
if (opts && typeof opts.onReleaseUnplacedDrop === "function") opts.onReleaseUnplacedDrop(id, type, row.id);
|
|
1698
|
+
setDragProjection(null);
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
return strip;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// SM-193: insertion index for a drop into the (possibly multi-column) backlog
|
|
1705
|
+
// list. `rects` are the non-shadow card bounding boxes in DOM order — which IS
|
|
1706
|
+
// the flat column-major order (CSS columns fill top→bottom, then the next
|
|
1707
|
+
// column). A single column reduces to the plain cursor-Y midpoint walk. For
|
|
1708
|
+
// multiple columns we FIRST pick the column the cursor is over (by X), then do
|
|
1709
|
+
// the Y-midpoint walk WITHIN that column — without this the pure-Y walk matches
|
|
1710
|
+
// a card at the top of a later column and drops land visually wrong.
|
|
1711
|
+
function columnAwareInsertionIndex(rects, x, y) {
|
|
1712
|
+
if (!rects || rects.length === 0) return 0;
|
|
1713
|
+
const cols = []; // [{ left, right, items: [{idx, top, bottom}] }] grouped by left edge
|
|
1714
|
+
for (let i = 0; i < rects.length; i++) {
|
|
1715
|
+
const r = rects[i];
|
|
1716
|
+
let col = null;
|
|
1717
|
+
for (let k = 0; k < cols.length; k++) { if (Math.abs(cols[k].left - r.left) < 1) { col = cols[k]; break; } }
|
|
1718
|
+
if (!col) { col = { left: r.left, right: r.right, items: [] }; cols.push(col); }
|
|
1719
|
+
col.right = Math.max(col.right, r.right);
|
|
1720
|
+
col.items.push({ idx: i, top: r.top, bottom: r.bottom });
|
|
1721
|
+
}
|
|
1722
|
+
cols.sort((a, b) => a.left - b.left);
|
|
1723
|
+
// Column whose horizontal band is nearest the cursor X (0 distance = inside).
|
|
1724
|
+
let target = cols[0], best = Infinity;
|
|
1725
|
+
for (let k = 0; k < cols.length; k++) {
|
|
1726
|
+
const c = cols[k];
|
|
1727
|
+
const dist = x < c.left ? c.left - x : (x > c.right ? x - c.right : 0);
|
|
1728
|
+
if (dist < best) { best = dist; target = c; }
|
|
1729
|
+
}
|
|
1730
|
+
// First card in that column (DOM order = top→bottom) whose mid-Y is below y.
|
|
1731
|
+
for (let j = 0; j < target.items.length; j++) {
|
|
1732
|
+
const it = target.items[j];
|
|
1733
|
+
if (y < (it.top + it.bottom) / 2) return it.idx;
|
|
1734
|
+
}
|
|
1735
|
+
// Below every card in this column → after its last card, which in flat
|
|
1736
|
+
// column-major order is the first card of the next column.
|
|
1737
|
+
return target.items[target.items.length - 1].idx + 1;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
// currentSnapshot is captured per-mount so card renderers (epic story-lists,
|
|
1742
|
+
// the per-release holding strip) can grab fresh story-lists from the snapshot.
|
|
1743
|
+
let currentSnapshot = null;
|
|
1744
|
+
// SM-49: per-render link-index built once at the top of renderInto and
|
|
1745
|
+
// consumed by every renderStoryCard / renderEpicCard call further down.
|
|
1746
|
+
// O(N+links) up-front beats O(links) per card.
|
|
1747
|
+
let _linkIndex = null;
|
|
1748
|
+
// SM-60: last-execution per test-definition. Same pattern as _linkIndex —
|
|
1749
|
+
// compute once per renderInto, then look up per card.
|
|
1750
|
+
let _lastExecIndex = null;
|
|
1751
|
+
|
|
1752
|
+
// _dragProjection drives live-reorder rendering during an active drag.
|
|
1753
|
+
// Shape: { ticketId, target: { type:"epic", epicId } | { type:"backlog" },
|
|
1754
|
+
// insertionIndex }. `_rerender` is wired by mount() so the
|
|
1755
|
+
// projection setter can trigger a full re-render.
|
|
1756
|
+
let _dragProjection = null;
|
|
1757
|
+
let _rerender = null;
|
|
1758
|
+
|
|
1759
|
+
// SM-274: collapse-state of the Map-Eingang (Bodenstreifen). Module-level so
|
|
1760
|
+
// it survives the tray's rebuild-on-every-render (the render reads it; the
|
|
1761
|
+
// header toggle flips it + triggers _rerender) — no reliance on DOM identity.
|
|
1762
|
+
let _trayCollapsed = false;
|
|
1763
|
+
|
|
1764
|
+
// SM-221: drag-layout cache. The base story-map layout (computeStoryMapLayout,
|
|
1765
|
+
// O(Releases×Steps×Tickets)) is recomputed on EVERY pointermove today because
|
|
1766
|
+
// setDragProjection → _rerender → renderInto recomputes it. During a drag the
|
|
1767
|
+
// snapshot does not change, so we compute it ONCE and reuse it; only the pure
|
|
1768
|
+
// projection helpers (applyEpicCellProjection / applyProcessStepProjection /
|
|
1769
|
+
// effectiveStoriesFor) run per move on top of the cached base. Keyed on the
|
|
1770
|
+
// (snapshot, filter, matchTicket) REFERENCES — any store commit produces a new
|
|
1771
|
+
// snapshot reference, so a commit mid-drag (WS-push from MCP) auto-invalidates.
|
|
1772
|
+
let _baseLayoutCache = null; // { snap, filter, matchTicket, layout } | null
|
|
1773
|
+
|
|
1774
|
+
function computeBaseLayout(snap, filter, matchTicket) {
|
|
1775
|
+
if (_baseLayoutCache
|
|
1776
|
+
&& _baseLayoutCache.snap === snap
|
|
1777
|
+
&& _baseLayoutCache.filter === filter
|
|
1778
|
+
&& _baseLayoutCache.matchTicket === matchTicket) {
|
|
1779
|
+
return _baseLayoutCache.layout;
|
|
1780
|
+
}
|
|
1781
|
+
const layout = computeStoryMapLayout(snap, { filter: filter, matchTicket: matchTicket });
|
|
1782
|
+
_baseLayoutCache = { snap: snap, filter: filter, matchTicket: matchTicket, layout: layout };
|
|
1783
|
+
return layout;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function setDragProjection(p) {
|
|
1787
|
+
// Reference-equality fast path so onMove tick that produces the same
|
|
1788
|
+
// {target, insertionIndex/beforeId} doesn't trigger a re-render.
|
|
1789
|
+
if (p && _dragProjection
|
|
1790
|
+
&& p.ticketId === _dragProjection.ticketId
|
|
1791
|
+
&& p.insertionIndex === _dragProjection.insertionIndex
|
|
1792
|
+
&& p.target && _dragProjection.target
|
|
1793
|
+
&& p.target.type === _dragProjection.target.type
|
|
1794
|
+
&& p.target.epicId === _dragProjection.target.epicId
|
|
1795
|
+
&& p.target.beforeId === _dragProjection.target.beforeId
|
|
1796
|
+
&& p.target.releaseId === _dragProjection.target.releaseId
|
|
1797
|
+
&& p.target.processStepId === _dragProjection.target.processStepId) {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
// SM-221: drag-end (p == null) drops the cache so the drop render recomputes
|
|
1801
|
+
// the base layout once ("Recalc erst beim Drop"). Drop/cancel/end all funnel
|
|
1802
|
+
// here via clearDragProjection / onEnd, so this is the single clear point.
|
|
1803
|
+
if (!p) _baseLayoutCache = null;
|
|
1804
|
+
const prev = _dragProjection;
|
|
1805
|
+
_dragProjection = p;
|
|
1806
|
+
// SM-223 (d): mid-drag (same dragged ticket, projection → projection) the
|
|
1807
|
+
// affected containers are rebuilt + morphed in place — the full grid
|
|
1808
|
+
// build+morph (the actual per-move cost) is skipped. Drag start (prev
|
|
1809
|
+
// null), drag end (p null) and unsupported shapes take the full path.
|
|
1810
|
+
if (p && prev && p.ticketId === prev.ticketId
|
|
1811
|
+
&& tryIncrementalDragUpdate(prev, p)) {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
if (typeof _rerender === "function") _rerender();
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Apply an active epic-on-cell drag projection: remove the dragged epic
|
|
1819
|
+
* from every cell it currently occupies, then insert it as a shadow
|
|
1820
|
+
* entry into the target cell at insertionIndex. Pure-ish — the layout's
|
|
1821
|
+
* `releases.{rid}.cells.{psid}.epics` arrays are cloned and replaced.
|
|
1822
|
+
*/
|
|
1823
|
+
function applyEpicCellProjection(layout, snapshot) {
|
|
1824
|
+
const proj = _dragProjection;
|
|
1825
|
+
if (!proj || !proj.target || proj.target.type !== "cell-epics") return layout;
|
|
1826
|
+
const draggedId = proj.ticketId;
|
|
1827
|
+
const tgtRel = proj.target.releaseId;
|
|
1828
|
+
const tgtPs = proj.target.processStepId;
|
|
1829
|
+
const insertionIndex = proj.insertionIndex || 0;
|
|
1830
|
+
const draggedTicket = (snapshot.tickets || []).find(t => t.id === draggedId);
|
|
1831
|
+
if (!draggedTicket || draggedTicket.type !== "epic") return layout;
|
|
1832
|
+
// SM-97: keep the dragged epic in its HOME cell during cross-cell projections.
|
|
1833
|
+
// The dnd layer styles the origin DOM with .sm-dragging for a ghost effect;
|
|
1834
|
+
// crucially the source cell does NOT shrink. Removing the epic would
|
|
1835
|
+
// shrink the source by one card's height + (when the epic has stories)
|
|
1836
|
+
// its sub-grid, shifting the whole layout. The cursor stays at a fixed
|
|
1837
|
+
// window-Y while the layout moves under it, so the next pointermove
|
|
1838
|
+
// re-projects to a cell the user is no longer aiming at — most visibly
|
|
1839
|
+
// for cross-release moves. Intra-cell reorder (source == target) still
|
|
1840
|
+
// filters so the shadow can be re-inserted at the projected index.
|
|
1841
|
+
const homeRel = draggedTicket.position && draggedTicket.position.releaseId;
|
|
1842
|
+
const homePs = draggedTicket.position && draggedTicket.position.processStepId;
|
|
1843
|
+
|
|
1844
|
+
const newReleases = {};
|
|
1845
|
+
for (const rid of Object.keys(layout.releases)) {
|
|
1846
|
+
const src = layout.releases[rid];
|
|
1847
|
+
const newCells = {};
|
|
1848
|
+
for (const psid of Object.keys(src.cells)) {
|
|
1849
|
+
const srcCell = src.cells[psid];
|
|
1850
|
+
const isHomeCell = (rid === homeRel && psid === homePs);
|
|
1851
|
+
const isTargetCell = (rid === tgtRel && psid === tgtPs);
|
|
1852
|
+
const isCrossCell = !(homeRel === tgtRel && homePs === tgtPs);
|
|
1853
|
+
let epics;
|
|
1854
|
+
if (isHomeCell && isCrossCell && !isTargetCell) {
|
|
1855
|
+
// Cross-cell, this is the home cell, not the target → keep epic.
|
|
1856
|
+
epics = srcCell.epics;
|
|
1857
|
+
} else {
|
|
1858
|
+
// Either intra-cell reorder, or non-home cell, or home cell that
|
|
1859
|
+
// also happens to be the target — filter out so we can re-insert
|
|
1860
|
+
// the shadow at the projected index without duplicating.
|
|
1861
|
+
epics = srcCell.epics.filter(e => e.epic.id !== draggedId);
|
|
1862
|
+
}
|
|
1863
|
+
if (isTargetCell) {
|
|
1864
|
+
const idx = Math.max(0, Math.min(epics.length, insertionIndex));
|
|
1865
|
+
epics = epics.slice();
|
|
1866
|
+
epics.splice(idx, 0, {
|
|
1867
|
+
epic: draggedTicket,
|
|
1868
|
+
stories: core.tickets.storiesInEpic(snapshot, draggedId),
|
|
1869
|
+
shadow: true
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
newCells[psid] = Object.assign({}, srcCell, { epics });
|
|
1873
|
+
}
|
|
1874
|
+
newReleases[rid] = Object.assign({}, src, { cells: newCells });
|
|
1875
|
+
}
|
|
1876
|
+
return Object.assign({}, layout, { releases: newReleases });
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Apply an active process-step drag projection to the layout's columns
|
|
1881
|
+
* array — origin column is removed and re-inserted at the projected
|
|
1882
|
+
* position (before `beforeId`, or appended if beforeId == null). Cells
|
|
1883
|
+
* follow automatically because renderReleaseRow iterates `columns`.
|
|
1884
|
+
*/
|
|
1885
|
+
function applyProcessStepProjection(layout) {
|
|
1886
|
+
const proj = _dragProjection;
|
|
1887
|
+
if (!proj || !proj.target || proj.target.type !== "process-step") return layout;
|
|
1888
|
+
const draggedId = proj.ticketId;
|
|
1889
|
+
const beforeId = proj.target.beforeId;
|
|
1890
|
+
const cols = layout.columns.slice();
|
|
1891
|
+
const origIdx = cols.findIndex(c => c.id === draggedId);
|
|
1892
|
+
if (origIdx < 0) return layout;
|
|
1893
|
+
const [dragged] = cols.splice(origIdx, 1);
|
|
1894
|
+
if (beforeId == null) {
|
|
1895
|
+
cols.push(dragged);
|
|
1896
|
+
} else {
|
|
1897
|
+
const insertIdx = cols.findIndex(c => c.id === beforeId);
|
|
1898
|
+
if (insertIdx < 0) cols.push(dragged);
|
|
1899
|
+
else cols.splice(insertIdx, 0, dragged);
|
|
1900
|
+
}
|
|
1901
|
+
return Object.assign({}, layout, { columns: cols });
|
|
1902
|
+
}
|
|
1903
|
+
function clearDragProjection() { setDragProjection(null); }
|
|
1904
|
+
|
|
1905
|
+
// ---- SM-223 (d): drag-incremental DOM update --------------------------
|
|
1906
|
+
//
|
|
1907
|
+
// SM-221 removed the per-move layout recompute, but the dragMove benchmark
|
|
1908
|
+
// showed the REAL per-move cost is renderInto building a fresh full grid +
|
|
1909
|
+
// Phase-C morphing every card (~200ms at N=500). Mid-drag, a projection
|
|
1910
|
+
// change only affects a handful of containers: the previous target, the new
|
|
1911
|
+
// target, and (for epic-cell drags) the dragged epic's home cell. We rebuild
|
|
1912
|
+
// ONLY those subtrees with the SAME builder the full path uses (renderCell
|
|
1913
|
+
// reads the live _dragProjection) and morph them in place — byte-identical
|
|
1914
|
+
// DOM by construction, at a fraction of the work.
|
|
1915
|
+
// Unsupported shapes (process-step column reorder, lookup misses, a commit
|
|
1916
|
+
// mid-drag) return false and fall back to the full render path.
|
|
1917
|
+
|
|
1918
|
+
// Wired by mount(): { host, opts } — the same wrappedOpts renderInto uses.
|
|
1919
|
+
let _dragRenderCtx = null;
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* The containers a projection touches: a list of cell keys and/or the
|
|
1923
|
+
* backlog section. Returns null when the projection type can't be handled
|
|
1924
|
+
* incrementally (e.g. process-step column reorder).
|
|
1925
|
+
*/
|
|
1926
|
+
function projectionContainers(proj, snap) {
|
|
1927
|
+
const t = (proj && proj.target) || {};
|
|
1928
|
+
if (t.type === "epic") {
|
|
1929
|
+
const epic = (snap.tickets || []).find(x => x.id === t.epicId);
|
|
1930
|
+
if (!epic) return null;
|
|
1931
|
+
const rid = epic.position && epic.position.releaseId;
|
|
1932
|
+
const pid = epic.position && epic.position.processStepId;
|
|
1933
|
+
// SM-253: a fully-placed epic → its cell. A partially-assigned epic (in a
|
|
1934
|
+
// holding strip, no process-step) has no cell to morph incrementally —
|
|
1935
|
+
// bail to the full render path so its drag preview still updates.
|
|
1936
|
+
if (!(rid && pid)) return null;
|
|
1937
|
+
return { cells: [{ releaseId: rid, processStepId: pid }] };
|
|
1938
|
+
}
|
|
1939
|
+
if (t.type === "cell") {
|
|
1940
|
+
return { cells: [{ releaseId: t.releaseId, processStepId: t.processStepId }] };
|
|
1941
|
+
}
|
|
1942
|
+
if (t.type === "cell-epics") {
|
|
1943
|
+
// applyEpicCellProjection touches the target cell AND the dragged
|
|
1944
|
+
// epic's home cell (SM-97 keep-in-home semantics) — rebuild both.
|
|
1945
|
+
const dragged = (snap.tickets || []).find(x => x.id === proj.ticketId);
|
|
1946
|
+
const hrid = dragged && dragged.position && dragged.position.releaseId;
|
|
1947
|
+
const hpid = dragged && dragged.position && dragged.position.processStepId;
|
|
1948
|
+
if (!hrid || !hpid) return null; // partially-assigned epic dragged over cells — full path
|
|
1949
|
+
return {
|
|
1950
|
+
cells: [
|
|
1951
|
+
{ releaseId: t.releaseId, processStepId: t.processStepId },
|
|
1952
|
+
{ releaseId: hrid, processStepId: hpid }
|
|
1953
|
+
]
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
// SM-253: no more "backlog" projection target (the global backlog is gone).
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Incrementally apply a projection change prev → next on the live DOM.
|
|
1962
|
+
* Returns true when handled (full render skipped). Both projections'
|
|
1963
|
+
* containers are rebuilt so the shadow disappears from the old target and
|
|
1964
|
+
* appears at the new one.
|
|
1965
|
+
*/
|
|
1966
|
+
function tryIncrementalDragUpdate(prev, next) {
|
|
1967
|
+
const ctx = _dragRenderCtx;
|
|
1968
|
+
if (!ctx || !ctx.host || !ctx.store || !_baseLayoutCache) return false;
|
|
1969
|
+
// A commit mid-drag produces a new snapshot reference → the cached base
|
|
1970
|
+
// no longer matches → full render (which rebuilds the cache). Guard
|
|
1971
|
+
// against the STORE's snapshot, not currentSnapshot: the Phase-A/B
|
|
1972
|
+
// fast-paths handle applyRemote commits WITHOUT renderInto, so
|
|
1973
|
+
// currentSnapshot and the cache would both be stale together and an
|
|
1974
|
+
// incremental morph would visually revert the remote change (review
|
|
1975
|
+
// finding on SM-223).
|
|
1976
|
+
const snap = ctx.store.get();
|
|
1977
|
+
if (!snap || _baseLayoutCache.snap !== snap) return false;
|
|
1978
|
+
const a = projectionContainers(prev, snap);
|
|
1979
|
+
const b = projectionContainers(next, snap);
|
|
1980
|
+
if (!a || !b) return false;
|
|
1981
|
+
const cellKeys = new Map();
|
|
1982
|
+
for (const c of a.cells.concat(b.cells)) {
|
|
1983
|
+
cellKeys.set(c.releaseId + "" + c.processStepId, c);
|
|
1984
|
+
}
|
|
1985
|
+
// The projected layout for cell-epics targets; a no-op for the others.
|
|
1986
|
+
const layout = applyEpicCellProjection(_baseLayoutCache.layout, snap);
|
|
1987
|
+
for (const c of cellKeys.values()) {
|
|
1988
|
+
const sel = '.sm-cell[data-release-id="' + cssEscape(c.releaseId) +
|
|
1989
|
+
'"][data-process-step-id="' + cssEscape(c.processStepId) + '"]';
|
|
1990
|
+
const oldCell = ctx.host.querySelector(sel);
|
|
1991
|
+
const rel = layout.releases[c.releaseId];
|
|
1992
|
+
const cellData = rel && rel.cells && rel.cells[c.processStepId];
|
|
1993
|
+
if (!oldCell || !cellData) return false;
|
|
1994
|
+
morphTree(oldCell, renderCell(c.releaseId, c.processStepId, cellData, ctx.opts));
|
|
1995
|
+
}
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* Wenn ProcessSteps existieren aber keine Release, zeigen wir leere
|
|
2001
|
+
* Phantom-Cells unter dem Backbone + eine Label-Zeile mit reinem
|
|
2002
|
+
* "+ Add Release"-Button (kein vorhandenes Label, nur die CTA).
|
|
2003
|
+
*/
|
|
2004
|
+
function renderPhantomReleaseRows(columns, columnMultipliers, opts) {
|
|
2005
|
+
const collapsedColumns = (opts && opts.collapsedColumns) || null;
|
|
2006
|
+
const cells = el("div", { class: "sm-release-row sm-release-cells sm-phantom-row" });
|
|
2007
|
+
cells.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
|
|
2008
|
+
for (const c of columns) {
|
|
2009
|
+
cells.appendChild(el("div", {
|
|
2010
|
+
class: "sm-cell sm-phantom-cell",
|
|
2011
|
+
dataset: { processStepId: c.id }
|
|
2012
|
+
}));
|
|
2013
|
+
}
|
|
2014
|
+
cells.appendChild(el("div", { class: "sm-row-filler" }));
|
|
2015
|
+
|
|
2016
|
+
const labelRow = el("div", { class: "sm-release-label-row sm-phantom-label-row" });
|
|
2017
|
+
const btn = el("button", {
|
|
2018
|
+
class: "sm-add-release-btn sm-phantom-cta",
|
|
2019
|
+
title: "Add release",
|
|
2020
|
+
html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Release</span>'
|
|
2021
|
+
});
|
|
2022
|
+
btn.addEventListener("click", (ev) => {
|
|
2023
|
+
ev.stopPropagation();
|
|
2024
|
+
if (opts && typeof opts.onAddRelease === "function") opts.onAddRelease();
|
|
2025
|
+
});
|
|
2026
|
+
labelRow.appendChild(btn);
|
|
2027
|
+
// SM-192: label (heading) above the cells, consistent with real releases.
|
|
2028
|
+
return [labelRow, cells];
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// SM-274/SM-276: render the Map-Eingang (Bodenstreifen). Returns the `.sm-tray`
|
|
2032
|
+
// node or null when there is nothing unassigned (no empty strip clutters the
|
|
2033
|
+
// map). SM-276: cards are drag sources (drop on a cell/epic = schedule via the
|
|
2034
|
+
// existing cell handlers) and the strip is itself a drop target (drop a
|
|
2035
|
+
// scheduled card here = unassign it back to the inbox).
|
|
2036
|
+
function renderUnassignedTray(snapshot, opts) {
|
|
2037
|
+
// Filter parity with the cells: SM-82 chip filter AND-combined with the
|
|
2038
|
+
// SM-191 query predicate, mirroring computeStoryMapLayout.
|
|
2039
|
+
const filter = (opts && opts.filter) || null;
|
|
2040
|
+
const matchTicket = (opts && typeof opts.matchTicket === "function") ? opts.matchTicket : null;
|
|
2041
|
+
const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
|
|
2042
|
+
const filterMatch = (filterMod && filter)
|
|
2043
|
+
? (t) => filterMod.matchesFilter(t, filter, filterCtx)
|
|
2044
|
+
: (_t) => true;
|
|
2045
|
+
const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
|
|
2046
|
+
|
|
2047
|
+
const tray = computeUnassignedTrayLayout(snapshot, {
|
|
2048
|
+
rows: STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT,
|
|
2049
|
+
matches: matches
|
|
2050
|
+
});
|
|
2051
|
+
if (tray.count === 0) return null;
|
|
2052
|
+
|
|
2053
|
+
const onToggle = () => {
|
|
2054
|
+
_trayCollapsed = !_trayCollapsed;
|
|
2055
|
+
if (typeof _rerender === "function") _rerender();
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
const header = el("div", { class: "sm-tray-header" }, [
|
|
2059
|
+
el("button", {
|
|
2060
|
+
class: "sm-tray-toggle", type: "button",
|
|
2061
|
+
"aria-expanded": _trayCollapsed ? "false" : "true",
|
|
2062
|
+
title: _trayCollapsed ? "Eingang ausklappen" : "Eingang einklappen",
|
|
2063
|
+
onclick: onToggle
|
|
2064
|
+
}, [ _trayCollapsed ? "▸" : "▾" ]),
|
|
2065
|
+
el("span", { class: "sm-tray-title", text: "Unassigned" }),
|
|
2066
|
+
el("span", { class: "sm-tray-count", text: String(tray.count) })
|
|
2067
|
+
]);
|
|
2068
|
+
|
|
2069
|
+
const root = el("div", {
|
|
2070
|
+
class: "sm-tray" + (_trayCollapsed ? " sm-tray-collapsed" : ""),
|
|
2071
|
+
dataset: { section: "unassigned-tray" }
|
|
2072
|
+
}, [ header ]);
|
|
2073
|
+
|
|
2074
|
+
// SM-276: the strip is a drop target — dropping a scheduled card here clears
|
|
2075
|
+
// its release (unassign back to the inbox). Accepts both stories and epics.
|
|
2076
|
+
dnd.enableDropTarget(root, {
|
|
2077
|
+
accepts: [DRAG_TYPES.STORY, DRAG_TYPES.EPIC],
|
|
2078
|
+
onDrop: ({ id }) => {
|
|
2079
|
+
if (typeof opts.onTrayDrop === "function") opts.onTrayDrop(id);
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
if (!_trayCollapsed) {
|
|
2084
|
+
const cardGrid = el("div", {
|
|
2085
|
+
class: "sm-tray-grid",
|
|
2086
|
+
style: {
|
|
2087
|
+
gridTemplateRows: "repeat(" + tray.rows + ", " + STORY_MAP_LAYOUT.CARD_HEIGHT_PX + "px)",
|
|
2088
|
+
gridAutoColumns: STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX + "px"
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
for (const t of tray.items) {
|
|
2092
|
+
// SM-276: epics drag as EPIC (drop on a cell → schedule via onEpicDrop),
|
|
2093
|
+
// everything else as STORY (drop on a cell/epic → onCellStoryDrop /
|
|
2094
|
+
// onStoryDrop). Clearing any leftover drag projection on end.
|
|
2095
|
+
cardGrid.appendChild(rendererCard.renderTicketCard(t, {
|
|
2096
|
+
onTicketClick: opts && opts.onTicketClick,
|
|
2097
|
+
projectId: snapshot && snapshot.project && snapshot.project.id,
|
|
2098
|
+
width: STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX,
|
|
2099
|
+
dragType: t.type === "epic" ? DRAG_TYPES.EPIC : DRAG_TYPES.STORY,
|
|
2100
|
+
dragId: t.id,
|
|
2101
|
+
dragOnEnd: () => { if (_dragProjection) setDragProjection(null); },
|
|
2102
|
+
linkInfo: _linkIndex && _linkIndex.get(t.id) || null,
|
|
2103
|
+
lastExecution: _lastExecIndex && _lastExecIndex.get(t.id) || null
|
|
2104
|
+
}));
|
|
2105
|
+
}
|
|
2106
|
+
// No inline max-height: the row count (set by sizeTrayBody after mount)
|
|
2107
|
+
// drives the body height, so the strip fills the available vertical space
|
|
2108
|
+
// without overflowing the viewport. grid-template-rows starts at the
|
|
2109
|
+
// fallback (tray.rows) and is updated to the viewport-reactive count.
|
|
2110
|
+
const body = el("div", { class: "sm-tray-body" }, [ cardGrid ]);
|
|
2111
|
+
root.appendChild(body);
|
|
2112
|
+
}
|
|
2113
|
+
return root;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function renderInto(host, store, opts) {
|
|
2117
|
+
const snap = store.get();
|
|
2118
|
+
currentSnapshot = snap;
|
|
2119
|
+
_linkIndex = rendererCard.computeLinkIndex(snap); // SM-49
|
|
2120
|
+
_lastExecIndex = rendererCard.computeLastExecutionIndex(snap); // SM-60
|
|
2121
|
+
_renderCounter += 1; // SM-20 instrumentation: unique render id
|
|
2122
|
+
_gridBuildCount += 1; // SM-223 instrumentation: full grid builds
|
|
2123
|
+
// SM-221: base layout from the drag-cache (computed once per snapshot);
|
|
2124
|
+
// the projection helpers run on top per pointermove without recomputing it.
|
|
2125
|
+
// We canonicalise the key here (falsy filter → null; non-function matchTicket
|
|
2126
|
+
// → null, mirroring computeStoryMapLayout's own coercion) so a drag's
|
|
2127
|
+
// rerenders always hit the same cache entry.
|
|
2128
|
+
const baseLayout = computeBaseLayout(snap, opts.filter || null,
|
|
2129
|
+
(typeof opts.matchTicket === "function") ? opts.matchTicket : null);
|
|
2130
|
+
const layout = applyProcessStepProjection(applyEpicCellProjection(baseLayout, snap));
|
|
2131
|
+
|
|
2132
|
+
// Belt + Suspenders: scrub stale drag-artifacts from an incomplete
|
|
2133
|
+
// previous drag (orphan ghost, stuck sm-dragging class).
|
|
2134
|
+
dnd.scrubArtifacts();
|
|
2135
|
+
// Wipe drop-target registry ONLY on the first render (no existing grid
|
|
2136
|
+
// in the host yet). Subsequent renders go through the Phase-C morph
|
|
2137
|
+
// path, which reuses existing DOM nodes — and with them their original
|
|
2138
|
+
// dnd registrations. Wiping the registry mid-flight would orphan the
|
|
2139
|
+
// surviving cells/cards and break drag-and-drop until the next remount.
|
|
2140
|
+
// Trade-off: the freshly-built (and then discarded by morph) NEW nodes
|
|
2141
|
+
// still call enableDropTarget during the build, leaving a small number
|
|
2142
|
+
// of dangling registry entries per render. Acceptable for now; address
|
|
2143
|
+
// by switching dnd._targets to a WeakMap if it grows into a real issue.
|
|
2144
|
+
const hasExistingGrid = !!host.querySelector(":scope > .sm-grid");
|
|
2145
|
+
if (!hasExistingGrid) dnd.clearAll();
|
|
2146
|
+
|
|
2147
|
+
const grid = tagBirth(el("div", { class: "sm-grid" }));
|
|
2148
|
+
grid.style.setProperty("--sm-release-label-w", STORY_MAP_LAYOUT.RELEASE_LABEL_WIDTH_PX + "px");
|
|
2149
|
+
grid.style.setProperty("--sm-backbone-col-w", STORY_MAP_LAYOUT.BACKBONE_COL_WIDTH_PX + "px");
|
|
2150
|
+
// SM-223 (a) gate: only virtualize cells (content-visibility) on very large
|
|
2151
|
+
// boards, where the layout/paint win outweighs the WebKit/Gecko row-height
|
|
2152
|
+
// jump. Normal boards render every cell eagerly → pixel-correct heights.
|
|
2153
|
+
if ((snap.tickets || []).length > STORY_MAP_LAYOUT.VIRTUALIZE_TICKET_THRESHOLD) {
|
|
2154
|
+
grid.classList.add("sm-grid-virtualized");
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
grid.appendChild(renderBackbone(layout.columns, layout.columnMultipliers, opts));
|
|
2158
|
+
// SM-83: opts.collapsedReleases is a Set<releaseId> of releases whose
|
|
2159
|
+
// cells-row should be collapsed. The cells-row stays in the DOM (only
|
|
2160
|
+
// visually hidden via .sm-release-collapsed-cells) so the toggle can
|
|
2161
|
+
// animate max-height. Label row is always rendered.
|
|
2162
|
+
const collapsed = (opts && opts.collapsedReleases) || null;
|
|
2163
|
+
layout.rows.forEach((row, idx) => {
|
|
2164
|
+
const isCollapsed = !!(collapsed && collapsed.has && collapsed.has(row.id));
|
|
2165
|
+
// SM-192: the label is a HEADING — render it ABOVE its cells so all the
|
|
2166
|
+
// release's tickets appear below the release bar.
|
|
2167
|
+
// SM-252: newest-on-top → the '+ Add Release' button lives on the TOP row
|
|
2168
|
+
// (a new release appends with the highest sortOrder and lands on top).
|
|
2169
|
+
const showAddRelease = idx === 0;
|
|
2170
|
+
const releaseData = layout.releases[row.id];
|
|
2171
|
+
grid.appendChild(renderReleaseLabelRow(row, showAddRelease, opts, isCollapsed));
|
|
2172
|
+
grid.appendChild(renderReleaseCellsRow(row, releaseData, layout.columns, layout.columnMultipliers, opts, isCollapsed));
|
|
2173
|
+
// SM-253: per-release holding zone for release-assigned-but-unplaced
|
|
2174
|
+
// tickets — only when it has any (no clutter), and not while collapsed.
|
|
2175
|
+
const unplaced = releaseData && releaseData.unplaced;
|
|
2176
|
+
if (!isCollapsed && unplaced && (unplaced.epics.length > 0 || unplaced.stories.length > 0)) {
|
|
2177
|
+
grid.appendChild(renderReleaseUnplacedRow(row, unplaced, opts));
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
// Phantom-rows when at least one ProcessStep exists but no Release yet —
|
|
2181
|
+
// without them the user sees only the backbone header floating with
|
|
2182
|
+
// nothing under it.
|
|
2183
|
+
if (layout.rows.length === 0 && layout.columns.length > 0) {
|
|
2184
|
+
for (const node of renderPhantomReleaseRows(layout.columns, layout.columnMultipliers, opts)) {
|
|
2185
|
+
grid.appendChild(node);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
// SM-253: the global Backlog section is gone — release-assigned-unplaced
|
|
2189
|
+
// tickets live in per-release holding strips (rendered in the rows loop);
|
|
2190
|
+
// release-less tickets are visible only in the Kanban.
|
|
2191
|
+
|
|
2192
|
+
// SM-274: Map-Eingang (Bodenstreifen) — sticky bottom strip for release-less
|
|
2193
|
+
// tickets. It is the LAST child of .sm-grid: the grid is width:max-content
|
|
2194
|
+
// (the full map scroll width), so a plain block child gets width:auto = that
|
|
2195
|
+
// width for free — the strip's background spans the whole map with pure CSS,
|
|
2196
|
+
// no measured width. Its own body is overflow-x:auto (a scroll container),
|
|
2197
|
+
// so the cards do NOT feed back into the grid's max-content width. Morph
|
|
2198
|
+
// reconciles it by its data-section key like any other section.
|
|
2199
|
+
const trayNode = renderUnassignedTray(snap, opts);
|
|
2200
|
+
if (trayNode) grid.appendChild(trayNode);
|
|
2201
|
+
|
|
2202
|
+
// SM-20 Phase C — morph the existing .sm-grid in place instead of wiping
|
|
2203
|
+
// the host. Preserves DOM identity for every keyed node that survived
|
|
2204
|
+
// the diff (cells, ps-cols, release-rows, cards, …) so the user no
|
|
2205
|
+
// longer sees a structural refresh-flash for ticket_create / release_
|
|
2206
|
+
// create / label-rename etc. The first render (no existing grid yet)
|
|
2207
|
+
// takes the appendChild path so the initial mount is unchanged.
|
|
2208
|
+
const oldGrid = host.querySelector(":scope > .sm-grid");
|
|
2209
|
+
if (oldGrid) {
|
|
2210
|
+
morphTree(oldGrid, grid);
|
|
2211
|
+
} else {
|
|
2212
|
+
host.innerHTML = "";
|
|
2213
|
+
host.appendChild(grid);
|
|
2214
|
+
}
|
|
2215
|
+
// SM-275: viewport-reactive row count — measure the LIVE tray (after morph,
|
|
2216
|
+
// the surviving node is the one in the DOM, not `trayNode`).
|
|
2217
|
+
const liveTray = host.querySelector(".sm-tray");
|
|
2218
|
+
if (liveTray) sizeTrayBody(host, liveTray);
|
|
2219
|
+
instrumentationReport(host, _renderCounter);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// SM-239: in-place refresh of the per-release X/Y badges after the Phase-A/B
|
|
2223
|
+
// fast-paths, which keep the release rows' DOM identity. A child status change
|
|
2224
|
+
// shifts the count without moving a card, so the badge must be repainted
|
|
2225
|
+
// explicitly. The full-rerender path builds them fresh and doesn't need this.
|
|
2226
|
+
function refreshReleaseProgressBadges(host, snap) {
|
|
2227
|
+
host.querySelectorAll(".sm-release-label-row[data-release-id]").forEach((row) => {
|
|
2228
|
+
const relId = row.dataset.releaseId;
|
|
2229
|
+
if (relId) rendererCard.updateReleaseProgressBadge(row, core.releaseProgress(snap, relId));
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// ---- Mount API -------------------------------------------------------
|
|
2234
|
+
|
|
2235
|
+
function mount(host, store, opts) {
|
|
2236
|
+
opts = opts || {};
|
|
2237
|
+
const actor = opts.actor || ACTOR_LOCAL;
|
|
2238
|
+
// Persistenz-Strategie: wenn opts.persistMove gegeben ist (main.js
|
|
2239
|
+
// setzt das im HTTP-Modus), nutzen wir das für ALLE Drop-Operationen.
|
|
2240
|
+
// Sonst gehen wir den lokalen Store-Pfad (für Tests + lokale Adapter).
|
|
2241
|
+
function moveVia(ticketId, position) {
|
|
2242
|
+
if (typeof opts.persistMove === "function") {
|
|
2243
|
+
return opts.persistMove(ticketId, position);
|
|
2244
|
+
}
|
|
2245
|
+
store.moveTicket(ticketId, position, actor);
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Ticket-Reorder — mirrors movesVia for processSteps. Takes orderedIds
|
|
2249
|
+
* + scope (the target container's releaseId/processStepId/epicId) and
|
|
2250
|
+
* dispatches to opts.persistReorderTickets (HTTP) or the local store
|
|
2251
|
+
* fallback. Mirrors the exact pattern of onProcessStepReorder.
|
|
2252
|
+
*/
|
|
2253
|
+
function reorderVia(orderedIds, scope) {
|
|
2254
|
+
if (typeof opts.persistReorderTickets === "function") {
|
|
2255
|
+
return opts.persistReorderTickets(orderedIds, scope);
|
|
2256
|
+
}
|
|
2257
|
+
store.reorderTickets(orderedIds, scope || null, actor);
|
|
2258
|
+
}
|
|
2259
|
+
function getPos(id) {
|
|
2260
|
+
const t = store.get().tickets.find(t => t.id === id);
|
|
2261
|
+
return (t && t.position) || {};
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* Build the new orderedIds list for a reorder operation: peers (sorted
|
|
2266
|
+
* ascending by sortOrder, dragged ticket already removed) with the
|
|
2267
|
+
* dragged ticket inserted at insertionIndex.
|
|
2268
|
+
*/
|
|
2269
|
+
function buildOrderedIds(peers, draggedId, insertionIndex) {
|
|
2270
|
+
const idx = Math.max(0, Math.min(peers.length, insertionIndex != null ? insertionIndex : peers.length));
|
|
2271
|
+
const ids = peers.map(t => t.id);
|
|
2272
|
+
ids.splice(idx, 0, draggedId);
|
|
2273
|
+
return ids;
|
|
2274
|
+
}
|
|
2275
|
+
const wrappedOpts = {
|
|
2276
|
+
// SM-82: forward filter spec to renderInto → computeStoryMapLayout.
|
|
2277
|
+
filter: opts.filter || null,
|
|
2278
|
+
// SM-191: forward the smart-bar query predicate too.
|
|
2279
|
+
matchTicket: opts.matchTicket || null,
|
|
2280
|
+
// SM-83: forward collapsed-releases set + toggle callback.
|
|
2281
|
+
collapsedReleases: opts.collapsedReleases || null,
|
|
2282
|
+
onToggleReleaseCollapsed: opts.onToggleReleaseCollapsed,
|
|
2283
|
+
// SM-84: same pattern for per-epic collapse.
|
|
2284
|
+
collapsedEpics: opts.collapsedEpics || null,
|
|
2285
|
+
onToggleEpicCollapsed: opts.onToggleEpicCollapsed,
|
|
2286
|
+
// SM-272: per-project process-step COLUMN collapse.
|
|
2287
|
+
collapsedColumns: opts.collapsedColumns || null,
|
|
2288
|
+
onToggleColumnCollapsed: opts.onToggleColumnCollapsed,
|
|
2289
|
+
onTicketClick: opts.onTicketClick,
|
|
2290
|
+
onAddTicket: opts.onAddTicket,
|
|
2291
|
+
onAddRelease: opts.onAddRelease,
|
|
2292
|
+
onAddProcessStep: opts.onAddProcessStep,
|
|
2293
|
+
onAddProcessStepWithTicket: opts.onAddProcessStepWithTicket,
|
|
2294
|
+
onReleaseClick: opts.onReleaseClick,
|
|
2295
|
+
onProcessStepClick: opts.onProcessStepClick,
|
|
2296
|
+
onProcessStepReorder: opts.onProcessStepReorder,
|
|
2297
|
+
onEpicDrop: (epicId, psId, releaseId, insertionIndex) => {
|
|
2298
|
+
const snap = store.get();
|
|
2299
|
+
const dragged = snap.tickets.find(t => t.id === epicId);
|
|
2300
|
+
if (!dragged) return;
|
|
2301
|
+
const cur = dragged.position || {};
|
|
2302
|
+
const tgtRel = releaseId !== undefined ? (releaseId || null) : (cur.releaseId || null);
|
|
2303
|
+
const tgtPs = psId || null;
|
|
2304
|
+
const peers = (tgtRel && tgtPs)
|
|
2305
|
+
? core.tickets.epicsInCell(snap, tgtRel, tgtPs).filter(t => t.id !== epicId)
|
|
2306
|
+
: [];
|
|
2307
|
+
const orderedIds = buildOrderedIds(peers, epicId, insertionIndex);
|
|
2308
|
+
reorderVia(orderedIds, { releaseId: tgtRel, processStepId: tgtPs, epicId: null });
|
|
2309
|
+
},
|
|
2310
|
+
onStoryDrop: (storyId, targetEpicId, insertionIndex) => {
|
|
2311
|
+
const snap = store.get();
|
|
2312
|
+
const dragged = snap.tickets.find(t => t.id === storyId);
|
|
2313
|
+
if (!dragged) return;
|
|
2314
|
+
const cur = dragged.position || {};
|
|
2315
|
+
let releaseId = cur.releaseId || null;
|
|
2316
|
+
let processStepId = cur.processStepId || null;
|
|
2317
|
+
if (targetEpicId) {
|
|
2318
|
+
const ep = snap.tickets.find(t => t.id === targetEpicId);
|
|
2319
|
+
if (ep && ep.position) {
|
|
2320
|
+
releaseId = ep.position.releaseId || null;
|
|
2321
|
+
processStepId = ep.position.processStepId || null;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
const peers = targetEpicId
|
|
2325
|
+
? core.tickets.storiesInEpic(snap, targetEpicId).filter(t => t.id !== storyId)
|
|
2326
|
+
: [];
|
|
2327
|
+
const orderedIds = buildOrderedIds(peers, storyId, insertionIndex);
|
|
2328
|
+
reorderVia(orderedIds, { releaseId, processStepId, epicId: targetEpicId || null });
|
|
2329
|
+
},
|
|
2330
|
+
// SM-253: re-home a dragged card to a release as UNPLACED (release set,
|
|
2331
|
+
// no process-step, no epic) — fired by the release header + holding strip.
|
|
2332
|
+
onReleaseUnplacedDrop: (ticketId, _type, releaseId) => {
|
|
2333
|
+
moveVia(ticketId, { releaseId: releaseId || null, processStepId: null, epicId: null });
|
|
2334
|
+
},
|
|
2335
|
+
onCellStoryDrop: (storyId, releaseId, processStepId, insertionIndex) => {
|
|
2336
|
+
const snap = store.get();
|
|
2337
|
+
const dragged = snap.tickets.find(t => t.id === storyId);
|
|
2338
|
+
if (!dragged) return;
|
|
2339
|
+
const peers = (releaseId && processStepId)
|
|
2340
|
+
? core.tickets.looseTicketsInCell(snap, releaseId, processStepId).filter(t => t.id !== storyId)
|
|
2341
|
+
: [];
|
|
2342
|
+
const orderedIds = buildOrderedIds(peers, storyId, insertionIndex);
|
|
2343
|
+
reorderVia(orderedIds, { releaseId: releaseId || null, processStepId: processStepId || null, epicId: null });
|
|
2344
|
+
},
|
|
2345
|
+
// SM-276: drop a scheduled card onto the Map-Eingang → clear its release
|
|
2346
|
+
// (unassign back to the inbox). Mirrors applyBacklogDrop; sortOrder is
|
|
2347
|
+
// preserved so the card keeps its slot among the release-less tickets.
|
|
2348
|
+
onTrayDrop: (ticketId) => {
|
|
2349
|
+
const cur = store.get().tickets.find(t => t.id === ticketId);
|
|
2350
|
+
if (!cur) return;
|
|
2351
|
+
moveVia(ticketId, {
|
|
2352
|
+
releaseId: null,
|
|
2353
|
+
processStepId: null,
|
|
2354
|
+
epicId: null,
|
|
2355
|
+
sortOrder: (cur.position && cur.position.sortOrder) || 0
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
function rerender() { renderInto(host, store, wrappedOpts); }
|
|
2360
|
+
_rerender = rerender;
|
|
2361
|
+
// SM-223 (d): context for the drag-incremental path — the SAME host +
|
|
2362
|
+
// wrappedOpts the full render uses, so subtree rebuilds are identical.
|
|
2363
|
+
// store is the staleness authority (see tryIncrementalDragUpdate).
|
|
2364
|
+
_dragRenderCtx = { host: host, opts: wrappedOpts, store: store };
|
|
2365
|
+
rerender();
|
|
2366
|
+
// SM-275: keep the Map-Eingang's row count in sync with the viewport. A
|
|
2367
|
+
// debounced window-resize re-measures and re-applies the row template to
|
|
2368
|
+
// the mounted tray (cheap — no full re-render, no DOM re-chunk). Guarded
|
|
2369
|
+
// for non-browser environments (JSDOM tests have no global `window`).
|
|
2370
|
+
let _resizeTimer = null;
|
|
2371
|
+
function onResize() {
|
|
2372
|
+
if (_resizeTimer) clearTimeout(_resizeTimer);
|
|
2373
|
+
_resizeTimer = setTimeout(function () {
|
|
2374
|
+
const tray = host.querySelector(":scope > .sm-tray");
|
|
2375
|
+
if (tray) sizeTrayBody(host, tray);
|
|
2376
|
+
}, STORY_MAP_LAYOUT.TRAY_RESIZE_DEBOUNCE_MS);
|
|
2377
|
+
}
|
|
2378
|
+
const _win = (typeof window !== "undefined") ? window : null;
|
|
2379
|
+
if (_win) _win.addEventListener("resize", onResize);
|
|
2380
|
+
// E24 — animate cards on external commits (MCP / WS pushes). Local
|
|
2381
|
+
// commits keep the instant rerender, since they already get direct
|
|
2382
|
+
// user feedback (drag-ghost, modal save, ...).
|
|
2383
|
+
let prevSnap = store.get();
|
|
2384
|
+
// E24.E — Story-Map animates THREE entity types: tickets (both story
|
|
2385
|
+
// and epic cards share `[data-ticket-id]`), process-step columns,
|
|
2386
|
+
// and release rows. The renderer registers the data-* attrs on the
|
|
2387
|
+
// respective DOM nodes; the helper does the FLIP + pulse per type.
|
|
2388
|
+
// SM-20-Followup: each entity-type's selector must match EXACTLY ONE node
|
|
2389
|
+
// per id, otherwise the FLIP helper's Map.set-by-id overwrites and every
|
|
2390
|
+
// matched node gets compared to a wrong prev-rect. data-process-step-id
|
|
2391
|
+
// sits on both .sm-backbone-col AND every .sm-cell — scope to the col.
|
|
2392
|
+
// data-release-id sits on .sm-release-row.sm-release-cells AND on
|
|
2393
|
+
// .sm-release-label-row AND on .sm-release-label — scope to the cells-row.
|
|
2394
|
+
const STORYMAP_ENTITY_TYPES = [
|
|
2395
|
+
{ listKey: "tickets", selector: "[data-ticket-id]", idAttr: "ticketId" },
|
|
2396
|
+
{ listKey: "processSteps", selector: ".sm-backbone-col[data-process-step-id]", idAttr: "processStepId" },
|
|
2397
|
+
{ listKey: "releases", selector: ".sm-release-row[data-release-id]", idAttr: "releaseId" }
|
|
2398
|
+
];
|
|
2399
|
+
const unsub = store.subscribe(function (snap, reason) {
|
|
2400
|
+
if (reason === "applyRemote" && cardAnimate) {
|
|
2401
|
+
// SM-20 Phase A — try the position-only fast path first. When an
|
|
2402
|
+
// applyRemote is a pure sort-order reshuffle inside one or more
|
|
2403
|
+
// existing containers, we move cards directly in the DOM (no
|
|
2404
|
+
// host.innerHTML wipe, no structural rebuild) and let FLIP animate
|
|
2405
|
+
// their travel. The shells (cells, backbone, release rows) keep
|
|
2406
|
+
// their DOM identity, so the screen no longer flashes underneath
|
|
2407
|
+
// the FLIP. Any non-trivial structural change → null → fallback.
|
|
2408
|
+
const groups = diffSortOnlyByContainer(prevSnap, snap);
|
|
2409
|
+
let handled = false;
|
|
2410
|
+
if (groups) {
|
|
2411
|
+
if (groups.size === 0) {
|
|
2412
|
+
// No effective change (a remote echo with identical positions).
|
|
2413
|
+
handled = true;
|
|
2414
|
+
} else {
|
|
2415
|
+
const ticketSelector = "[data-ticket-id]";
|
|
2416
|
+
const prevRects = cardAnimate.captureRectsBySelector(host, ticketSelector, "ticketId");
|
|
2417
|
+
if (applySortOnlyReshuffle(host, groups)) {
|
|
2418
|
+
cardAnimate.flipFromCapturedSelector(host, prevRects, ticketSelector, "ticketId");
|
|
2419
|
+
const touchedIds = new Set();
|
|
2420
|
+
for (const [, ids] of groups) for (const id of ids) touchedIds.add(id);
|
|
2421
|
+
cardAnimate.pulseEntities(host, touchedIds, '[data-ticket-id="', '"]');
|
|
2422
|
+
handled = true;
|
|
2423
|
+
}
|
|
2424
|
+
// applySortOnlyReshuffle returned false → a card or container
|
|
2425
|
+
// wasn't in the DOM where we expected it; fall through to the
|
|
2426
|
+
// full-rerender path so we never silently lose a change.
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
// SM-20 Phase B — content-only patch fast-path. Hit by `ticket_update`,
|
|
2430
|
+
// `check_dod_item`, `change_ticket_status`, etc.: a ticket's content
|
|
2431
|
+
// changed but its container + position are unchanged. We patch the
|
|
2432
|
+
// existing card DOM in place — no host.innerHTML wipe.
|
|
2433
|
+
if (!handled) {
|
|
2434
|
+
const changed = diffCardContentOnly(prevSnap, snap);
|
|
2435
|
+
if (changed) {
|
|
2436
|
+
if (changed.size === 0) {
|
|
2437
|
+
handled = true;
|
|
2438
|
+
} else if (applyCardContentPatch(host, changed, snap, wrappedOpts)) {
|
|
2439
|
+
cardAnimate.pulseEntities(host, changed, '[data-ticket-id="', '"]');
|
|
2440
|
+
handled = true;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
if (!handled) {
|
|
2445
|
+
cardAnimate.animateExternalCommitMulti({
|
|
2446
|
+
host: host,
|
|
2447
|
+
prevSnap: prevSnap,
|
|
2448
|
+
nextSnap: snap,
|
|
2449
|
+
rerender: rerender,
|
|
2450
|
+
entityTypes: STORYMAP_ENTITY_TYPES
|
|
2451
|
+
});
|
|
2452
|
+
} else {
|
|
2453
|
+
// SM-239: the Phase-A/B fast-paths don't rebuild the release rows, so
|
|
2454
|
+
// refresh the X/Y progress badges in place (a child status change can
|
|
2455
|
+
// shift a release's done-count without moving any card).
|
|
2456
|
+
refreshReleaseProgressBadges(host, snap);
|
|
2457
|
+
}
|
|
2458
|
+
} else {
|
|
2459
|
+
rerender();
|
|
2460
|
+
}
|
|
2461
|
+
prevSnap = snap;
|
|
2462
|
+
});
|
|
2463
|
+
// Controller is a callable function (unsubscribes when invoked) with
|
|
2464
|
+
// attached methods so existing callers like `app.unmountStoryMap()`
|
|
2465
|
+
// keep working while new code can do `ctrl.setDragProjection(...)`.
|
|
2466
|
+
const ctrl = function () {
|
|
2467
|
+
unsub();
|
|
2468
|
+
// SM-275: drop the resize listener so a torn-down host isn't measured.
|
|
2469
|
+
if (_win) _win.removeEventListener("resize", onResize);
|
|
2470
|
+
if (_resizeTimer) clearTimeout(_resizeTimer);
|
|
2471
|
+
// SM-153: reset drag state on unmount so the next mount can't render a
|
|
2472
|
+
// phantom shadow-card left over from an aborted drag (Kanban does the
|
|
2473
|
+
// same). Set the vars directly — clearDragProjection() would trigger a
|
|
2474
|
+
// rerender against a host we're tearing down.
|
|
2475
|
+
_dragProjection = null;
|
|
2476
|
+
_rerender = null;
|
|
2477
|
+
_baseLayoutCache = null; // SM-221: don't leak a stale layout into the next mount
|
|
2478
|
+
_dragRenderCtx = null; // SM-223: don't morph into a torn-down host
|
|
2479
|
+
};
|
|
2480
|
+
ctrl.unsubscribe = unsub;
|
|
2481
|
+
ctrl.setDragProjection = setDragProjection;
|
|
2482
|
+
ctrl.clearDragProjection = clearDragProjection;
|
|
2483
|
+
ctrl._wrappedOpts = wrappedOpts; // test-only handle
|
|
2484
|
+
return ctrl;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
return {
|
|
2488
|
+
STORY_MAP_LAYOUT,
|
|
2489
|
+
DRAG_TYPES,
|
|
2490
|
+
DRAG_FORMATS,
|
|
2491
|
+
computeStoryMapLayout,
|
|
2492
|
+
computeUnassignedTrayLayout, // SM-274 — exposed for tests
|
|
2493
|
+
computeTrayRows, // SM-275 — exposed for tests
|
|
2494
|
+
applyEpicDrop,
|
|
2495
|
+
applyStoryDrop,
|
|
2496
|
+
applyBacklogDrop,
|
|
2497
|
+
applyReleaseReorder,
|
|
2498
|
+
applyProcessStepReorder,
|
|
2499
|
+
// SM-20 Phase A — exposed for tests
|
|
2500
|
+
containerKeyOf,
|
|
2501
|
+
diffSortOnlyByContainer,
|
|
2502
|
+
findContainerForKey,
|
|
2503
|
+
applySortOnlyReshuffle,
|
|
2504
|
+
// SM-20 Phase B — exposed for tests
|
|
2505
|
+
diffCardContentOnly,
|
|
2506
|
+
applyCardContentPatch,
|
|
2507
|
+
// SM-20 Phase C — exposed for tests
|
|
2508
|
+
morphTree,
|
|
2509
|
+
keyOf,
|
|
2510
|
+
// SM-97 — exposed for tests: drive applyEpicCellProjection in isolation.
|
|
2511
|
+
applyEpicCellProjection,
|
|
2512
|
+
// SM-193 — exposed for tests: column-aware backlog insertion index.
|
|
2513
|
+
columnAwareInsertionIndex,
|
|
2514
|
+
// SM-245 — exposed for tests: pure process-step reorder from snapshot + beforeId.
|
|
2515
|
+
reorderedProcessStepIds,
|
|
2516
|
+
_setDragProjectionForTests: function (p) { _dragProjection = p; },
|
|
2517
|
+
// SM-221 — exposed for tests: count + reset base-layout computations, and
|
|
2518
|
+
// inspect/clear the drag-layout cache.
|
|
2519
|
+
_getLayoutComputeCount: function () { return _layoutComputeCount; },
|
|
2520
|
+
_resetLayoutComputeCount: function () { _layoutComputeCount = 0; },
|
|
2521
|
+
_peekBaseLayoutCache: function () { return _baseLayoutCache; },
|
|
2522
|
+
// SM-223 — exposed for tests: count full grid builds (the drag-incremental
|
|
2523
|
+
// path keeps this at 0 per mid-drag move) and force a full render to prove
|
|
2524
|
+
// DOM identity between the incremental and the full path.
|
|
2525
|
+
_getGridBuildCount: function () { return _gridBuildCount; },
|
|
2526
|
+
_resetGridBuildCount: function () { _gridBuildCount = 0; },
|
|
2527
|
+
_rerenderForTests: function () { if (typeof _rerender === "function") _rerender(); },
|
|
2528
|
+
mount
|
|
2529
|
+
};
|
|
2530
|
+
}));
|