generated from coulomb/repo-seed
429 lines
17 KiB
JavaScript
429 lines
17 KiB
JavaScript
/**
|
|
* entity-modal — click any entity row or card to open a full-detail overlay.
|
|
*
|
|
* Usage:
|
|
* import {openEntityModal} from "./components/entity-modal.js";
|
|
* row.addEventListener("click", () => openEntityModal(entity, "workstream"));
|
|
*
|
|
* Supported types: "workstream" | "task" | "ep" | "td"
|
|
*/
|
|
|
|
const _STYLE_ID = "entity-modal-styles";
|
|
|
|
function _ensureStyles() {
|
|
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
|
const s = document.createElement("style");
|
|
s.id = _STYLE_ID;
|
|
s.textContent = `
|
|
/* ── Modal backdrop ──────────────────────────────────────────────────────── */
|
|
.entity-modal {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
|
z-index: 9100; display: flex; align-items: center; justify-content: center;
|
|
animation: _em-fade 0.15s ease;
|
|
}
|
|
@keyframes _em-fade { from { opacity:0 } to { opacity:1 } }
|
|
|
|
/* ── Modal box ────────────────────────────────────────────────────────────── */
|
|
.entity-modal-box {
|
|
width: min(700px, 92vw); max-height: 88vh;
|
|
background: var(--theme-background, #fff); border-radius: 12px;
|
|
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
|
display: flex; flex-direction: column;
|
|
animation: _em-rise 0.15s ease; overflow: hidden;
|
|
}
|
|
@keyframes _em-rise {
|
|
from { transform: translateY(14px); opacity: 0 }
|
|
to { transform: translateY(0); opacity: 1 }
|
|
}
|
|
|
|
/* ── Header ───────────────────────────────────────────────────────────────── */
|
|
.entity-modal-header {
|
|
display: flex; align-items: flex-start; gap: 0.75rem;
|
|
padding: 0.85rem 1rem 0.75rem;
|
|
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
|
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
|
|
}
|
|
.entity-modal-title {
|
|
flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35;
|
|
color: var(--theme-foreground, #111); word-break: break-word;
|
|
}
|
|
.entity-modal-close {
|
|
background: none; border: 1px solid transparent; cursor: pointer;
|
|
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
|
|
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
|
|
font-family: inherit; line-height: 1.3;
|
|
}
|
|
.entity-modal-close:hover {
|
|
border-color: var(--theme-foreground-faint, #ccc);
|
|
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
|
}
|
|
|
|
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
|
.entity-modal-body {
|
|
overflow-y: auto; padding: 0.85rem 1rem;
|
|
display: flex; flex-direction: column; gap: 0.4rem;
|
|
}
|
|
|
|
/* ── Field rows ───────────────────────────────────────────────────────────── */
|
|
.em-field {
|
|
display: grid; grid-template-columns: 130px 1fr;
|
|
gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline;
|
|
}
|
|
.em-label {
|
|
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
|
|
padding-top: 0.14rem; white-space: nowrap;
|
|
}
|
|
.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; }
|
|
|
|
/* ── Description block ────────────────────────────────────────────────────── */
|
|
.em-desc {
|
|
font-size: 0.83rem; color: var(--theme-foreground-muted, #555);
|
|
line-height: 1.55; white-space: pre-wrap; word-break: break-word;
|
|
background: var(--theme-background-alt, #f9f9f9);
|
|
border-radius: 6px; padding: 0.55rem 0.75rem;
|
|
border: 1px solid var(--theme-foreground-faint, #eee);
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* ── Divider ──────────────────────────────────────────────────────────────── */
|
|
.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; }
|
|
|
|
/* ── Badge (reused from page styles, self-contained) ─────────────────────── */
|
|
.em-badge {
|
|
display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px;
|
|
font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
|
|
/* ── Deps list ────────────────────────────────────────────────────────────── */
|
|
.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; }
|
|
.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); }
|
|
|
|
/* ── Entity table (shared across all list pages) ──────────────────────────── */
|
|
.entity-table-wrap { overflow-x: auto; max-width: 100%; }
|
|
.entity-table {
|
|
width: 100%; border-collapse: collapse; font-size: 0.87rem;
|
|
table-layout: fixed; /* honour column widths; never spill outside container */
|
|
}
|
|
.entity-table thead th {
|
|
text-align: left; padding: 0.4rem 0.65rem;
|
|
border-bottom: 2px solid var(--theme-foreground-faint, #ddd);
|
|
font-size: 0.73rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
|
|
.entity-table tbody tr:last-child { border-bottom: none; }
|
|
.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); }
|
|
.entity-table td {
|
|
padding: 0.4rem 0.65rem; vertical-align: middle;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.entity-row { cursor: pointer; }
|
|
/* Proportional column widths — other cols share the remainder equally */
|
|
.et-title-col { width: 32%; }
|
|
.et-ws-col { width: 14%; }
|
|
.et-title-cell { font-weight: 500; }
|
|
.et-ws-cell { font-style: italic; }
|
|
`;
|
|
document.head.append(s);
|
|
}
|
|
|
|
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
|
const _STATUS_STYLE = {
|
|
proposed: "background:#fef3c7;color:#92400e",
|
|
ready: "background:#e0f2fe;color:#075985",
|
|
active: "background:#d4edda;color:#155724",
|
|
blocked: "background:#f8d7da;color:#721c24",
|
|
backlog: "background:#f1f5f9;color:#64748b",
|
|
finished: "background:#cce5ff;color:#004085",
|
|
completed: "background:#cce5ff;color:#004085",
|
|
archived: "background:#e2e3e5;color:#383d41",
|
|
open: "background:#dbeafe;color:#1e40af",
|
|
in_progress: "background:#fef3c7;color:#92400e",
|
|
wait: "background:#fef3c7;color:#92400e",
|
|
progress: "background:#ede9fe;color:#5b21b6",
|
|
addressed: "background:#dcfce7;color:#166534",
|
|
deferred: "background:#f1f5f9;color:#64748b",
|
|
wont_fix: "background:#f3f4f6;color:#9ca3af",
|
|
todo: "background:#f1f5f9;color:#475569",
|
|
done: "background:#dcfce7;color:#166534",
|
|
cancel: "background:#f3f4f6;color:#9ca3af",
|
|
cancelled: "background:#f3f4f6;color:#9ca3af",
|
|
resolved: "background:#dcfce7;color:#166534",
|
|
superseded: "background:#e2e3e5;color:#383d41",
|
|
};
|
|
|
|
const _PRIORITY_STYLE = {
|
|
critical: "background:#fee2e2;color:#991b1b",
|
|
high: "background:#ffedd5;color:#9a3412",
|
|
medium: "background:#dbeafe;color:#1e40af",
|
|
low: "background:#f1f5f9;color:#475569",
|
|
};
|
|
|
|
/* ── DOM helpers ─────────────────────────────────────────────────────────── */
|
|
function _badge(text, styleMap) {
|
|
const el = document.createElement("span");
|
|
el.className = "em-badge";
|
|
el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555";
|
|
el.textContent = (text ?? "").replace(/_/g, " ");
|
|
return el;
|
|
}
|
|
|
|
function _field(label, valueEl) {
|
|
const row = document.createElement("div");
|
|
row.className = "em-field";
|
|
const l = document.createElement("span");
|
|
l.className = "em-label";
|
|
l.textContent = label;
|
|
row.append(l, valueEl);
|
|
return row;
|
|
}
|
|
|
|
function _textVal(text) {
|
|
const v = document.createElement("span");
|
|
v.className = "em-value";
|
|
v.textContent = text ?? "—";
|
|
return v;
|
|
}
|
|
|
|
function _descVal(text) {
|
|
const v = document.createElement("div");
|
|
v.className = "em-desc";
|
|
v.textContent = text;
|
|
return v;
|
|
}
|
|
|
|
function _divider() {
|
|
const hr = document.createElement("hr");
|
|
hr.className = "em-divider";
|
|
return hr;
|
|
}
|
|
|
|
function _fmtDate(iso) {
|
|
if (!iso) return "—";
|
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
|
}
|
|
|
|
/* ── Body builders per entity type ─────────────────────────────────────── */
|
|
function _buildBody(entity, type) {
|
|
const els = [];
|
|
const tf = (label, text) => _field(label, _textVal(text));
|
|
const bf = (label, val, styleMap) => {
|
|
const v = document.createElement("span");
|
|
v.className = "em-value";
|
|
v.append(_badge(val, styleMap));
|
|
return _field(label, v);
|
|
};
|
|
|
|
if (type === "workstream") {
|
|
els.push(
|
|
bf("Status", entity.status, _STATUS_STYLE),
|
|
tf("Domain", entity.domain ?? entity.topic_title ?? "—"),
|
|
tf("Owner", entity.owner ?? "—"),
|
|
tf("Due", entity.due_date ?? "—"),
|
|
);
|
|
if (entity.description) {
|
|
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
|
}
|
|
if (entity.tasks_total !== undefined) {
|
|
els.push(_divider(),
|
|
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
|
|
(entity.tasks_progress > 0 ? ` · ${entity.tasks_progress} progress` : "") +
|
|
(entity.tasks_wait > 0 ? ` · ${entity.tasks_wait} wait` : ""))
|
|
);
|
|
}
|
|
if (entity.depends_on?.length) {
|
|
const list = document.createElement("div"); list.className = "em-deps-list";
|
|
for (const d of entity.depends_on) {
|
|
const span = document.createElement("span"); span.className = "em-dep-item";
|
|
span.textContent = `↳ ${d.workstream_title}`;
|
|
list.append(span);
|
|
}
|
|
els.push(_field("Depends on", list));
|
|
}
|
|
if (entity.blocks?.length) {
|
|
const list = document.createElement("div"); list.className = "em-deps-list";
|
|
for (const d of entity.blocks) {
|
|
const span = document.createElement("span"); span.className = "em-dep-item";
|
|
span.textContent = `⊳ ${d.workstream_title}`;
|
|
list.append(span);
|
|
}
|
|
els.push(_field("Blocks", list));
|
|
}
|
|
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
|
if (entity.slug) els.push(tf("Slug", entity.slug));
|
|
els.push(tf("ID", entity.id));
|
|
}
|
|
|
|
else if (type === "task") {
|
|
els.push(
|
|
bf("Status", entity.status, _STATUS_STYLE),
|
|
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
|
tf("Domain", entity.domain ?? "—"),
|
|
tf("Workstream", entity.workstream_title ?? "—"),
|
|
tf("Assignee", entity.assignee ?? "—"),
|
|
tf("Due", entity.due_date ?? "—"),
|
|
);
|
|
if (entity.description) {
|
|
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
|
}
|
|
if (entity.blocking_reason) {
|
|
const v = document.createElement("span");
|
|
v.className = "em-value"; v.style.color = "#b45309";
|
|
v.textContent = entity.blocking_reason;
|
|
els.push(_divider(), _field("Blocking reason", v));
|
|
}
|
|
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
|
els.push(tf("ID", entity.id));
|
|
}
|
|
|
|
else if (type === "ep") {
|
|
if (entity.ep_id) els.push(tf("EP ID", entity.ep_id));
|
|
els.push(
|
|
bf("Status", entity.status, _STATUS_STYLE),
|
|
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
|
tf("Type", entity.ep_type ?? "—"),
|
|
tf("Domain", entity.domain ?? "—"),
|
|
tf("Workstream", entity.workstream_title ?? "—"),
|
|
tf("Location", entity.location ?? "—"),
|
|
);
|
|
if (entity.description) {
|
|
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
|
}
|
|
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
|
els.push(tf("UUID", entity.id));
|
|
}
|
|
|
|
else if (type === "td") {
|
|
if (entity.td_id) els.push(tf("TD ID", entity.td_id));
|
|
els.push(
|
|
bf("Severity", entity.severity, _PRIORITY_STYLE),
|
|
bf("Status", entity.status, _STATUS_STYLE),
|
|
tf("Type", entity.debt_type ?? "—"),
|
|
tf("Domain", entity.domain ?? "—"),
|
|
tf("Workstream", entity.workstream_title ?? "—"),
|
|
tf("Location", entity.location ?? "—"),
|
|
);
|
|
if (entity.description) {
|
|
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
|
}
|
|
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
|
els.push(tf("UUID", entity.id));
|
|
}
|
|
|
|
return els;
|
|
}
|
|
|
|
/* ── Public API ──────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Open a detail modal for the given entity.
|
|
* @param {object} entity - The entity data object (workstream, task, ep, or td)
|
|
* @param {string} type - One of: "workstream" | "task" | "ep" | "td"
|
|
*/
|
|
export function openEntityModal(entity, type) {
|
|
_ensureStyles();
|
|
document.getElementById("_entity-modal-root")?.remove();
|
|
|
|
const title = entity.title ?? "(no title)";
|
|
|
|
const root = document.createElement("div");
|
|
root.id = "_entity-modal-root";
|
|
root.className = "entity-modal";
|
|
root.setAttribute("role", "dialog");
|
|
root.setAttribute("aria-modal", "true");
|
|
root.setAttribute("aria-label", title);
|
|
|
|
const box = document.createElement("div");
|
|
box.className = "entity-modal-box";
|
|
|
|
// Header
|
|
const header = document.createElement("div");
|
|
header.className = "entity-modal-header";
|
|
const titleEl = document.createElement("div");
|
|
titleEl.className = "entity-modal-title";
|
|
titleEl.textContent = title;
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.className = "entity-modal-close";
|
|
closeBtn.textContent = "✕ close";
|
|
closeBtn.setAttribute("aria-label", "Close detail panel");
|
|
header.append(titleEl, closeBtn);
|
|
|
|
// Body
|
|
const body = document.createElement("div");
|
|
body.className = "entity-modal-body";
|
|
for (const el of _buildBody(entity, type)) body.append(el);
|
|
|
|
box.append(header, body);
|
|
root.append(box);
|
|
document.body.append(root);
|
|
|
|
const close = () => root.remove();
|
|
closeBtn.addEventListener("click", close);
|
|
root.addEventListener("click", e => { if (e.target === root) close(); });
|
|
const onKey = e => {
|
|
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
}
|
|
|
|
/**
|
|
* Build an interactive entity table element.
|
|
*
|
|
* @param {Array} rows - Array of entity objects to display
|
|
* @param {Array} columns - [{label, key, cls?}] — columns in order
|
|
* @param {Function} onRowClick - Called with the full entity when a row is clicked
|
|
* @returns {HTMLTableElement}
|
|
*/
|
|
export function buildEntityTable(rows, columns, onRowClick) {
|
|
_ensureStyles();
|
|
|
|
const table = document.createElement("table");
|
|
table.className = "entity-table";
|
|
|
|
// Header
|
|
const thead = document.createElement("thead");
|
|
const htr = document.createElement("tr");
|
|
for (const col of columns) {
|
|
const th = document.createElement("th");
|
|
th.textContent = col.label;
|
|
if (col.cls) th.className = col.cls;
|
|
htr.append(th);
|
|
}
|
|
thead.append(htr);
|
|
table.append(thead);
|
|
|
|
// Body
|
|
const tbody = document.createElement("tbody");
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "entity-row";
|
|
tr.addEventListener("click", () => onRowClick(row));
|
|
|
|
for (const col of columns) {
|
|
const td = document.createElement("td");
|
|
const raw = col.key ? row[col.key] : null;
|
|
const rendered = col.render ? col.render(row) : (raw ?? "—");
|
|
let textStr = "";
|
|
if (rendered instanceof Node) {
|
|
td.append(rendered);
|
|
textStr = td.textContent ?? "";
|
|
} else {
|
|
textStr = String(rendered ?? "—");
|
|
td.textContent = textStr;
|
|
}
|
|
if (col.cls) td.className = col.cls;
|
|
// Native tooltip so full value shows on hover (skip placeholder "—")
|
|
if (!(rendered instanceof Node) && textStr && textStr !== "—") td.title = textStr;
|
|
tr.append(td);
|
|
}
|
|
tbody.append(tr);
|
|
}
|
|
table.append(tbody);
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "entity-table-wrap";
|
|
wrap.append(table);
|
|
return wrap;
|
|
}
|