feat: expand Kanban task detail view

This commit is contained in:
Manfred
2026-05-04 22:21:58 +02:00
committed by test
parent 88bf62b6e4
commit fafc2ab4f1
4 changed files with 188 additions and 5 deletions
+72
View File
@@ -470,6 +470,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Insights',
@@ -1394,6 +1403,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'ToDo',
tab_insights: 'インサイト',
@@ -2160,6 +2178,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Список дел',
tab_insights: 'Аналитика',
@@ -3020,6 +3047,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Analíticas',
@@ -3868,6 +3904,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Statistiken',
@@ -4737,6 +4782,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: '待办',
tab_insights: '统计',
@@ -6607,6 +6661,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Estatísticas',
@@ -7437,6 +7500,15 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
kanban_parents: 'Parents',
kanban_children: 'Children',
kanban_runs_count: 'Runs ({0})',
kanban_no_comments: 'No comments',
kanban_no_events: 'No events',
kanban_no_runs: 'No runs',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: '통계',
+82 -5
View File
@@ -988,13 +988,93 @@ async function loadKanban(animate){
function filterKanban(){ _kanbanRenderBoard(); }
function _kanbanFormatDetailValue(value){
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'object') {
try { return JSON.stringify(value, null, 2); } catch(e) { return String(value); }
}
return String(value);
}
function _kanbanDetailSection(cls, title, inner, emptyKey){
const content = inner || `<div class="kanban-detail-empty">${esc(t(emptyKey))}</div>`;
return `<section class="kanban-detail-section ${cls}">
<h3>${esc(title)}</h3>
${content}
</section>`;
}
function _kanbanCommentHtml(comment){
const body = comment.body || comment.text || comment.content || '';
const by = comment.author || comment.created_by || comment.actor || '';
const at = comment.created_at || comment.ts || '';
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
</div>`;
}
function _kanbanEventHtml(event){
const kind = event.kind || event.type || 'event';
const at = event.created_at || event.ts || '';
const payload = _kanbanFormatDetailValue(event.payload || event.data || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(kind)}</div>
${payload ? `<pre class="kanban-detail-pre">${esc(payload)}</pre>` : ''}
<div class="kanban-detail-row-meta">${esc(at)}</div>
</div>`;
}
function _kanbanRunHtml(run){
const status = run.status || run.state || run.result || '';
const label = run.run_id || run.id || run.worker || t('kanban_task');
const started = run.started_at || run.created_at || '';
const finished = run.finished_at || run.completed_at || '';
const detail = run.error || run.summary || run.log_tail || '';
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(label)}${status ? ` · ${esc(status)}` : ''}</div>
${detail ? `<pre class="kanban-detail-pre">${esc(_kanbanFormatDetailValue(detail))}</pre>` : ''}
<div class="kanban-detail-row-meta">${esc([started, finished].filter(Boolean).join(' → '))}</div>
</div>`;
}
function _kanbanLinksHtml(links){
const parents = (links && links.parents) || [];
const children = (links && links.children) || [];
if (!parents.length && !children.length) return '';
const item = id => `<code>${esc(id)}</code>`;
return `<div class="kanban-detail-links-grid">
<div><strong>${esc(t('kanban_parents'))}</strong><div>${parents.length ? parents.map(item).join(' ') : esc(t('kanban_empty'))}</div></div>
<div><strong>${esc(t('kanban_children'))}</strong><div>${children.length ? children.map(item).join(' ') : esc(t('kanban_empty'))}</div></div>
</div>`;
}
function _kanbanRenderTaskDetail(data){
const task = data.task || {};
const title = _kanbanTaskTitle(task);
const body = _kanbanTaskBody(task) || t('kanban_no_description');
const meta = _kanbanTaskMeta(task);
const comments = data.comments || [];
const events = data.events || [];
const links = data.links || {};
const runs = data.runs || [];
return `<div class="kanban-task-preview-title">${esc(title)}</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-detail-grid">
${_kanbanDetailSection('kanban-detail-comments', String(t('kanban_comments_count')).replace('{0}', comments.length), comments.map(_kanbanCommentHtml).join(''), 'kanban_no_comments')}
${_kanbanDetailSection('kanban-detail-events', String(t('kanban_events_count')).replace('{0}', events.length), events.map(_kanbanEventHtml).join(''), 'kanban_no_events')}
${_kanbanDetailSection('kanban-detail-links', t('kanban_links'), _kanbanLinksHtml(links), 'kanban_empty')}
${_kanbanDetailSection('kanban-detail-runs', String(t('kanban_runs_count')).replace('{0}', runs.length), runs.map(_kanbanRunHtml).join(''), 'kanban_no_runs')}
</div>`;
}
async function loadKanbanTask(taskId){
if (!taskId) return;
try {
const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId));
const task = data.task || {};
const title = _kanbanTaskTitle(task);
const body = _kanbanTaskBody(task) || t('kanban_no_description');
const board = $('kanbanBoard');
if (board) {
board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected'));
@@ -1002,11 +1082,8 @@ async function loadKanbanTask(taskId){
}
const preview = $('kanbanTaskPreview');
if (preview) {
const meta = _kanbanTaskMeta(task);
preview.style.display = '';
preview.innerHTML = `<div class="kanban-task-preview-title">${esc(title)}</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}`;
preview.innerHTML = _kanbanRenderTaskDetail(data);
}
showToast(`${t('kanban_task')}: ${title}`);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
+12
View File
@@ -3145,3 +3145,15 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-task-preview{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel);}
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:6px;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-top:12px;}
.kanban-detail-section{border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:10px;min-width:0;}
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
.kanban-detail-empty{font-size:12px;color:var(--muted);}
.kanban-detail-links-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:12px;color:var(--muted);}
.kanban-detail-links-grid code{display:inline-block;margin:4px 4px 0 0;padding:2px 5px;border-radius:5px;background:var(--input-bg);border:1px solid var(--border);color:var(--text);}
+22
View File
@@ -49,6 +49,21 @@ def test_kanban_frontend_uses_read_only_relative_api_endpoints():
assert "classList.add('selected')" in PANELS
def test_kanban_task_detail_renders_read_only_sections():
assert "function _kanbanRenderTaskDetail" in PANELS
for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
assert payload_key in PANELS
for section_class in (
"kanban-detail-section",
"kanban-detail-comments",
"kanban-detail-events",
"kanban-detail-links",
"kanban-detail-runs",
):
assert section_class in PANELS
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
def test_kanban_board_has_native_css_classes():
for selector in (
".kanban-board",
@@ -77,6 +92,13 @@ def test_kanban_i18n_keys_exist_in_every_locale_block():
"kanban_unavailable",
"kanban_read_only",
"kanban_empty",
"kanban_comments_count",
"kanban_events_count",
"kanban_links",
"kanban_runs_count",
"kanban_no_comments",
"kanban_no_events",
"kanban_no_runs",
]
missing = [
f"{locale}:{key}"