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,659 @@
|
|
|
1
|
+
// SM-282 — Table view (Jira Issue Navigator style).
|
|
2
|
+
//
|
|
3
|
+
// A query-driven table over ALL tickets of the project — including epics and
|
|
4
|
+
// unassigned tickets that Map/Kanban hide. Own query line on top (same SM-187
|
|
5
|
+
// language as the smart-bar and MCP query_tickets; shared/query-engine.js is
|
|
6
|
+
// the single source), result table below. Columns are resolved through
|
|
7
|
+
// QUERY_FIELDS (label + resolve per field) — NO parallel field logic.
|
|
8
|
+
//
|
|
9
|
+
// SM-282 scope: fixed DEFAULT_COLUMNS, row click → ticket modal, live-sync
|
|
10
|
+
// with focus guard. Column config (SM-283), header sort (SM-284), CSV export
|
|
11
|
+
// (SM-285) and saved queries (SM-286) build on top of this skeleton.
|
|
12
|
+
//
|
|
13
|
+
// UMD-wrapped: same source runs in the browser AND require()s in Node tests.
|
|
14
|
+
(function (root, factory) {
|
|
15
|
+
if (typeof module === "object" && module.exports) {
|
|
16
|
+
module.exports = factory(
|
|
17
|
+
require("./query-engine.js"),
|
|
18
|
+
require("./smartbar-autocomplete.js"),
|
|
19
|
+
require("./dnd.js")
|
|
20
|
+
);
|
|
21
|
+
} else {
|
|
22
|
+
(root.STORYMAP = root.STORYMAP || {}).viewTable = factory(
|
|
23
|
+
root.STORYMAP && root.STORYMAP.queryEngine,
|
|
24
|
+
root.STORYMAP && root.STORYMAP.smartbarAutocomplete,
|
|
25
|
+
root.STORYMAP && root.STORYMAP.dnd
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}(typeof self !== "undefined" ? self : this, function (queryEngine, smartbarAutocomplete, dnd) {
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
if (!queryEngine) throw new Error("view-table: query-engine module missing");
|
|
32
|
+
|
|
33
|
+
const TABLE_VIEW = {
|
|
34
|
+
// Column keys reference QUERY_FIELDS[].key — the single field source.
|
|
35
|
+
DEFAULT_COLUMNS: ["key", "type", "title", "status", "release", "processStep", "epic", "updated"],
|
|
36
|
+
// Query re-runs debounced while typing (same cadence as the smart-bar).
|
|
37
|
+
QUERY_DEBOUNCE_MS: 200,
|
|
38
|
+
// SM-283: column config persistence, one entry per project.
|
|
39
|
+
COLUMNS_STORAGE_PREFIX: "storymap-table-columns-",
|
|
40
|
+
// SM-283: dnd drag type for column picker rows.
|
|
41
|
+
DRAG_TYPE_COLUMN: "tv-column",
|
|
42
|
+
// SM-292: default column widths (px) for table-layout:fixed. `title` is
|
|
43
|
+
// deliberately absent — the one width-less column takes the remaining
|
|
44
|
+
// space, so the table always fills the full view width. epic + updated
|
|
45
|
+
// are wide enough that keys/timestamps never wrap (user finding).
|
|
46
|
+
DEFAULT_COL_WIDTHS: {
|
|
47
|
+
key: 90, type: 140, status: 100, release: 170,
|
|
48
|
+
processStep: 150, epic: 90, updated: 140,
|
|
49
|
+
created: 140, label: 140, acCount: 90, linkedTo: 120, linkedFrom: 120,
|
|
50
|
+
},
|
|
51
|
+
// SM-292: per-drag lower bound for column resizing.
|
|
52
|
+
COL_MIN_WIDTH_PX: 56,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function storage() {
|
|
56
|
+
try {
|
|
57
|
+
if (typeof localStorage !== "undefined") return localStorage;
|
|
58
|
+
if (typeof window !== "undefined" && window.localStorage) return window.localStorage;
|
|
59
|
+
} catch (_e) { /* opaque origin / privacy mode — no persistence */ }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function el(tag, attrs, text) {
|
|
64
|
+
const d = document.createElement(tag);
|
|
65
|
+
if (attrs) for (const k in attrs) {
|
|
66
|
+
if (k === "class") d.className = attrs[k];
|
|
67
|
+
else d.setAttribute(k, attrs[k]);
|
|
68
|
+
}
|
|
69
|
+
if (text != null) d.textContent = text;
|
|
70
|
+
return d;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Display formatting per QUERY_FIELDS type. Pure. */
|
|
74
|
+
function formatCell(field, raw) {
|
|
75
|
+
if (raw == null) return "";
|
|
76
|
+
if (field.type === "date") {
|
|
77
|
+
const n = Number(raw);
|
|
78
|
+
if (!n) return "";
|
|
79
|
+
const d = new Date(n);
|
|
80
|
+
const pad = (x) => String(x).padStart(2, "0");
|
|
81
|
+
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
82
|
+
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(raw)) return raw.join(", ");
|
|
85
|
+
return String(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pure view model: run the query (empty string = all live tickets, default
|
|
90
|
+
* ticketKey order) and resolve the column cells via QUERY_FIELDS.
|
|
91
|
+
* → { columns: [{key, label}], rows: [{id, cells: [{key, value}]}], error }
|
|
92
|
+
* On a query error, rows is [] and error carries {message, position} — the
|
|
93
|
+
* DOM layer keeps the last good rows visible instead.
|
|
94
|
+
*/
|
|
95
|
+
function computeTableModel(snapshot, queryString, columnKeys, orderBy) {
|
|
96
|
+
const fields = queryEngine.fieldsByKey();
|
|
97
|
+
const keys = (columnKeys && columnKeys.length ? columnKeys : TABLE_VIEW.DEFAULT_COLUMNS)
|
|
98
|
+
.filter((k) => fields[String(k).toLowerCase()]);
|
|
99
|
+
const columns = keys.map((k) => {
|
|
100
|
+
const f = fields[String(k).toLowerCase()];
|
|
101
|
+
return { key: k, label: f.label };
|
|
102
|
+
});
|
|
103
|
+
const q = (queryString == null ? "" : String(queryString)).trim();
|
|
104
|
+
// null AST = match all (engine contract) — the empty query shows the
|
|
105
|
+
// whole project, which is the visibility point of this view.
|
|
106
|
+
// SM-284: an explicit orderBy (header click) OVERRIDES the query's own
|
|
107
|
+
// ORDER BY (engine contract of opts.orderBy); without one, the query's
|
|
108
|
+
// ORDER BY applies and is reported back for the header indicator.
|
|
109
|
+
const result = queryEngine.queryTickets(snapshot, q === "" ? null : q, orderBy ? { orderBy: orderBy } : undefined);
|
|
110
|
+
if (result.error) return { columns: columns, rows: [], error: result.error, orderBy: null };
|
|
111
|
+
const ctx = queryEngine.buildQueryCtx(snapshot);
|
|
112
|
+
const rows = result.tickets.map((t) => ({
|
|
113
|
+
id: t.id,
|
|
114
|
+
cells: keys.map((k) => {
|
|
115
|
+
const f = fields[String(k).toLowerCase()];
|
|
116
|
+
return { key: k, value: formatCell(f, f.resolve(t, ctx)) };
|
|
117
|
+
})
|
|
118
|
+
}));
|
|
119
|
+
return { columns: columns, rows: rows, error: null, orderBy: result.orderBy || null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- SM-283: column configuration -----------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Filter to known QUERY_FIELDS keys, dedupe; empty/invalid → default set. Pure. */
|
|
125
|
+
function normalizeColumnKeys(keys) {
|
|
126
|
+
const fields = queryEngine.fieldsByKey();
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const known = (Array.isArray(keys) ? keys : []).filter((k) => {
|
|
129
|
+
const lk = String(k).toLowerCase();
|
|
130
|
+
if (!fields[lk] || seen.has(lk)) return false;
|
|
131
|
+
seen.add(lk);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
return known.length ? known : TABLE_VIEW.DEFAULT_COLUMNS.slice();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pure reorder (mirrors view-settings.reorderStatuses on plain keys). */
|
|
138
|
+
function reorderColumnKeys(keys, sourceKey, targetKey, before) {
|
|
139
|
+
if (sourceKey === targetKey) return keys.slice();
|
|
140
|
+
const fromIdx = keys.indexOf(sourceKey);
|
|
141
|
+
if (fromIdx < 0) return keys.slice();
|
|
142
|
+
const next = keys.slice();
|
|
143
|
+
next.splice(fromIdx, 1);
|
|
144
|
+
let insertAt = next.indexOf(targetKey);
|
|
145
|
+
if (insertAt < 0) insertAt = next.length;
|
|
146
|
+
if (!before) insertAt += 1;
|
|
147
|
+
next.splice(insertAt, 0, sourceKey);
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read the per-project table config: { keys: string[]|null, widths: {} }.
|
|
153
|
+
* SM-292 extended the stored shape from a bare key array to
|
|
154
|
+
* { keys, widths } — the old array format is migrated on read.
|
|
155
|
+
*/
|
|
156
|
+
function readColumnsFromStorage(projectId) {
|
|
157
|
+
const ls = storage();
|
|
158
|
+
if (!ls || !projectId) return { keys: null, widths: {} };
|
|
159
|
+
try {
|
|
160
|
+
const raw = ls.getItem(TABLE_VIEW.COLUMNS_STORAGE_PREFIX + projectId);
|
|
161
|
+
if (!raw) return { keys: null, widths: {} };
|
|
162
|
+
const data = JSON.parse(raw);
|
|
163
|
+
if (Array.isArray(data)) return { keys: data, widths: {} }; // legacy shape
|
|
164
|
+
if (data && typeof data === "object") {
|
|
165
|
+
return {
|
|
166
|
+
keys: Array.isArray(data.keys) ? data.keys : null,
|
|
167
|
+
widths: (data.widths && typeof data.widths === "object") ? data.widths : {}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { keys: null, widths: {} };
|
|
171
|
+
} catch (_e) { return { keys: null, widths: {} }; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeColumnsToStorage(projectId, keys, widths) {
|
|
175
|
+
const ls = storage();
|
|
176
|
+
if (!ls || !projectId) return;
|
|
177
|
+
try {
|
|
178
|
+
ls.setItem(TABLE_VIEW.COLUMNS_STORAGE_PREFIX + projectId,
|
|
179
|
+
JSON.stringify({ keys: keys, widths: widths || {} }));
|
|
180
|
+
}
|
|
181
|
+
catch (_e) { /* quota etc. — config just won't persist */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- SM-285: CSV export ----------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** RFC-4180 field escaping: quote when the value contains , " CR or LF. Pure. */
|
|
187
|
+
function csvEscape(value) {
|
|
188
|
+
const s = value == null ? "" : String(value);
|
|
189
|
+
if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Serialize a table model to CSV. Header row = field keys. CRLF lines. Pure. */
|
|
194
|
+
function toCsv(columns, rows) {
|
|
195
|
+
const lines = [columns.map((c) => csvEscape(c.key)).join(",")];
|
|
196
|
+
rows.forEach((r) => {
|
|
197
|
+
lines.push(r.cells.map((c) => csvEscape(c.value)).join(","));
|
|
198
|
+
});
|
|
199
|
+
return lines.join("\r\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Download filename, sanitized like project-io.exportFilename. Pure. */
|
|
203
|
+
function csvFilename(projectId) {
|
|
204
|
+
const id = projectId ? String(projectId).replace(/[^a-zA-Z0-9_-]/g, "_") : "project";
|
|
205
|
+
return id + "-tickets.csv";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Browser glue: trigger a client-side download via a transient anchor.
|
|
209
|
+
* Uses a Blob URL when available, else a data: URI (also covers jsdom). */
|
|
210
|
+
function triggerDownload(filename, text) {
|
|
211
|
+
const a = document.createElement("a");
|
|
212
|
+
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") {
|
|
213
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
|
|
214
|
+
a.href = url;
|
|
215
|
+
a.download = filename;
|
|
216
|
+
a.click();
|
|
217
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
218
|
+
} else {
|
|
219
|
+
a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(text);
|
|
220
|
+
a.download = filename;
|
|
221
|
+
a.click();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- DOM layer ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/** Effective width for a column: user-resized > default > null (flexible). */
|
|
228
|
+
function widthForColumn(key, widths) {
|
|
229
|
+
if (widths && typeof widths[key] === "number" && widths[key] > 0) return widths[key];
|
|
230
|
+
if (typeof TABLE_VIEW.DEFAULT_COL_WIDTHS[key] === "number") return TABLE_VIEW.DEFAULT_COL_WIDTHS[key];
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* SM-294: clamp a resize delta so BOTH the dragged column and the
|
|
236
|
+
* compensating column stay >= minW. This is what makes the separator stop
|
|
237
|
+
* (clip) at the ends instead of drifting. Pure.
|
|
238
|
+
*/
|
|
239
|
+
function clampResizeDelta(dx, draggedW, compW, minW) {
|
|
240
|
+
// Bounds are pinned to never cross zero: when one side ALREADY sits
|
|
241
|
+
// below minW (overconstrained narrow viewport), the drag must not invert
|
|
242
|
+
// — dx=0 stays admissible and only the widening direction of the
|
|
243
|
+
// sub-min column is allowed (review finding on d25fd11).
|
|
244
|
+
return Math.max(Math.min(0, minW - draggedW), Math.min(Math.max(0, compW - minW), dx));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* SM-294: pick the column that compensates a resize 1:1 (keeps the column
|
|
249
|
+
* sum == container, so fixed-layout has NO slack to redistribute and the
|
|
250
|
+
* separator tracks the mouse exactly). Preference: the flexible column
|
|
251
|
+
* (no width — normally `title`); when the flexible column itself is
|
|
252
|
+
* dragged, the next column to the right (or left, for the last one). Pure.
|
|
253
|
+
*/
|
|
254
|
+
function pickCompensator(columnKeys, draggedKey, widths) {
|
|
255
|
+
// Dragging a width-carrying column: the flexible partner absorbs.
|
|
256
|
+
if (widthForColumn(draggedKey, widths) != null) {
|
|
257
|
+
const flex = columnKeys.find((k) => k !== draggedKey && widthForColumn(k, widths) == null);
|
|
258
|
+
if (flex) return flex;
|
|
259
|
+
}
|
|
260
|
+
const idx = columnKeys.indexOf(draggedKey);
|
|
261
|
+
if (idx < 0) return null;
|
|
262
|
+
// Dragging the flexible column (or no flexible partner exists): prefer a
|
|
263
|
+
// WIDTH-CARRYING neighbor — a flexible partner would make the resulting
|
|
264
|
+
// patch empty and the whole gesture a silent no-op (review finding).
|
|
265
|
+
const after = columnKeys.slice(idx + 1).find((k) => widthForColumn(k, widths) != null);
|
|
266
|
+
if (after) return after;
|
|
267
|
+
const before = columnKeys.slice(0, idx).reverse().find((k) => widthForColumn(k, widths) != null);
|
|
268
|
+
if (before) return before;
|
|
269
|
+
// All partners flexible: plain neighbor (live preview only).
|
|
270
|
+
if (idx < columnKeys.length - 1) return columnKeys[idx + 1];
|
|
271
|
+
return idx > 0 ? columnKeys[idx - 1] : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderTable(tableHost, model, opts, refs) {
|
|
275
|
+
tableHost.innerHTML = "";
|
|
276
|
+
const table = el("table", { class: "tv-table" });
|
|
277
|
+
// SM-292: table-layout:fixed + colgroup. Columns with a width keep it;
|
|
278
|
+
// the width-less ones (title by default) share the remaining space, so
|
|
279
|
+
// the table always fills the full view width.
|
|
280
|
+
const widths = (refs && refs.widths) || {};
|
|
281
|
+
const colgroup = el("colgroup");
|
|
282
|
+
const colEls = {};
|
|
283
|
+
const thEls = {};
|
|
284
|
+
model.columns.forEach((c) => {
|
|
285
|
+
const col = el("col", { "data-col": c.key });
|
|
286
|
+
const w = widthForColumn(c.key, widths);
|
|
287
|
+
if (w != null) col.style.width = w + "px";
|
|
288
|
+
colEls[c.key] = col;
|
|
289
|
+
colgroup.appendChild(col);
|
|
290
|
+
});
|
|
291
|
+
table.appendChild(colgroup);
|
|
292
|
+
const thead = el("thead");
|
|
293
|
+
const headRow = el("tr");
|
|
294
|
+
const sortField = model.orderBy ? String(model.orderBy.field).toLowerCase() : null;
|
|
295
|
+
model.columns.forEach((c) => {
|
|
296
|
+
const th = el("th", { "data-col": c.key });
|
|
297
|
+
th.appendChild(el("span", { class: "tv-th-label" }, c.label));
|
|
298
|
+
// SM-284: clickable headers; the active sort column carries the
|
|
299
|
+
// indicator (data-sort-dir styles the ▲/▼ via CSS).
|
|
300
|
+
if (sortField && String(c.key).toLowerCase() === sortField) {
|
|
301
|
+
th.setAttribute("data-sort-dir", model.orderBy.dir === "desc" ? "desc" : "asc");
|
|
302
|
+
}
|
|
303
|
+
if (opts.onHeaderSort) {
|
|
304
|
+
th.addEventListener("click", () => opts.onHeaderSort(c.key));
|
|
305
|
+
}
|
|
306
|
+
// SM-292: per-column resize handle at the right edge of the header.
|
|
307
|
+
// Pointer-drag adjusts the col width live; the handle never triggers
|
|
308
|
+
// the sort click (stopPropagation on down + click).
|
|
309
|
+
if (opts.onColumnResize) {
|
|
310
|
+
const handle = el("span", { class: "tv-resize-handle" });
|
|
311
|
+
handle.addEventListener("click", (ev) => ev.stopPropagation());
|
|
312
|
+
handle.addEventListener("pointerdown", (ev) => {
|
|
313
|
+
ev.stopPropagation();
|
|
314
|
+
ev.preventDefault();
|
|
315
|
+
// Capture so pointerup outside the window still reaches us (mouse
|
|
316
|
+
// input gets no implicit capture) — review finding on 8218ee3.
|
|
317
|
+
try { if (handle.setPointerCapture && ev.pointerId != null) handle.setPointerCapture(ev.pointerId); } catch (_e) {}
|
|
318
|
+
const keys = model.columns.map((x) => x.key);
|
|
319
|
+
const compKey = pickCompensator(keys, c.key, widths);
|
|
320
|
+
if (!compKey) return;
|
|
321
|
+
const startX = ev.clientX;
|
|
322
|
+
// SM-294: freeze EVERY column at its measured width (sum ==
|
|
323
|
+
// container) and compensate the drag 1:1 on compKey. With zero
|
|
324
|
+
// slack, fixed-layout redistributes nothing and the separator
|
|
325
|
+
// tracks the mouse exactly. Measure the live THs (cols have no
|
|
326
|
+
// principal box → zero rect); fall back to configured widths.
|
|
327
|
+
const start = {};
|
|
328
|
+
keys.forEach((k) => {
|
|
329
|
+
const r = thEls[k] && thEls[k].getBoundingClientRect ? thEls[k].getBoundingClientRect() : null;
|
|
330
|
+
start[k] = (r && r.width) || widthForColumn(k, widths) || 150;
|
|
331
|
+
});
|
|
332
|
+
keys.forEach((k) => { colEls[k].style.width = Math.round(start[k]) + "px"; });
|
|
333
|
+
let moved = false;
|
|
334
|
+
function onMove(mv) {
|
|
335
|
+
moved = true;
|
|
336
|
+
const dx = clampResizeDelta(mv.clientX - startX, start[c.key], start[compKey], TABLE_VIEW.COL_MIN_WIDTH_PX);
|
|
337
|
+
colEls[c.key].style.width = Math.round(start[c.key] + dx) + "px";
|
|
338
|
+
colEls[compKey].style.width = Math.round(start[compKey] - dx) + "px";
|
|
339
|
+
}
|
|
340
|
+
function cleanup() {
|
|
341
|
+
document.removeEventListener("pointermove", onMove);
|
|
342
|
+
document.removeEventListener("pointerup", onUp);
|
|
343
|
+
document.removeEventListener("pointercancel", onCancel);
|
|
344
|
+
}
|
|
345
|
+
function suppressTrailingClick() {
|
|
346
|
+
// A clamped drag can end with the cursor over the TH label — the
|
|
347
|
+
// synthesized click would toggle the sort. Swallow exactly the
|
|
348
|
+
// click that belongs to this gesture.
|
|
349
|
+
if (!moved) return;
|
|
350
|
+
const swallow = (ce) => ce.stopPropagation();
|
|
351
|
+
th.addEventListener("click", swallow, true);
|
|
352
|
+
setTimeout(() => th.removeEventListener("click", swallow, true), 0);
|
|
353
|
+
}
|
|
354
|
+
function onUp(uv) {
|
|
355
|
+
cleanup();
|
|
356
|
+
suppressTrailingClick();
|
|
357
|
+
const dx = clampResizeDelta(uv.clientX - startX, start[c.key], start[compKey], TABLE_VIEW.COL_MIN_WIDTH_PX);
|
|
358
|
+
// Persist only width-carrying columns. The flexible column (no
|
|
359
|
+
// configured width — normally `title`) NEVER gets one: it stays
|
|
360
|
+
// the slack absorber, so sum == container holds after re-render
|
|
361
|
+
// and its new size follows implicitly from the others.
|
|
362
|
+
const patch = {};
|
|
363
|
+
if (widthForColumn(c.key, widths) != null) patch[c.key] = Math.round(start[c.key] + dx);
|
|
364
|
+
if (widthForColumn(compKey, widths) != null) patch[compKey] = Math.round(start[compKey] - dx);
|
|
365
|
+
opts.onColumnResize(patch);
|
|
366
|
+
}
|
|
367
|
+
function onCancel() {
|
|
368
|
+
// Gesture taken over (touch scroll, OS gesture): persist nothing;
|
|
369
|
+
// the empty patch just re-renders the canonical widths.
|
|
370
|
+
cleanup();
|
|
371
|
+
suppressTrailingClick();
|
|
372
|
+
opts.onColumnResize({});
|
|
373
|
+
}
|
|
374
|
+
document.addEventListener("pointermove", onMove);
|
|
375
|
+
document.addEventListener("pointerup", onUp);
|
|
376
|
+
document.addEventListener("pointercancel", onCancel);
|
|
377
|
+
});
|
|
378
|
+
th.appendChild(handle);
|
|
379
|
+
}
|
|
380
|
+
thEls[c.key] = th;
|
|
381
|
+
headRow.appendChild(th);
|
|
382
|
+
});
|
|
383
|
+
thead.appendChild(headRow);
|
|
384
|
+
table.appendChild(thead);
|
|
385
|
+
const tbody = el("tbody");
|
|
386
|
+
model.rows.forEach((r) => {
|
|
387
|
+
const tr = el("tr", { "data-ticket-id": r.id });
|
|
388
|
+
r.cells.forEach((c) => tr.appendChild(el("td", { "data-col": c.key }, c.value)));
|
|
389
|
+
tr.addEventListener("click", () => { if (opts.onTicketClick) opts.onTicketClick(r.id); });
|
|
390
|
+
tbody.appendChild(tr);
|
|
391
|
+
});
|
|
392
|
+
table.appendChild(tbody);
|
|
393
|
+
tableHost.appendChild(table);
|
|
394
|
+
const count = el("div", { class: "tv-count" },
|
|
395
|
+
model.rows.length + (model.rows.length === 1 ? " ticket" : " tickets"));
|
|
396
|
+
tableHost.appendChild(count);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function applyQuery(refs, store, opts, queryString) {
|
|
400
|
+
// SM-284 (review finding): a NEW query resets the header sort — otherwise
|
|
401
|
+
// a single header click would permanently shadow any ORDER BY the user
|
|
402
|
+
// types later (engine opts.orderBy always wins over the query's own).
|
|
403
|
+
if (refs.sort && queryString !== refs.lastGoodQuery) refs.sort = null;
|
|
404
|
+
const model = computeTableModel(store.get(), queryString, refs.columns || opts.columns, refs.sort || null);
|
|
405
|
+
refs.errorHost.innerHTML = "";
|
|
406
|
+
if (model.error) {
|
|
407
|
+
// Non-destructive (same contract as the smart-bar): report the error,
|
|
408
|
+
// keep the last good result on screen.
|
|
409
|
+
const msg = model.error.message
|
|
410
|
+
+ (model.error.position != null ? " (at " + model.error.position + ")" : "");
|
|
411
|
+
refs.errorHost.appendChild(el("div", { class: "tv-query-error" }, msg));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
refs.lastGoodQuery = queryString;
|
|
415
|
+
refs.lastOrderBy = model.orderBy; // SM-284: effective sort (header OR query ORDER BY)
|
|
416
|
+
renderTable(refs.tableHost, model, opts, refs);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Mount the table view. Returns { unmount }.
|
|
421
|
+
* opts: onTicketClick(id), columns? (SM-283 hook), getSnapshotForAutocomplete?
|
|
422
|
+
*/
|
|
423
|
+
function mount(host, store, opts) {
|
|
424
|
+
opts = opts || {};
|
|
425
|
+
host.innerHTML = "";
|
|
426
|
+
const root = el("div", { class: "tv-root" });
|
|
427
|
+
|
|
428
|
+
const queryRow = el("div", { class: "tv-query-row" });
|
|
429
|
+
const input = el("input", {
|
|
430
|
+
class: "tv-query-input",
|
|
431
|
+
type: "text",
|
|
432
|
+
placeholder: "Query… e.g. type = user-story AND status IN (ready, in-progress) — empty shows all",
|
|
433
|
+
spellcheck: "false"
|
|
434
|
+
});
|
|
435
|
+
queryRow.appendChild(input);
|
|
436
|
+
const columnsBtn = el("button", { class: "btn tv-columns-btn", type: "button" }, "Columns");
|
|
437
|
+
queryRow.appendChild(columnsBtn);
|
|
438
|
+
// SM-289: second entry point to the (general) ticket-import dialog.
|
|
439
|
+
if (opts.onImport) {
|
|
440
|
+
const importBtn = el("button", { class: "btn tv-import-btn", type: "button" }, "Import…");
|
|
441
|
+
importBtn.addEventListener("click", () => opts.onImport());
|
|
442
|
+
queryRow.appendChild(importBtn);
|
|
443
|
+
}
|
|
444
|
+
const columnsPanelHost = el("div", { class: "tv-columns-panel-host" });
|
|
445
|
+
const errorHost = el("div", { class: "tv-query-error-host" });
|
|
446
|
+
const tableHost = el("div", { class: "tv-table-host" });
|
|
447
|
+
root.appendChild(queryRow);
|
|
448
|
+
root.appendChild(columnsPanelHost);
|
|
449
|
+
root.appendChild(errorHost);
|
|
450
|
+
root.appendChild(tableHost);
|
|
451
|
+
host.appendChild(root);
|
|
452
|
+
|
|
453
|
+
// SM-283/292: column config — per-project persisted selection, order + widths.
|
|
454
|
+
const projectId = (store.get() && store.get().project && store.get().project.id) || null;
|
|
455
|
+
const storedConfig = readColumnsFromStorage(projectId);
|
|
456
|
+
const refs = {
|
|
457
|
+
errorHost: errorHost,
|
|
458
|
+
tableHost: tableHost,
|
|
459
|
+
lastGoodQuery: "",
|
|
460
|
+
columns: normalizeColumnKeys(storedConfig.keys),
|
|
461
|
+
widths: storedConfig.widths || {}, // SM-292: per-column pixel widths (user-resized)
|
|
462
|
+
sort: null, // SM-284: header-click sort {field, dir} — overrides query ORDER BY
|
|
463
|
+
lastOrderBy: null // SM-284: effective sort of the last render (for toggle direction)
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// SM-284: clickable column headers. Same column again toggles direction;
|
|
467
|
+
// a different column starts ascending. The header sort overrides a query
|
|
468
|
+
// ORDER BY (engine opts.orderBy contract).
|
|
469
|
+
// SM-292: onColumnResize persists the dragged width with the config.
|
|
470
|
+
opts = Object.assign({}, opts, {
|
|
471
|
+
onHeaderSort: (key) => {
|
|
472
|
+
const cur = refs.lastOrderBy;
|
|
473
|
+
const dir = (cur && String(cur.field).toLowerCase() === String(key).toLowerCase() && cur.dir !== "desc")
|
|
474
|
+
? "desc" : "asc";
|
|
475
|
+
refs.sort = { field: key, dir: dir };
|
|
476
|
+
applyQuery(refs, store, opts, refs.lastGoodQuery);
|
|
477
|
+
},
|
|
478
|
+
// SM-294: the resize hands over a width PATCH (dragged + possibly the
|
|
479
|
+
// compensating neighbor; the flexible column is never included). An
|
|
480
|
+
// empty patch (cancelled drag) just re-renders the canonical widths.
|
|
481
|
+
onColumnResize: (patch) => {
|
|
482
|
+
const changed = patch && Object.keys(patch).length > 0;
|
|
483
|
+
if (changed) {
|
|
484
|
+
refs.widths = Object.assign({}, refs.widths, patch);
|
|
485
|
+
writeColumnsToStorage(projectId, refs.columns, refs.widths);
|
|
486
|
+
}
|
|
487
|
+
applyQuery(refs, store, opts, refs.lastGoodQuery);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
function setColumns(nextKeys) {
|
|
492
|
+
refs.columns = normalizeColumnKeys(nextKeys);
|
|
493
|
+
writeColumnsToStorage(projectId, refs.columns, refs.widths);
|
|
494
|
+
applyQuery(refs, store, opts, refs.lastGoodQuery);
|
|
495
|
+
if (columnsPanelHost.childNodes.length) renderColumnsPanel();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function renderColumnsPanel() {
|
|
499
|
+
columnsPanelHost.innerHTML = "";
|
|
500
|
+
const panel = el("div", { class: "tv-columns-panel" });
|
|
501
|
+
// Active columns first (in display order), then the remaining fields in
|
|
502
|
+
// QUERY_FIELDS order. Only active rows carry a grip (order = table order).
|
|
503
|
+
const activeSet = new Set(refs.columns.map((k) => String(k).toLowerCase()));
|
|
504
|
+
const rest = queryEngine.QUERY_FIELDS
|
|
505
|
+
.map((f) => f.key)
|
|
506
|
+
.filter((k) => !activeSet.has(String(k).toLowerCase()));
|
|
507
|
+
const fields = queryEngine.fieldsByKey();
|
|
508
|
+
refs.columns.concat(rest).forEach((key) => {
|
|
509
|
+
const f = fields[String(key).toLowerCase()];
|
|
510
|
+
const active = activeSet.has(String(key).toLowerCase());
|
|
511
|
+
const row = el("div", { class: "tv-columns-row" + (active ? " tv-columns-row-active" : "") });
|
|
512
|
+
row.setAttribute("data-col-key", key);
|
|
513
|
+
if (active) row.appendChild(el("span", { class: "tv-columns-grip" }, "⋮⋮"));
|
|
514
|
+
const cb = el("input", { type: "checkbox" });
|
|
515
|
+
cb.checked = active;
|
|
516
|
+
cb.addEventListener("change", () => {
|
|
517
|
+
if (cb.checked) {
|
|
518
|
+
setColumns(refs.columns.concat([key]));
|
|
519
|
+
} else {
|
|
520
|
+
const next = refs.columns.filter((k) => k !== key);
|
|
521
|
+
// Min 1 column: refuse to empty the table entirely.
|
|
522
|
+
if (next.length === 0) { cb.checked = true; return; }
|
|
523
|
+
setColumns(next);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
row.appendChild(cb);
|
|
527
|
+
row.appendChild(el("span", { class: "tv-columns-label" }, f.label));
|
|
528
|
+
if (active && dnd && typeof dnd.enableDraggable === "function") {
|
|
529
|
+
dnd.enableDraggable(row, { dragType: TABLE_VIEW.DRAG_TYPE_COLUMN, dragId: key });
|
|
530
|
+
dnd.enableDropTarget(row, {
|
|
531
|
+
accepts: [TABLE_VIEW.DRAG_TYPE_COLUMN],
|
|
532
|
+
onEnter: (e) => e.classList.add("tv-columns-drop-target"),
|
|
533
|
+
onLeave: (e) => e.classList.remove("tv-columns-drop-target"),
|
|
534
|
+
onDrop: ({ id, event }) => {
|
|
535
|
+
row.classList.remove("tv-columns-drop-target");
|
|
536
|
+
if (id === key) return;
|
|
537
|
+
const rect = row.getBoundingClientRect();
|
|
538
|
+
const before = (event && typeof event.clientY === "number")
|
|
539
|
+
? event.clientY < rect.top + rect.height / 2 : true;
|
|
540
|
+
setColumns(reorderColumnKeys(refs.columns, id, key, before));
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
panel.appendChild(row);
|
|
545
|
+
});
|
|
546
|
+
const footer = el("div", { class: "tv-columns-footer" });
|
|
547
|
+
const resetBtn = el("button", { class: "btn tv-columns-reset", type: "button" }, "Reset to defaults");
|
|
548
|
+
resetBtn.addEventListener("click", () => setColumns(TABLE_VIEW.DEFAULT_COLUMNS.slice()));
|
|
549
|
+
footer.appendChild(resetBtn);
|
|
550
|
+
panel.appendChild(footer);
|
|
551
|
+
columnsPanelHost.appendChild(panel);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
columnsBtn.addEventListener("click", () => {
|
|
555
|
+
if (columnsPanelHost.childNodes.length) { columnsPanelHost.innerHTML = ""; return; }
|
|
556
|
+
renderColumnsPanel();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// SM-285/292: CSV export lives in the Project menu now (user finding) —
|
|
560
|
+
// the mounted view exposes it on the controller so the menu exports the
|
|
561
|
+
// CURRENT state (query + columns + sort). One source: computeTableModel.
|
|
562
|
+
function exportCsv() {
|
|
563
|
+
const model = computeTableModel(store.get(), refs.lastGoodQuery, refs.columns, refs.sort || null);
|
|
564
|
+
if (model.error) return; // defensive: lastGoodQuery cannot really error
|
|
565
|
+
triggerDownload(csvFilename(projectId), toCsv(model.columns, model.rows));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let debounceTimer = null;
|
|
569
|
+
function onInput() {
|
|
570
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
571
|
+
debounceTimer = setTimeout(() => {
|
|
572
|
+
applyQuery(refs, store, opts, input.value || "");
|
|
573
|
+
}, TABLE_VIEW.QUERY_DEBOUNCE_MS);
|
|
574
|
+
}
|
|
575
|
+
input.addEventListener("input", onInput);
|
|
576
|
+
input.addEventListener("keydown", (ev) => {
|
|
577
|
+
if (ev.key === "Enter") {
|
|
578
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
579
|
+
applyQuery(refs, store, opts, input.value || "");
|
|
580
|
+
} else if (ev.key === "Escape") {
|
|
581
|
+
input.value = "";
|
|
582
|
+
applyQuery(refs, store, opts, "");
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Jira-style autocomplete on the view's own query line (same module as
|
|
587
|
+
// the smart-bar; capture-phase keydown intercepts Enter/Tab/Esc while
|
|
588
|
+
// the dropdown is open).
|
|
589
|
+
let acHandle = null;
|
|
590
|
+
if (smartbarAutocomplete && smartbarAutocomplete.attachAutocomplete) {
|
|
591
|
+
acHandle = smartbarAutocomplete.attachAutocomplete(input, {
|
|
592
|
+
getSnapshot: () => store.get(),
|
|
593
|
+
onAccept: () => applyQuery(refs, store, opts, input.value || "")
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Live-sync: any store commit re-renders the table. The query input lives
|
|
598
|
+
// outside tableHost and is never rebuilt, so typing is never stomped.
|
|
599
|
+
// Focus guard: while the user is mid-edit (input focused), the rerun uses
|
|
600
|
+
// the LAST APPLIED query, not the half-typed one — otherwise an external
|
|
601
|
+
// commit would surface a parse error for an incomplete expression.
|
|
602
|
+
const unsub = store.subscribe
|
|
603
|
+
? store.subscribe(() => applyQuery(refs, store, opts,
|
|
604
|
+
document.activeElement === input ? refs.lastGoodQuery : (input.value || "")))
|
|
605
|
+
: null;
|
|
606
|
+
|
|
607
|
+
applyQuery(refs, store, opts, input.value || "");
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
exportCsv: exportCsv, // SM-292: called from the Project menu
|
|
611
|
+
unmount() {
|
|
612
|
+
if (typeof unsub === "function") unsub();
|
|
613
|
+
if (acHandle && acHandle.detach) acHandle.detach();
|
|
614
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
615
|
+
host.innerHTML = "";
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* SM-292: Project-menu export path when the Table view is NOT mounted —
|
|
622
|
+
* all tickets (empty query) with the project's persisted column config.
|
|
623
|
+
*/
|
|
624
|
+
function exportTicketsCsv(store, projectId) {
|
|
625
|
+
const stored = readColumnsFromStorage(projectId);
|
|
626
|
+
const model = computeTableModel(store.get(), "", normalizeColumnKeys(stored.keys), null);
|
|
627
|
+
if (model.error) return;
|
|
628
|
+
triggerDownload(csvFilename(projectId), toCsv(model.columns, model.rows));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Test hook: run a query against a mounted host synchronously. */
|
|
632
|
+
function _applyQueryForTest(host, store, queryString) {
|
|
633
|
+
const refs = {
|
|
634
|
+
errorHost: host.querySelector(".tv-query-error-host"),
|
|
635
|
+
tableHost: host.querySelector(".tv-table-host"),
|
|
636
|
+
lastGoodQuery: ""
|
|
637
|
+
};
|
|
638
|
+
applyQuery(refs, store, {}, queryString);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
TABLE_VIEW: TABLE_VIEW,
|
|
643
|
+
computeTableModel: computeTableModel,
|
|
644
|
+
formatCell: formatCell,
|
|
645
|
+
normalizeColumnKeys: normalizeColumnKeys,
|
|
646
|
+
reorderColumnKeys: reorderColumnKeys,
|
|
647
|
+
toCsv: toCsv,
|
|
648
|
+
csvEscape: csvEscape,
|
|
649
|
+
csvFilename: csvFilename,
|
|
650
|
+
widthForColumn: widthForColumn,
|
|
651
|
+
clampResizeDelta: clampResizeDelta,
|
|
652
|
+
pickCompensator: pickCompensator,
|
|
653
|
+
exportTicketsCsv: exportTicketsCsv,
|
|
654
|
+
readColumnsFromStorage: readColumnsFromStorage,
|
|
655
|
+
writeColumnsToStorage: writeColumnsToStorage,
|
|
656
|
+
mount: mount,
|
|
657
|
+
_applyQueryForTest: _applyQueryForTest
|
|
658
|
+
};
|
|
659
|
+
}));
|