mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 13:56:36 -10:00
feat: enrich kanban cards, editable drawer, debounced live updates (fixes #170)
Gap 1: card DTO now carries tenant, created_at, comment_count and child
progress; cardElement renders dense chrome (checkbox, status dot, id,
priority badge, tenant chip, assignee, age, comment count, progress pill).
Gap 2: new POST /api/kanban/tasks/{id}/comments route plus add_comment on
the store protocol; drawer is now editable (title/assignee/priority),
markdown-rendered (XSS-safe), shows a comment thread with Enter-to-submit
and run/event history rows.
Gap 3: SSE consumer refetches on a 300ms debounce, the open drawer
live-refreshes on events for its task, and destructive moves/delete prompt
for confirmation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
0b62e7dcec
commit
9726411010
4 files changed
+483
-48
No files matched your search
@@ -46,10 +46,33 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _child_progress(task: Dict[str, Any]) -> Optional[Dict[str, int]]:
|
||||
"""Derive done/total subtask progress from task.children or meta.
|
||||
|
||||
Accepts either a list of child dicts under "children" (each with a status)
|
||||
or an explicit {"done": int, "total": int} under meta["progress"].
|
||||
Returns None when there is nothing to report.
|
||||
"""
|
||||
meta = task.get("meta") or {}
|
||||
explicit = meta.get("progress")
|
||||
if isinstance(explicit, dict):
|
||||
total = explicit.get("total")
|
||||
done = explicit.get("done")
|
||||
if isinstance(total, int) and total > 0 and isinstance(done, int):
|
||||
return {"done": max(0, min(done, total)), "total": total}
|
||||
children = task.get("children")
|
||||
if isinstance(children, list) and children:
|
||||
total = len(children)
|
||||
done = sum(1 for c in children if isinstance(c, dict) and c.get("status") == "done")
|
||||
return {"done": done, "total": total}
|
||||
return None
|
||||
|
||||
|
||||
def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalise task dict to board card component."""
|
||||
title = (task.get("title") or task.get("id") or "Task")[:120]
|
||||
footer_parts = [p for p in (task.get("assignee"), task.get("priority")) if p]
|
||||
comments = task.get("comments") or []
|
||||
return {
|
||||
"id": task.get("id"),
|
||||
"title": title,
|
||||
@@ -57,8 +80,12 @@ def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"status": task.get("status"),
|
||||
"assignee": task.get("assignee"),
|
||||
"priority": task.get("priority"),
|
||||
"tenant": task.get("tenant"),
|
||||
"created_at": task.get("created_at"),
|
||||
"comment_count": len(comments),
|
||||
"progress": _child_progress(task),
|
||||
"body": task.get("body"),
|
||||
"comments": task.get("comments") or [],
|
||||
"comments": comments,
|
||||
"meta": task.get("meta") or {},
|
||||
}
|
||||
|
||||
@@ -90,6 +117,9 @@ class KanbanStoreProtocol(ABC):
|
||||
@abstractmethod
|
||||
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]: ...
|
||||
|
||||
@@ -174,6 +204,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
|
||||
"priority": data.get("priority"),
|
||||
"tenant": data.get("tenant"),
|
||||
"board": data.get("board", "default"),
|
||||
"children": data.get("children") or [],
|
||||
"comments": [],
|
||||
"meta": data.get("meta") or {},
|
||||
"created_at": _now_iso(),
|
||||
@@ -188,7 +219,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
for key in ("title", "body", "assignee", "priority", "tenant", "meta"):
|
||||
for key in ("title", "body", "assignee", "priority", "tenant", "meta", "children"):
|
||||
if key in data:
|
||||
task[key] = data[key]
|
||||
if "status" in data and data["status"] in VALID_STATUSES:
|
||||
@@ -209,6 +240,24 @@ class SimpleKanbanStore(KanbanStoreProtocol):
|
||||
self._emit("task_moved", task, {"from_status": old, "to_status": status})
|
||||
return dict(task)
|
||||
|
||||
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
text = str((comment or {}).get("text") or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
entry = {
|
||||
"id": f"c_{uuid.uuid4().hex[:8]}",
|
||||
"author": (comment or {}).get("author") or "human",
|
||||
"text": text,
|
||||
"created_at": _now_iso(),
|
||||
}
|
||||
task.setdefault("comments", []).append(entry)
|
||||
task["updated_at"] = _now_iso()
|
||||
self._emit("task_commented", task, {"comment_id": entry["id"]})
|
||||
return dict(task)
|
||||
|
||||
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
|
||||
if status not in VALID_STATUSES:
|
||||
return {"updated": 0, "errors": ["invalid status"]}
|
||||
@@ -264,6 +313,9 @@ class InjectedKanbanStore(KanbanStoreProtocol):
|
||||
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]:
|
||||
return self._call("move_task", task_id, status)
|
||||
|
||||
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return self._call("add_comment", task_id, comment)
|
||||
|
||||
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
|
||||
return self._call("bulk_update", task_ids, status)
|
||||
|
||||
@@ -331,6 +383,7 @@ class KanbanFeature(BaseFeatureProtocol):
|
||||
Route("/api/kanban/tasks/{task_id}", self._patch, methods=["PATCH"]),
|
||||
Route("/api/kanban/tasks/{task_id}", self._delete, methods=["DELETE"]),
|
||||
Route("/api/kanban/tasks/{task_id}/move", self._move, methods=["POST"]),
|
||||
Route("/api/kanban/tasks/{task_id}/comments", self._add_comment, methods=["POST"]),
|
||||
Route("/api/kanban/events", self._events, methods=["GET"]),
|
||||
Route("/api/kanban/health", self._health, methods=["GET"]),
|
||||
Route("/api/kanban/boards", self._boards, methods=["GET"]),
|
||||
@@ -380,6 +433,16 @@ class KanbanFeature(BaseFeatureProtocol):
|
||||
return JSONResponse({"error": "not found or invalid status"}, status_code=404)
|
||||
return JSONResponse(task)
|
||||
|
||||
async def _add_comment(self, request: Request) -> JSONResponse:
|
||||
store = get_kanban_store()
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict) or not str(body.get("text") or "").strip():
|
||||
return JSONResponse({"error": "text required"}, status_code=400)
|
||||
task = store.add_comment(request.path_params["task_id"], body)
|
||||
if not task:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse(task)
|
||||
|
||||
async def _bulk(self, request: Request) -> JSONResponse:
|
||||
store = get_kanban_store()
|
||||
body = await request.json()
|
||||
|
||||
@@ -35,6 +35,60 @@ function el(tag, attrs, children) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
if (!value) return '';
|
||||
const then = typeof value === 'number' ? value * 1000 : Date.parse(value);
|
||||
if (!then || Number.isNaN(then)) return '';
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
const safe = escapeHtml(text);
|
||||
const lines = safe.split(/\r?\n/);
|
||||
const html = [];
|
||||
let inCode = false;
|
||||
let listOpen = false;
|
||||
const closeList = () => { if (listOpen) { html.push('</ul>'); listOpen = false; } };
|
||||
lines.forEach((raw) => {
|
||||
if (/^```/.test(raw.trim())) {
|
||||
if (inCode) { html.push('</code></pre>'); inCode = false; }
|
||||
else { closeList(); html.push('<pre><code>'); inCode = true; }
|
||||
return;
|
||||
}
|
||||
if (inCode) { html.push(raw + '\n'); return; }
|
||||
let line = raw;
|
||||
line = line.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
line = line.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
line = line.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) { closeList(); html.push(`<h${heading[1].length}>${heading[2]}</h${heading[1].length}>`); return; }
|
||||
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (bullet) { if (!listOpen) { html.push('<ul>'); listOpen = true; } html.push(`<li>${bullet[1]}</li>`); return; }
|
||||
closeList();
|
||||
if (line.trim() === '') html.push('<br/>');
|
||||
else html.push(`<p>${line}</p>`);
|
||||
});
|
||||
closeList();
|
||||
if (inCode) html.push('</code></pre>');
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function cardElement(card, { selectable, selected, onSelect, onOpen }) {
|
||||
const wrap = el('div', {
|
||||
className: `aiui-board-card${selected ? ' aiui-board-card-selected' : ''}`,
|
||||
@@ -42,18 +96,48 @@ function cardElement(card, { selectable, selected, onSelect, onOpen }) {
|
||||
'data-task-id': card.id || '',
|
||||
'data-status': card.status || '',
|
||||
});
|
||||
const inner = el('div', { className: 'db-card' });
|
||||
if (card.title) inner.appendChild(el('div', { className: 'db-card-title', text: card.title }));
|
||||
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
|
||||
const footer = card.footer || card.assignee || '';
|
||||
if (footer) inner.appendChild(el('div', { className: 'db-card-footer', text: footer }));
|
||||
const inner = el('div', { className: 'db-card aiui-board-card-dense' });
|
||||
|
||||
const head = el('div', { className: 'aiui-board-card-head' });
|
||||
if (selectable && onSelect) {
|
||||
const box = el('input', {
|
||||
type: 'checkbox',
|
||||
className: 'aiui-board-card-check',
|
||||
'aria-label': 'Select task',
|
||||
});
|
||||
box.checked = !!selected;
|
||||
box.addEventListener('click', (e) => { e.stopPropagation(); onSelect(card.id, true); });
|
||||
head.appendChild(box);
|
||||
}
|
||||
if (card.id) head.appendChild(el('span', { className: 'aiui-board-card-id', text: card.id }));
|
||||
const colour = STATUS_COLOURS[card.status];
|
||||
if (card.status) {
|
||||
const dot = el('span', { className: 'aiui-board-status-dot', text: '●', title: card.status });
|
||||
if (colour) dot.style.color = colour;
|
||||
head.appendChild(dot);
|
||||
}
|
||||
if (card.priority) {
|
||||
inner.appendChild(el('span', {
|
||||
className: 'aiui-board-badge',
|
||||
text: String(card.priority),
|
||||
style: 'font-size:10px;margin-left:6px;opacity:0.8',
|
||||
head.appendChild(el('span', { className: 'aiui-board-badge aiui-board-priority', text: String(card.priority) }));
|
||||
}
|
||||
inner.appendChild(head);
|
||||
|
||||
if (card.title) inner.appendChild(el('div', { className: 'db-card-title aiui-board-card-name', text: card.title }));
|
||||
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
|
||||
|
||||
const foot = el('div', { className: 'aiui-board-card-foot' });
|
||||
if (card.tenant) foot.appendChild(el('span', { className: 'aiui-board-chip aiui-board-tenant', text: String(card.tenant) }));
|
||||
if (card.assignee) foot.appendChild(el('span', { className: 'aiui-board-chip', text: String(card.assignee) }));
|
||||
const age = relativeTime(card.created_at);
|
||||
if (age) foot.appendChild(el('span', { className: 'aiui-board-card-age', text: age }));
|
||||
if (card.comment_count) foot.appendChild(el('span', { className: 'aiui-board-card-count', text: `\uD83D\uDCAC ${card.comment_count}` }));
|
||||
if (card.progress && card.progress.total) {
|
||||
foot.appendChild(el('span', {
|
||||
className: 'aiui-board-progress',
|
||||
text: `${card.progress.done}/${card.progress.total}`,
|
||||
}));
|
||||
}
|
||||
if (foot.childNodes.length) inner.appendChild(foot);
|
||||
|
||||
wrap.appendChild(inner);
|
||||
wrap.addEventListener('click', (e) => {
|
||||
if (e.shiftKey && onSelect) onSelect(card.id, true);
|
||||
@@ -148,7 +232,9 @@ function renderBoardDOM(container, data, opts) {
|
||||
e.preventDefault();
|
||||
trash.classList.remove('aiui-board-drop-target');
|
||||
const taskId = e.dataTransfer.getData('text/plain');
|
||||
if (taskId && opts.onDelete) await opts.onDelete(taskId);
|
||||
if (!taskId || !opts.onDelete) return;
|
||||
if (typeof confirm === 'function' && !confirm('Permanently delete this task?')) return;
|
||||
await opts.onDelete(taskId);
|
||||
});
|
||||
wrap.appendChild(trash);
|
||||
}
|
||||
@@ -156,46 +242,170 @@ function renderBoardDOM(container, data, opts) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openTaskDrawer(card, { apiBase, onClose, onRefresh }) {
|
||||
function historyRows(card) {
|
||||
const meta = card.meta || {};
|
||||
const rows = [];
|
||||
const events = meta.events || meta.history;
|
||||
if (Array.isArray(events)) {
|
||||
events.forEach((ev) => {
|
||||
if (typeof ev === 'string') rows.push({ text: ev });
|
||||
else if (ev && typeof ev === 'object') {
|
||||
const label = ev.type || ev.event || ev.status || 'event';
|
||||
const when = relativeTime(ev.ts || ev.created_at || ev.time);
|
||||
rows.push({ text: ev.text || ev.message || label, when });
|
||||
}
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function openTaskDrawer(card, { apiBase, onClose, onRefresh, fetchTask }) {
|
||||
const overlay = el('div', { className: 'aiui-board-drawer-overlay' });
|
||||
const drawer = el('div', { className: 'aiui-board-drawer' });
|
||||
const close = () => overlay.remove();
|
||||
drawer.appendChild(el('button', {
|
||||
className: 'aiui-board-drawer-close',
|
||||
text: '×',
|
||||
onclick: close,
|
||||
}));
|
||||
drawer.appendChild(el('h3', { text: card.title || 'Task' }));
|
||||
if (card.body) drawer.appendChild(el('pre', { className: 'aiui-board-drawer-body', text: card.body }));
|
||||
else drawer.appendChild(el('p', { className: 'aiui-board-drawer-meta', text: `ID: ${card.id || '—'}` }));
|
||||
const meta = el('div', { className: 'aiui-board-drawer-meta' });
|
||||
meta.textContent = [card.status, card.assignee, card.priority].filter(Boolean).join(' · ');
|
||||
drawer.appendChild(meta);
|
||||
let current = card;
|
||||
let closed = false;
|
||||
const close = () => { closed = true; overlay.remove(); if (onClose) onClose(); };
|
||||
|
||||
if (apiBase && card.id) {
|
||||
const actions = el('div', { className: 'aiui-board-drawer-actions' });
|
||||
['blocked', 'ready', 'done'].forEach((st) => {
|
||||
actions.appendChild(el('button', {
|
||||
className: 'db-btn db-btn-sm',
|
||||
text: st,
|
||||
onclick: async () => {
|
||||
await fetch(`${apiBase}/tasks/${card.id}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: st }),
|
||||
});
|
||||
if (onRefresh) await onRefresh();
|
||||
close();
|
||||
if (onClose) onClose();
|
||||
},
|
||||
}));
|
||||
const editable = !!(apiBase && card.id);
|
||||
|
||||
async function patch(data) {
|
||||
if (!editable) return;
|
||||
await fetch(`${apiBase}/tasks/${current.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
drawer.appendChild(actions);
|
||||
if (onRefresh) await onRefresh();
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (closed) return;
|
||||
if (fetchTask && current.id) {
|
||||
try {
|
||||
const fresh = await fetchTask(current.id);
|
||||
if (fresh) current = { ...current, ...fresh };
|
||||
} catch (_) { /* keep stale */ }
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function field(label, value, onSave) {
|
||||
const row = el('div', { className: 'aiui-board-drawer-field' });
|
||||
row.appendChild(el('label', { className: 'aiui-board-drawer-label', text: label }));
|
||||
const input = el('input', { className: 'aiui-board-drawer-input', value: value == null ? '' : String(value) });
|
||||
if (!editable) input.disabled = true;
|
||||
input.addEventListener('change', () => onSave(input.value));
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); onSave(input.value); } });
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
function render() {
|
||||
drawer.innerHTML = '';
|
||||
drawer.appendChild(el('button', { className: 'aiui-board-drawer-close', text: '×', onclick: close }));
|
||||
|
||||
drawer.appendChild(field('Title', current.title, (v) => patch({ title: v })));
|
||||
|
||||
if (current.body != null) {
|
||||
const bodyEl = el('div', { className: 'aiui-board-drawer-body aiui-board-markdown' });
|
||||
bodyEl.innerHTML = renderMarkdown(current.body);
|
||||
drawer.appendChild(bodyEl);
|
||||
}
|
||||
|
||||
const grid = el('div', { className: 'aiui-board-drawer-grid' });
|
||||
grid.appendChild(field('Assignee', current.assignee, (v) => patch({ assignee: v })));
|
||||
grid.appendChild(field('Priority', current.priority, (v) => patch({ priority: v })));
|
||||
drawer.appendChild(grid);
|
||||
|
||||
const meta = el('div', { className: 'aiui-board-drawer-meta' });
|
||||
meta.textContent = [current.status, current.tenant, relativeTime(current.created_at)].filter(Boolean).join(' · ');
|
||||
drawer.appendChild(meta);
|
||||
|
||||
const hist = historyRows(current);
|
||||
if (hist.length) {
|
||||
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'History' }));
|
||||
const histWrap = el('div', { className: 'aiui-board-history' });
|
||||
hist.forEach((r) => {
|
||||
const row = el('div', { className: 'aiui-board-history-row' });
|
||||
row.appendChild(el('span', { className: 'aiui-board-history-text', text: r.text }));
|
||||
if (r.when) row.appendChild(el('span', { className: 'aiui-board-history-when', text: r.when }));
|
||||
histWrap.appendChild(row);
|
||||
});
|
||||
drawer.appendChild(histWrap);
|
||||
}
|
||||
|
||||
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'Comments' }));
|
||||
const thread = el('div', { className: 'aiui-board-comments' });
|
||||
(current.comments || []).forEach((c) => {
|
||||
const item = el('div', { className: 'aiui-board-comment' });
|
||||
const head = el('div', { className: 'aiui-board-comment-head' });
|
||||
head.appendChild(el('span', { className: 'aiui-board-comment-author', text: c.author || 'human' }));
|
||||
const when = relativeTime(c.created_at);
|
||||
if (when) head.appendChild(el('span', { className: 'aiui-board-comment-when', text: when }));
|
||||
item.appendChild(head);
|
||||
const text = el('div', { className: 'aiui-board-comment-text aiui-board-markdown' });
|
||||
text.innerHTML = renderMarkdown(typeof c === 'string' ? c : (c.text || ''));
|
||||
item.appendChild(text);
|
||||
thread.appendChild(item);
|
||||
});
|
||||
drawer.appendChild(thread);
|
||||
|
||||
if (editable) {
|
||||
const box = el('textarea', {
|
||||
className: 'aiui-board-comment-input',
|
||||
placeholder: 'Add a comment… (Enter to submit, Shift+Enter for newline)',
|
||||
rows: '2',
|
||||
});
|
||||
const submit = async () => {
|
||||
const value = box.value.trim();
|
||||
if (!value) return;
|
||||
box.value = '';
|
||||
await fetch(`${apiBase}/tasks/${current.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: value }),
|
||||
});
|
||||
if (onRefresh) await onRefresh();
|
||||
await reload();
|
||||
};
|
||||
box.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
});
|
||||
drawer.appendChild(box);
|
||||
|
||||
const actions = el('div', { className: 'aiui-board-drawer-actions' });
|
||||
['blocked', 'ready', 'done'].forEach((st) => {
|
||||
actions.appendChild(el('button', {
|
||||
className: 'db-btn db-btn-sm',
|
||||
text: st,
|
||||
onclick: async () => {
|
||||
if ((st === 'done' || st === 'blocked') && !confirm(`Move task to "${st}"?`)) return;
|
||||
await fetch(`${apiBase}/tasks/${current.id}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: st }),
|
||||
});
|
||||
if (onRefresh) await onRefresh();
|
||||
close();
|
||||
},
|
||||
}));
|
||||
});
|
||||
drawer.appendChild(actions);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
overlay.appendChild(drawer);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
return {
|
||||
id: card.id,
|
||||
isOpen: () => !closed,
|
||||
refresh: reload,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,8 +415,11 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
let destroyed = false;
|
||||
let pollTimer = null;
|
||||
let eventSource = null;
|
||||
let debounceTimer = null;
|
||||
let openDrawer = null;
|
||||
const selected = new Set();
|
||||
let lastData = null;
|
||||
const debounceMs = opts.debounceMs != null ? opts.debounceMs : 300;
|
||||
|
||||
const toolbar = el('div', { className: 'aiui-board-toolbar' });
|
||||
root.appendChild(toolbar);
|
||||
@@ -231,6 +444,14 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const drawerApiBase = (opts.drawerApiBase || apiBase).replace(/\/$/, '');
|
||||
|
||||
async function fetchTask(taskId) {
|
||||
const res = await fetch(`${drawerApiBase}/tasks/${taskId}`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function defaultBulk(status) {
|
||||
if (!selected.size) return;
|
||||
const res = await fetch(`${apiBase}/tasks/bulk`, {
|
||||
@@ -303,7 +524,15 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
showTrash: opts.showTrash !== false,
|
||||
selected,
|
||||
onSelect: opts.interactive !== false ? onSelect : null,
|
||||
onOpen: (card) => openTaskDrawer(card, { apiBase: opts.drawerApiBase || apiBase, onRefresh: refresh }),
|
||||
onOpen: (card) => {
|
||||
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
|
||||
openDrawer = openTaskDrawer(card, {
|
||||
apiBase: drawerApiBase,
|
||||
onRefresh: refresh,
|
||||
fetchTask,
|
||||
onClose: () => { openDrawer = null; },
|
||||
});
|
||||
},
|
||||
onMove: async (taskId, status) => {
|
||||
await (opts.onMove || defaultMove)(taskId, status);
|
||||
await refresh();
|
||||
@@ -319,12 +548,28 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedRefresh() {
|
||||
if (!debounceMs) { refresh(); return; }
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => { debounceTimer = null; refresh(); }, debounceMs);
|
||||
}
|
||||
|
||||
function handleEvent(raw) {
|
||||
let ev = null;
|
||||
try { ev = raw ? JSON.parse(raw) : null; } catch (_) { ev = null; }
|
||||
if (openDrawer && openDrawer.isOpen() && ev && ev.task_id && ev.task_id === openDrawer.id) {
|
||||
openDrawer.refresh();
|
||||
}
|
||||
debouncedRefresh();
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!opts.eventsUrl || typeof EventSource === 'undefined') return;
|
||||
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(opts.currentBoard || 'default')}`;
|
||||
const activeBoard = opts.boardState?.id || opts.currentBoard || 'default';
|
||||
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(activeBoard)}`;
|
||||
try {
|
||||
eventSource = new EventSource(url);
|
||||
eventSource.onmessage = () => refresh();
|
||||
eventSource.onmessage = (e) => handleEvent(e && e.data);
|
||||
} catch (_) { /* polling fallback */ }
|
||||
}
|
||||
|
||||
@@ -337,7 +582,9 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (eventSource) eventSource.close();
|
||||
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
@@ -348,4 +595,4 @@ export function createBoard(root, opts = {}) {
|
||||
return createInteractiveBoard(root, { ...opts, interactive: false, showTrash: false });
|
||||
}
|
||||
|
||||
export { renderBoardDOM, el };
|
||||
export { renderBoardDOM, el, renderMarkdown, relativeTime, escapeHtml };
|
||||
@@ -408,6 +408,37 @@ const DASHBOARD_STYLE = `
|
||||
.aiui-board-drawer-body { white-space: pre-wrap; font-size: 13px; color: var(--db-text-dim); }
|
||||
.aiui-board-drawer-meta { font-size: 12px; color: var(--db-text-dim); margin: 8px 0; }
|
||||
.aiui-board-drawer-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
||||
.aiui-board-card-dense { padding: 10px 12px; }
|
||||
.aiui-board-card-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.aiui-board-card-check { margin: 0; cursor: pointer; }
|
||||
.aiui-board-card-id { font-family: var(--db-mono, monospace); font-size: 10px; color: var(--db-text-dim); }
|
||||
.aiui-board-status-dot { font-size: 10px; line-height: 1; }
|
||||
.aiui-board-priority { margin-left: auto; font-size: 10px; opacity: 0.85; }
|
||||
.aiui-board-card-name { font-size: 13px; text-transform: none; letter-spacing: 0; color: var(--db-text); margin: 0 0 6px; font-weight: 500; }
|
||||
.aiui-board-card-foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--db-text-dim); }
|
||||
.aiui-board-chip { padding: 1px 6px; border-radius: 999px; background: rgba(255,255,255,0.06); font-size: 10px; }
|
||||
.aiui-board-tenant { background: var(--db-accent-glow); color: var(--db-accent); }
|
||||
.aiui-board-card-age { opacity: 0.8; }
|
||||
.aiui-board-card-count { opacity: 0.9; }
|
||||
.aiui-board-progress { margin-left: auto; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--db-border); font-size: 10px; }
|
||||
.aiui-board-drawer-field { margin: 8px 0; }
|
||||
.aiui-board-drawer-label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin-bottom: 4px; }
|
||||
.aiui-board-drawer-input { width: 100%; padding: 6px 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; }
|
||||
.aiui-board-drawer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.aiui-board-drawer-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin: 16px 0 8px; }
|
||||
.aiui-board-markdown { font-size: 13px; line-height: 1.5; color: var(--db-text-dim); }
|
||||
.aiui-board-markdown p { margin: 0 0 6px; }
|
||||
.aiui-board-markdown pre { background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); padding: 8px; overflow-x: auto; }
|
||||
.aiui-board-markdown code { font-family: var(--db-mono, monospace); font-size: 12px; }
|
||||
.aiui-board-history { display: flex; flex-direction: column; gap: 4px; }
|
||||
.aiui-board-history-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--db-text-dim); }
|
||||
.aiui-board-history-when { opacity: 0.7; white-space: nowrap; }
|
||||
.aiui-board-comments { display: flex; flex-direction: column; gap: 10px; }
|
||||
.aiui-board-comment { border-left: 2px solid var(--db-border); padding-left: 10px; }
|
||||
.aiui-board-comment-head { display: flex; gap: 8px; align-items: baseline; }
|
||||
.aiui-board-comment-author { font-size: 12px; font-weight: 600; color: var(--db-text); }
|
||||
.aiui-board-comment-when { font-size: 11px; color: var(--db-text-dim); }
|
||||
.aiui-board-comment-input { width: 100%; margin-top: 10px; padding: 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; resize: vertical; }
|
||||
.aiui-board-page-header { margin-bottom: 8px; }
|
||||
.db-slot-host { min-height: 0; }
|
||||
.db-sidebar-footer { margin-top: auto; padding: 10px 14px; border-top: 1px solid var(--db-border); font-size: 11px; color: var(--db-text-dim); }
|
||||
|
||||
@@ -5,7 +5,12 @@ from __future__ import annotations
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.backends import clear_backends, set_backend
|
||||
from praisonaiui.features.kanban import get_kanban_store, reset_kanban_store
|
||||
from praisonaiui.features.kanban import (
|
||||
_child_progress,
|
||||
_task_card,
|
||||
get_kanban_store,
|
||||
reset_kanban_store,
|
||||
)
|
||||
from praisonaiui.server import create_app, reset_state
|
||||
|
||||
|
||||
@@ -81,6 +86,9 @@ def test_kanban_uses_injected_store():
|
||||
def move_task(self, task_id, status):
|
||||
return None
|
||||
|
||||
def add_comment(self, task_id, comment):
|
||||
return None
|
||||
|
||||
def bulk_update(self, task_ids, status):
|
||||
return {"updated": 0}
|
||||
|
||||
@@ -105,6 +113,92 @@ def test_kanban_uses_injected_store():
|
||||
reset_kanban_store()
|
||||
|
||||
|
||||
def test_task_card_enriched_dto():
|
||||
card = _task_card(
|
||||
{
|
||||
"id": "t1",
|
||||
"title": "Refactor auth",
|
||||
"status": "running",
|
||||
"assignee": "coder",
|
||||
"priority": "P2",
|
||||
"tenant": "acme",
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"comments": [{"text": "a"}, {"text": "b"}],
|
||||
}
|
||||
)
|
||||
assert card["tenant"] == "acme"
|
||||
assert card["created_at"] == "2024-01-01T00:00:00+00:00"
|
||||
assert card["comment_count"] == 2
|
||||
assert card["progress"] is None
|
||||
|
||||
|
||||
def test_child_progress_from_children_and_meta():
|
||||
children = {"children": [{"status": "done"}, {"status": "ready"}, {"status": "done"}]}
|
||||
assert _child_progress(children) == {"done": 2, "total": 3}
|
||||
|
||||
explicit = {"meta": {"progress": {"done": 1, "total": 4}}}
|
||||
assert _child_progress(explicit) == {"done": 1, "total": 4}
|
||||
|
||||
assert _child_progress({}) is None
|
||||
assert _child_progress({"meta": {"progress": {"done": 0, "total": 0}}}) is None
|
||||
|
||||
|
||||
def test_card_in_board_carries_progress_and_counts():
|
||||
clear_backends()
|
||||
client = _client()
|
||||
store = get_kanban_store()
|
||||
store.create_task(
|
||||
{
|
||||
"id": "px",
|
||||
"title": "Parent",
|
||||
"status": "todo",
|
||||
"tenant": "acme",
|
||||
"children": [{"status": "done"}, {"status": "todo"}],
|
||||
}
|
||||
)
|
||||
board = client.get("/api/kanban/board").json()
|
||||
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
|
||||
card = next(c for c in todo_cards if c["id"] == "px")
|
||||
assert card["tenant"] == "acme"
|
||||
assert card["progress"] == {"done": 1, "total": 2}
|
||||
assert card["comment_count"] == 0
|
||||
clear_backends()
|
||||
|
||||
|
||||
def test_kanban_add_comment():
|
||||
clear_backends()
|
||||
client = _client()
|
||||
create = client.post("/api/kanban/tasks", json={"title": "Has comments", "status": "todo"})
|
||||
task_id = create.json()["id"]
|
||||
|
||||
resp = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": "please unblock"})
|
||||
assert resp.status_code == 200
|
||||
task = resp.json()
|
||||
assert len(task["comments"]) == 1
|
||||
assert task["comments"][0]["text"] == "please unblock"
|
||||
assert task["comments"][0]["author"] == "human"
|
||||
|
||||
board = client.get("/api/kanban/board").json()
|
||||
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
|
||||
card = next(c for c in todo_cards if c["id"] == task_id)
|
||||
assert card["comment_count"] == 1
|
||||
clear_backends()
|
||||
|
||||
|
||||
def test_kanban_add_comment_validation():
|
||||
clear_backends()
|
||||
client = _client()
|
||||
create = client.post("/api/kanban/tasks", json={"title": "T", "status": "todo"})
|
||||
task_id = create.json()["id"]
|
||||
|
||||
empty = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": " "})
|
||||
assert empty.status_code == 400
|
||||
|
||||
missing = client.post("/api/kanban/tasks/nope/comments", json={"text": "hi"})
|
||||
assert missing.status_code == 404
|
||||
clear_backends()
|
||||
|
||||
|
||||
def test_jobs_board_endpoint():
|
||||
clear_backends()
|
||||
client = _client()
|
||||
|
||||
Reference in new issue
Block a user