diff --git a/static/i18n.js b/static/i18n.js index 2ed2a098..a0960172 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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: '통계', diff --git a/static/panels.js b/static/panels.js index 40e57e30..eb715864 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 || `
${esc(t(emptyKey))}
`; + return `
+

${esc(title)}

+ ${content} +
`; +} + +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 `
+
${esc(body)}
+
${esc([by, at].filter(Boolean).join(' · '))}
+
`; +} + +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 `
+
${esc(kind)}
+ ${payload ? `
${esc(payload)}
` : ''} +
${esc(at)}
+
`; +} + +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 `
+
${esc(label)}${status ? ` · ${esc(status)}` : ''}
+ ${detail ? `
${esc(_kanbanFormatDetailValue(detail))}
` : ''} +
${esc([started, finished].filter(Boolean).join(' → '))}
+
`; +} + +function _kanbanLinksHtml(links){ + const parents = (links && links.parents) || []; + const children = (links && links.children) || []; + if (!parents.length && !children.length) return ''; + const item = id => `${esc(id)}`; + return ``; +} + +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 `
${esc(title)}
+
${esc(body)}
+ ${meta.length ? `
${esc(meta.join(' · '))}
` : ''} +
+ ${_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')} +
`; +} + 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 = `
${esc(title)}
-
${esc(body)}
- ${meta.length ? `
${esc(meta.join(' · '))}
` : ''}`; + preview.innerHTML = _kanbanRenderTaskDetail(data); } showToast(`${t('kanban_task')}: ${title}`); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } diff --git a/static/style.css b/static/style.css index 37e2aed1..dbdbe2ff 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index b4bd6924..fb0e4b43 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -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}"