mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
feat: expand Kanban task detail view
This commit is contained in:
@@ -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
@@ -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'); }
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user