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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer-Events-basiertes Drag-and-Drop.
|
|
3
|
+
*
|
|
4
|
+
* Ersetzt die HTML5-`draggable`-API, die in Safari und einigen anderen
|
|
5
|
+
* Browsern unzuverlässig ist. Pointer-Events funktionieren konsistent in
|
|
6
|
+
* Maus + Touch, lassen sich präzise hooken und erlauben uns ein eigenes
|
|
7
|
+
* Ghost-Element zu rendern.
|
|
8
|
+
*
|
|
9
|
+
* API:
|
|
10
|
+
* enableDraggable(element, { dragType, dragId, onStart?, onEnd? }) → cleanup
|
|
11
|
+
* enableDropTarget(element, { accepts, onEnter?, onLeave?, onDrop }) → cleanup
|
|
12
|
+
* clearAll() — drop all registered targets (used by renderer rerender)
|
|
13
|
+
*
|
|
14
|
+
* Der Drop-Callback bekommt `{ type, id, target, event }`.
|
|
15
|
+
*
|
|
16
|
+
* Ghost-Preview folgt der Maus mit ~0.7 Opacity. Drop-Target-Discovery
|
|
17
|
+
* via `elementFromPoint`, Walk-Up zum nächsten registrierten Target.
|
|
18
|
+
*/
|
|
19
|
+
(function (root, factory) {
|
|
20
|
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
|
21
|
+
else (root.STORYMAP = root.STORYMAP || {}).dnd = factory();
|
|
22
|
+
}(typeof self !== "undefined" ? self : this, function () {
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const CONSTANTS = {
|
|
26
|
+
DRAG_THRESHOLD_PX: 4,
|
|
27
|
+
GHOST_OPACITY: 0.78,
|
|
28
|
+
GHOST_Z_INDEX: 1000,
|
|
29
|
+
GHOST_TILT_DEG: 2,
|
|
30
|
+
GHOST_MAX_WIDTH_PX: 280
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Global registry — keyed by element via a WeakMap (SM-153). The story-map
|
|
34
|
+
// builds-then-discards DOM nodes on every morph render; with a Set those
|
|
35
|
+
// discarded nodes' target entries leaked (unbounded growth + linear
|
|
36
|
+
// findTarget). A WeakMap lets the GC reclaim entries for detached nodes and
|
|
37
|
+
// makes findTarget an O(depth) per-element lookup. `clearAll()` swaps in a
|
|
38
|
+
// fresh map.
|
|
39
|
+
let _targetsByEl = new WeakMap();
|
|
40
|
+
let _active = null; // { type, id, ghost, originEl, offsetX, offsetY, currentTarget, onEnd }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walk up from `node` and return the FIRST registered drop target that
|
|
44
|
+
* also accepts `dragType`. Without the type-filter, nested targets that
|
|
45
|
+
* don't accept the active drag (e.g. an Epic-Card inside a Cell — the
|
|
46
|
+
* Epic-Card accepts STORY but not EPIC) would swallow the search and
|
|
47
|
+
* leave the user without a valid drop target — causing same-cell
|
|
48
|
+
* Epic-reorders to silently no-op (SM-61).
|
|
49
|
+
*
|
|
50
|
+
* When `dragType` is omitted (or for backwards compatibility), falls
|
|
51
|
+
* back to the original "first registered target wins" semantics.
|
|
52
|
+
*/
|
|
53
|
+
function findTarget(node, dragType) {
|
|
54
|
+
let cur = node;
|
|
55
|
+
while (cur && cur !== document.body && cur !== document) {
|
|
56
|
+
const t = _targetsByEl.get(cur);
|
|
57
|
+
if (t) {
|
|
58
|
+
if (!dragType || (t.accepts && t.accepts.indexOf(dragType) >= 0)) return t;
|
|
59
|
+
// Registered but doesn't accept this type — keep walking up.
|
|
60
|
+
}
|
|
61
|
+
cur = cur.parentNode;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function startDrag(originEl, opts, pointerEvent) {
|
|
67
|
+
const rect = originEl.getBoundingClientRect();
|
|
68
|
+
const targetW = Math.min(rect.width, CONSTANTS.GHOST_MAX_WIDTH_PX);
|
|
69
|
+
// Proportional scale: if the ghost's width was clamped, the grab-offset
|
|
70
|
+
// must shrink in the same ratio so the cursor stays at the same relative
|
|
71
|
+
// x within the (smaller) ghost. Without this, stretched origin cards
|
|
72
|
+
// produce a ghost that jumps sideways from the cursor.
|
|
73
|
+
const scale = rect.width > 0 ? (targetW / rect.width) : 1;
|
|
74
|
+
const ghost = originEl.cloneNode(true);
|
|
75
|
+
ghost.style.position = "fixed";
|
|
76
|
+
ghost.style.left = rect.left + "px";
|
|
77
|
+
ghost.style.top = rect.top + "px";
|
|
78
|
+
ghost.style.width = targetW + "px";
|
|
79
|
+
ghost.style.zIndex = String(CONSTANTS.GHOST_Z_INDEX);
|
|
80
|
+
ghost.style.pointerEvents = "none";
|
|
81
|
+
ghost.style.opacity = String(CONSTANTS.GHOST_OPACITY);
|
|
82
|
+
ghost.style.transform = "rotate(" + CONSTANTS.GHOST_TILT_DEG + "deg)";
|
|
83
|
+
ghost.style.boxShadow = "0 10px 28px rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.10)";
|
|
84
|
+
ghost.classList.add("sm-drag-ghost");
|
|
85
|
+
document.body.appendChild(ghost);
|
|
86
|
+
|
|
87
|
+
originEl.classList.add("sm-dragging");
|
|
88
|
+
|
|
89
|
+
_active = {
|
|
90
|
+
type: opts.dragType,
|
|
91
|
+
id: opts.dragId,
|
|
92
|
+
originEl,
|
|
93
|
+
ghost,
|
|
94
|
+
offsetX: (pointerEvent.clientX - rect.left) * scale,
|
|
95
|
+
offsetY: (pointerEvent.clientY - rect.top),
|
|
96
|
+
currentTarget: null,
|
|
97
|
+
onEnd: opts.onEnd
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (typeof opts.onStart === "function") opts.onStart();
|
|
101
|
+
|
|
102
|
+
document.addEventListener("pointermove", onDragMove, true);
|
|
103
|
+
document.addEventListener("pointerup", onDragEnd, true);
|
|
104
|
+
document.addEventListener("pointercancel", onDragCancel, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onDragMove(ev) {
|
|
108
|
+
if (!_active) return;
|
|
109
|
+
ev.preventDefault();
|
|
110
|
+
_active.ghost.style.left = (ev.clientX - _active.offsetX) + "px";
|
|
111
|
+
_active.ghost.style.top = (ev.clientY - _active.offsetY) + "px";
|
|
112
|
+
|
|
113
|
+
// Hide ghost temporarily so elementFromPoint sees what's under it.
|
|
114
|
+
_active.ghost.style.display = "none";
|
|
115
|
+
const under = document.elementFromPoint(ev.clientX, ev.clientY);
|
|
116
|
+
_active.ghost.style.display = "";
|
|
117
|
+
|
|
118
|
+
// Pass the active drag-type so findTarget skips registered targets that
|
|
119
|
+
// don't accept it and walks up to the nearest one that DOES — required
|
|
120
|
+
// for same-cell Epic-reorders where the Epic-Card (accepts STORY only)
|
|
121
|
+
// would otherwise swallow the EPIC-drop search (SM-61).
|
|
122
|
+
const target = under ? findTarget(under, _active.type) : null;
|
|
123
|
+
const acceptedTarget = target; // findTarget already type-filtered.
|
|
124
|
+
|
|
125
|
+
if (acceptedTarget !== _active.currentTarget) {
|
|
126
|
+
if (_active.currentTarget && typeof _active.currentTarget.onLeave === "function") {
|
|
127
|
+
try { _active.currentTarget.onLeave(_active.currentTarget.element); } catch (_) {}
|
|
128
|
+
}
|
|
129
|
+
_active.currentTarget = acceptedTarget;
|
|
130
|
+
if (_active.currentTarget && typeof _active.currentTarget.onEnter === "function") {
|
|
131
|
+
try { _active.currentTarget.onEnter(_active.currentTarget.element); } catch (_) {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Per-pointermove notification to the active target, used by the
|
|
136
|
+
// live-reorder mechanism to compute the insertion index from clientY.
|
|
137
|
+
if (_active.currentTarget && typeof _active.currentTarget.onMove === "function") {
|
|
138
|
+
try {
|
|
139
|
+
_active.currentTarget.onMove({
|
|
140
|
+
type: _active.type,
|
|
141
|
+
id: _active.id,
|
|
142
|
+
target: _active.currentTarget.element,
|
|
143
|
+
clientX: ev.clientX,
|
|
144
|
+
clientY: ev.clientY
|
|
145
|
+
});
|
|
146
|
+
} catch (_) {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Visuelle Aufräumarbeiten (Ghost entfernen, sm-dragging entfernen,
|
|
152
|
+
* Drop-Target-Highlight clearen). Wird VOR dem onDrop-Callback aufgerufen,
|
|
153
|
+
* damit der re-render danach saubere DOM-State sieht — selbst wenn das
|
|
154
|
+
* Drop-Callback asynchron ist (HTTP-PUT + reloadStore).
|
|
155
|
+
*/
|
|
156
|
+
function cleanupVisualOnly() {
|
|
157
|
+
if (!_active) return;
|
|
158
|
+
if (_active.ghost && _active.ghost.parentNode) _active.ghost.parentNode.removeChild(_active.ghost);
|
|
159
|
+
if (_active.originEl) _active.originEl.classList.remove("sm-dragging");
|
|
160
|
+
if (_active.currentTarget && typeof _active.currentTarget.onLeave === "function") {
|
|
161
|
+
try { _active.currentTarget.onLeave(_active.currentTarget.element); } catch (_) {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resetActiveState() {
|
|
166
|
+
if (!_active) return;
|
|
167
|
+
if (typeof _active.onEnd === "function") {
|
|
168
|
+
try { _active.onEnd(); } catch (_) {}
|
|
169
|
+
}
|
|
170
|
+
document.removeEventListener("pointermove", onDragMove, true);
|
|
171
|
+
document.removeEventListener("pointerup", onDragEnd, true);
|
|
172
|
+
document.removeEventListener("pointercancel", onDragCancel, true);
|
|
173
|
+
_active = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onDragEnd(ev) {
|
|
177
|
+
if (!_active) return;
|
|
178
|
+
// Order is load-bearing:
|
|
179
|
+
// 1. cleanupVisualOnly — ghost off, dragging-class off (visual reset).
|
|
180
|
+
// 2. target.onDrop — fires the drop callback. MUST happen BEFORE
|
|
181
|
+
// resetActiveState, because resetActiveState invokes the draggable's
|
|
182
|
+
// onEnd, which (in the storymap renderer) calls
|
|
183
|
+
// setDragProjection(null). If we cleared the projection first, the
|
|
184
|
+
// drop callback would read a null projection and silently fall back
|
|
185
|
+
// to "no insertion index" (= splice at end), losing the user's
|
|
186
|
+
// intended drop position.
|
|
187
|
+
// 3. resetActiveState — fires onEnd, removes listeners, _active=null.
|
|
188
|
+
const target = _active.currentTarget;
|
|
189
|
+
const type = _active.type;
|
|
190
|
+
const id = _active.id;
|
|
191
|
+
cleanupVisualOnly();
|
|
192
|
+
if (target && typeof target.onDrop === "function") {
|
|
193
|
+
try { target.onDrop({ type, id, target: target.element, event: ev }); } catch (e) { console.error(e); }
|
|
194
|
+
}
|
|
195
|
+
resetActiveState();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function onDragCancel() {
|
|
199
|
+
cleanupVisualOnly();
|
|
200
|
+
resetActiveState();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Brute-force cleanup of orphan drag artifacts. Called by the renderer
|
|
205
|
+
* before each mount so a previous incomplete drag can't leave stale
|
|
206
|
+
* ghosts / dragging-classes in the document.
|
|
207
|
+
*/
|
|
208
|
+
function scrubArtifacts() {
|
|
209
|
+
if (typeof document === "undefined") return;
|
|
210
|
+
// Never remove the CURRENTLY ACTIVE drag's ghost — only orphans from
|
|
211
|
+
// a previous incomplete drag. Live-reorder triggers a renderer
|
|
212
|
+
// re-render mid-drag; without this guard the active ghost would be
|
|
213
|
+
// deleted on every projection change and the user loses cursor-follow.
|
|
214
|
+
const activeGhost = _active && _active.ghost;
|
|
215
|
+
const activeOrigin = _active && _active.originEl;
|
|
216
|
+
// SM-79: same exclusion-pattern as ghost + dragging — DON'T strip the
|
|
217
|
+
// currently-hovered drop-target's highlight class mid-drag. Without this
|
|
218
|
+
// guard, every `setDragProjection` (fires per pointermove inside any
|
|
219
|
+
// drop target) triggers a renderer rerender → scrubArtifacts → wipes
|
|
220
|
+
// .sm-drop-target → user never sees a drop-zone highlight at all.
|
|
221
|
+
const activeDrop = _active && _active.currentTarget && _active.currentTarget.element;
|
|
222
|
+
const ghosts = document.querySelectorAll(".sm-drag-ghost");
|
|
223
|
+
for (const g of ghosts) if (g !== activeGhost && g.parentNode) g.parentNode.removeChild(g);
|
|
224
|
+
const stuck = document.querySelectorAll(".sm-dragging");
|
|
225
|
+
for (const d of stuck) if (d !== activeOrigin) d.classList.remove("sm-dragging");
|
|
226
|
+
const targets = document.querySelectorAll(".sm-drop-target");
|
|
227
|
+
for (const t of targets) if (t !== activeDrop) t.classList.remove("sm-drop-target");
|
|
228
|
+
// Orphan live-reorder shadow cards from an aborted drag — but ONLY
|
|
229
|
+
// when no drag is active. During an active drag the shadow card is
|
|
230
|
+
// re-emitted by the renderer at the new insertion position.
|
|
231
|
+
if (!_active) {
|
|
232
|
+
const shadows = document.querySelectorAll(".sm-card-shadow");
|
|
233
|
+
for (const s of shadows) if (s.parentNode) s.parentNode.removeChild(s);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- Public API ----------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Mark an element draggable. Returns a cleanup function that
|
|
241
|
+
* removes the pointerdown listener.
|
|
242
|
+
*
|
|
243
|
+
* The threshold-based gesture avoids stealing clicks: short taps
|
|
244
|
+
* still propagate to click-handlers, only movement > threshold
|
|
245
|
+
* starts a drag.
|
|
246
|
+
*/
|
|
247
|
+
function enableDraggable(element, opts) {
|
|
248
|
+
function onDown(ev) {
|
|
249
|
+
if (ev.button !== 0 || _active) return;
|
|
250
|
+
// SM-158: `a` added — clicking a ticket-key link inside a draggable card
|
|
251
|
+
// must navigate, not start a drag.
|
|
252
|
+
if (ev.target && ev.target.closest && ev.target.closest("button,input,select,textarea,a")) return;
|
|
253
|
+
// Stop the pointerdown from bubbling to ancestor draggables. A Story-
|
|
254
|
+
// Card inside an Epic-Card needs its own drag to win — without this
|
|
255
|
+
// the Epic's onDown also fires and the two drags race for _active.
|
|
256
|
+
ev.stopPropagation();
|
|
257
|
+
const startX = ev.clientX, startY = ev.clientY;
|
|
258
|
+
function maybeStart(mEv) {
|
|
259
|
+
if (Math.hypot(mEv.clientX - startX, mEv.clientY - startY) < CONSTANTS.DRAG_THRESHOLD_PX) return;
|
|
260
|
+
document.removeEventListener("pointermove", maybeStart, true);
|
|
261
|
+
document.removeEventListener("pointerup", cancel, true);
|
|
262
|
+
if (_active) return; // belt+suspenders: another draggable beat us to it
|
|
263
|
+
startDrag(element, opts, mEv);
|
|
264
|
+
}
|
|
265
|
+
function cancel() {
|
|
266
|
+
document.removeEventListener("pointermove", maybeStart, true);
|
|
267
|
+
document.removeEventListener("pointerup", cancel, true);
|
|
268
|
+
}
|
|
269
|
+
document.addEventListener("pointermove", maybeStart, true);
|
|
270
|
+
document.addEventListener("pointerup", cancel, true);
|
|
271
|
+
}
|
|
272
|
+
element.addEventListener("pointerdown", onDown);
|
|
273
|
+
return function detach() {
|
|
274
|
+
element.removeEventListener("pointerdown", onDown);
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Register an element as a drop target.
|
|
280
|
+
* accepts: Array<string> — only drag-types in this list will activate enter/drop.
|
|
281
|
+
* onEnter(el), onLeave(el), onDrop({type, id, target, event})
|
|
282
|
+
* Returns a cleanup function that removes it from the registry.
|
|
283
|
+
*/
|
|
284
|
+
function enableDropTarget(element, opts) {
|
|
285
|
+
const target = {
|
|
286
|
+
element,
|
|
287
|
+
accepts: opts.accepts || [],
|
|
288
|
+
onEnter: opts.onEnter,
|
|
289
|
+
onLeave: opts.onLeave,
|
|
290
|
+
onMove: opts.onMove,
|
|
291
|
+
onDrop: opts.onDrop
|
|
292
|
+
};
|
|
293
|
+
_targetsByEl.set(element, target);
|
|
294
|
+
return function detach() {
|
|
295
|
+
if (_targetsByEl.get(element) === target) _targetsByEl.delete(element);
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Wipe all registered drop targets. The renderer calls this at the
|
|
301
|
+
* start of each remount so stale targets from the previous DOM tree
|
|
302
|
+
* are cleared before new ones are registered.
|
|
303
|
+
*/
|
|
304
|
+
function clearAll() { _targetsByEl = new WeakMap(); }
|
|
305
|
+
|
|
306
|
+
function isDragging() { return _active != null; }
|
|
307
|
+
function currentDrag() { return _active ? { type: _active.type, id: _active.id } : null; }
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
CONSTANTS,
|
|
311
|
+
enableDraggable,
|
|
312
|
+
enableDropTarget,
|
|
313
|
+
clearAll,
|
|
314
|
+
scrubArtifacts,
|
|
315
|
+
isDragging,
|
|
316
|
+
currentDrag,
|
|
317
|
+
// Test seam: the registered drop-target entry for an element (handlers +
|
|
318
|
+
// accepts). Lets tests invoke the EXACT onDrop closure that a real pointer
|
|
319
|
+
// drop would fire — including a morph-reused node's (possibly stale) one.
|
|
320
|
+
_getDropTarget: function (el) { return _targetsByEl.get(el); }
|
|
321
|
+
};
|
|
322
|
+
}));
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SM-82 — Ticket-Filter (View-Layer)
|
|
3
|
+
*
|
|
4
|
+
* Pure module. No DOM access, no storage, no store. Consumers (renderers,
|
|
5
|
+
* main.js) wire it into their pipelines.
|
|
6
|
+
*
|
|
7
|
+
* Two surface shapes:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Simple filter spec** — what the toolbar UI produces:
|
|
10
|
+
*
|
|
11
|
+
* { statuses: string[]|null, // null = no constraint
|
|
12
|
+
* types: string[]|null,
|
|
13
|
+
* hideCompletedReleases: bool }
|
|
14
|
+
*
|
|
15
|
+
* 2. **Internal Boolean-Tree AST** — what matchesFilter actually evaluates:
|
|
16
|
+
*
|
|
17
|
+
* Node = { op: "AND", children: Node[] }
|
|
18
|
+
* | { op: "OR", children: Node[] }
|
|
19
|
+
* | { op: "NOT", child: Node }
|
|
20
|
+
* | { op: "CLAUSE", field, clauseOp, value?, values? };
|
|
21
|
+
*
|
|
22
|
+
* `toAST` converts the simple spec into a flat AND-tree of clauses. A future
|
|
23
|
+
* JQL parser (v3+) would produce arbitrary AND/OR/NOT trees consumed by the
|
|
24
|
+
* same `matchesFilter` evaluator — the evaluator is already recursive.
|
|
25
|
+
*
|
|
26
|
+
* Supported clauseOps today: eq, neq, in, not-in.
|
|
27
|
+
*
|
|
28
|
+
* Supported fields:
|
|
29
|
+
* - "status" — ticket.status
|
|
30
|
+
* - "type" — ticket.type
|
|
31
|
+
* - "release.status" — virtual; resolves via ctx.releaseById (built by
|
|
32
|
+
* `buildReleaseCtx(snapshot)` once per render).
|
|
33
|
+
*
|
|
34
|
+
* `releaseVisible(release, filter)` is a separate helper because the renderer
|
|
35
|
+
* uses it to skip release-row iteration entirely when `hideCompletedReleases`
|
|
36
|
+
* is on — different shape than per-ticket filtering.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
(function (global, factory) {
|
|
40
|
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
|
41
|
+
else {
|
|
42
|
+
const ns = (global.STORYMAP = global.STORYMAP || {});
|
|
43
|
+
ns.filter = factory();
|
|
44
|
+
}
|
|
45
|
+
}(typeof window !== "undefined" ? window : globalThis, function () {
|
|
46
|
+
"use strict";
|
|
47
|
+
|
|
48
|
+
/** Normalise the simple filter spec — empty arrays → null. */
|
|
49
|
+
function normalizeFilter(raw) {
|
|
50
|
+
raw = raw || {};
|
|
51
|
+
const statuses = Array.isArray(raw.statuses) && raw.statuses.length > 0
|
|
52
|
+
? raw.statuses.slice() : null;
|
|
53
|
+
const types = Array.isArray(raw.types) && raw.types.length > 0
|
|
54
|
+
? raw.types.slice() : null;
|
|
55
|
+
return {
|
|
56
|
+
statuses: statuses,
|
|
57
|
+
types: types,
|
|
58
|
+
hideCompletedReleases: !!raw.hideCompletedReleases
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convert the simple spec into a Boolean-Tree AST. Today's UI generates a
|
|
64
|
+
* flat AND-tree; a future JQL parser produces arbitrary trees with the
|
|
65
|
+
* same node shapes and the same evaluator works unchanged.
|
|
66
|
+
*/
|
|
67
|
+
function toAST(filter) {
|
|
68
|
+
const f = normalizeFilter(filter);
|
|
69
|
+
const clauses = [];
|
|
70
|
+
if (f.statuses) {
|
|
71
|
+
clauses.push({ op: "CLAUSE", field: "status", clauseOp: "in", values: f.statuses });
|
|
72
|
+
}
|
|
73
|
+
if (f.types) {
|
|
74
|
+
clauses.push({ op: "CLAUSE", field: "type", clauseOp: "in", values: f.types });
|
|
75
|
+
}
|
|
76
|
+
if (f.hideCompletedReleases) {
|
|
77
|
+
clauses.push({ op: "CLAUSE", field: "release.status", clauseOp: "neq", value: "completed" });
|
|
78
|
+
}
|
|
79
|
+
return { op: "AND", children: clauses };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isAST(x) {
|
|
83
|
+
return x && typeof x === "object" && typeof x.op === "string"
|
|
84
|
+
&& (x.op === "AND" || x.op === "OR" || x.op === "NOT" || x.op === "CLAUSE");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Test whether `ticket` satisfies the filter. Accepts either a simple spec
|
|
89
|
+
* (will be converted to AST internally) or a pre-built AST. `ctx` provides
|
|
90
|
+
* resolution helpers for virtual fields — pass `buildReleaseCtx(snapshot)`
|
|
91
|
+
* if your filter uses `release.status`.
|
|
92
|
+
*/
|
|
93
|
+
function matchesFilter(ticket, filter, ctx) {
|
|
94
|
+
if (!filter) return true;
|
|
95
|
+
const ast = isAST(filter) ? filter : toAST(filter);
|
|
96
|
+
return evalNode(ticket, ast, ctx);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evalNode(ticket, node, ctx) {
|
|
100
|
+
if (!node) return true;
|
|
101
|
+
if (node.op === "AND") {
|
|
102
|
+
return (node.children || []).every(function (c) { return evalNode(ticket, c, ctx); });
|
|
103
|
+
}
|
|
104
|
+
if (node.op === "OR") {
|
|
105
|
+
const cs = node.children || [];
|
|
106
|
+
if (cs.length === 0) return true;
|
|
107
|
+
return cs.some(function (c) { return evalNode(ticket, c, ctx); });
|
|
108
|
+
}
|
|
109
|
+
if (node.op === "NOT") {
|
|
110
|
+
return !evalNode(ticket, node.child, ctx);
|
|
111
|
+
}
|
|
112
|
+
if (node.op === "CLAUSE") {
|
|
113
|
+
return evalClause(ticket, node, ctx);
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function evalClause(ticket, clause, ctx) {
|
|
119
|
+
const value = resolveField(ticket, clause.field, ctx);
|
|
120
|
+
switch (clause.clauseOp) {
|
|
121
|
+
case "eq": return value === clause.value;
|
|
122
|
+
case "neq": return value !== clause.value;
|
|
123
|
+
case "in": return Array.isArray(clause.values) && clause.values.indexOf(value) >= 0;
|
|
124
|
+
case "not-in": return !Array.isArray(clause.values) || clause.values.indexOf(value) < 0;
|
|
125
|
+
default: return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveField(ticket, field, ctx) {
|
|
130
|
+
if (!ticket || !field) return null;
|
|
131
|
+
if (field === "status") return ticket.status;
|
|
132
|
+
if (field === "type") return ticket.type;
|
|
133
|
+
if (field === "release.status") {
|
|
134
|
+
const rid = ticket.position && ticket.position.releaseId;
|
|
135
|
+
if (!rid || !ctx || !ctx.releaseById) return null;
|
|
136
|
+
const r = ctx.releaseById.get(rid);
|
|
137
|
+
return r ? r.status : null;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a release-row should be rendered at all. Used by Map to skip
|
|
144
|
+
* the entire row when its status is `completed` and the filter hides it.
|
|
145
|
+
*/
|
|
146
|
+
function releaseVisible(release, filter) {
|
|
147
|
+
if (!filter || !release) return true;
|
|
148
|
+
const f = normalizeFilter(filter);
|
|
149
|
+
if (f.hideCompletedReleases && release.status === "completed") return false;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Build a release-by-id lookup for use as `ctx` in matchesFilter. */
|
|
154
|
+
function buildReleaseCtx(snapshot) {
|
|
155
|
+
const releaseById = new Map();
|
|
156
|
+
const releases = (snapshot && snapshot.releases) || [];
|
|
157
|
+
for (let i = 0; i < releases.length; i++) {
|
|
158
|
+
releaseById.set(releases[i].id, releases[i]);
|
|
159
|
+
}
|
|
160
|
+
return { releaseById: releaseById };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Count how many filter dimensions are active. Drives the toolbar badge. */
|
|
164
|
+
function countActiveRules(filter) {
|
|
165
|
+
const f = normalizeFilter(filter);
|
|
166
|
+
let n = 0;
|
|
167
|
+
if (f.statuses) n++;
|
|
168
|
+
if (f.types) n++;
|
|
169
|
+
if (f.hideCompletedReleases) n++;
|
|
170
|
+
return n;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sentinel: a fully-empty filter (no constraint). Useful as a default. */
|
|
174
|
+
const EMPTY_FILTER = Object.freeze({
|
|
175
|
+
statuses: null,
|
|
176
|
+
types: null,
|
|
177
|
+
hideCompletedReleases: false
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* SM-16 — free-text search match (distinct from the structured board filter:
|
|
182
|
+
* search HIGHLIGHTS, the filter REMOVES). Case-insensitive; an empty/blank
|
|
183
|
+
* query matches everything. Every whitespace-separated term must be found
|
|
184
|
+
* (AND) somewhere in the ticket's key / title / description / labels.
|
|
185
|
+
*/
|
|
186
|
+
function searchHaystack(ticket) {
|
|
187
|
+
if (!ticket) return "";
|
|
188
|
+
const labels = Array.isArray(ticket.labels) ? ticket.labels.join(" ") : "";
|
|
189
|
+
return [ticket.ticketKey, ticket.title, ticket.description, labels]
|
|
190
|
+
.filter(function (s) { return typeof s === "string"; })
|
|
191
|
+
.join(" ")
|
|
192
|
+
.toLowerCase();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function matchesSearch(ticket, query) {
|
|
196
|
+
const q = (query == null ? "" : String(query)).toLowerCase().trim();
|
|
197
|
+
if (q === "") return true;
|
|
198
|
+
const hay = searchHaystack(ticket);
|
|
199
|
+
return q.split(/\s+/).every(function (term) { return hay.indexOf(term) >= 0; });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
normalizeFilter: normalizeFilter,
|
|
204
|
+
toAST: toAST,
|
|
205
|
+
matchesFilter: matchesFilter,
|
|
206
|
+
matchesSearch: matchesSearch,
|
|
207
|
+
releaseVisible: releaseVisible,
|
|
208
|
+
buildReleaseCtx: buildReleaseCtx,
|
|
209
|
+
countActiveRules: countActiveRules,
|
|
210
|
+
EMPTY_FILTER: EMPTY_FILTER,
|
|
211
|
+
// Exposed for tests / debugging:
|
|
212
|
+
_evalNode: evalNode,
|
|
213
|
+
_resolveField: resolveField
|
|
214
|
+
};
|
|
215
|
+
}));
|