diff --git a/static/i18n.js b/static/i18n.js index 1419cebb..dd4d1c17 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -530,6 +530,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Title', + kanban_description: 'Description', + kanban_description_placeholder: 'Optional — what needs to happen, acceptance criteria, links', + kanban_status: 'Status', + kanban_assignee: 'Assignee', + kanban_assignee_placeholder: 'Optional — leave blank for any worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Optional — project or team slug', + kanban_priority: 'Priority', + kanban_priority_hint: 'Higher numbers run first. Default 0.', + kanban_title_required: 'Title is required.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -1570,6 +1581,17 @@ const LOCALES = { kanban_no_comments: 'コメントなし', kanban_no_events: 'イベントなし', kanban_no_runs: '実行なし', + kanban_title: 'タイトル', + kanban_description: '説明', + kanban_description_placeholder: '任意 — 何をすべきか、受け入れ基準、リンク', + kanban_status: 'ステータス', + kanban_assignee: '担当者', + kanban_assignee_placeholder: '任意 — 空欄で任意のワーカーに', + kanban_tenant: 'テナント', + kanban_tenant_placeholder: '任意 — プロジェクトまたはチームのスラッグ', + kanban_priority: '優先度', + kanban_priority_hint: '値が大きいほど優先されます。既定値は 0。', + kanban_title_required: 'タイトルは必須です。', kanban_new_task: '新規タスク', kanban_add_comment: 'コメント追加', kanban_only_mine: '自分のみ', @@ -2443,6 +2465,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Заголовок', + kanban_description: 'Описание', + kanban_description_placeholder: 'Необязательно — что нужно сделать, критерии приёмки, ссылки', + kanban_status: 'Статус', + kanban_assignee: 'Исполнитель', + kanban_assignee_placeholder: 'Необязательно — оставьте пустым для любого исполнителя', + kanban_tenant: 'Арендатор', + kanban_tenant_placeholder: 'Необязательно — слаг проекта или команды', + kanban_priority: 'Приоритет', + kanban_priority_hint: 'Большие числа выполняются первыми. По умолчанию 0.', + kanban_title_required: 'Заголовок обязателен.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -3417,6 +3450,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descripción', + kanban_description_placeholder: 'Opcional — qué hay que hacer, criterios de aceptación, enlaces', + kanban_status: 'Estado', + kanban_assignee: 'Responsable', + kanban_assignee_placeholder: 'Opcional — déjalo en blanco para cualquier trabajador', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug del proyecto o equipo', + kanban_priority: 'Prioridad', + kanban_priority_hint: 'Los números más altos se ejecutan primero. Predeterminado: 0.', + kanban_title_required: 'El título es obligatorio.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -4379,6 +4423,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Titel', + kanban_description: 'Beschreibung', + kanban_description_placeholder: 'Optional — was zu tun ist, Akzeptanzkriterien, Links', + kanban_status: 'Status', + kanban_assignee: 'Zugewiesen an', + kanban_assignee_placeholder: 'Optional — leer lassen für beliebigen Worker', + kanban_tenant: 'Mandant', + kanban_tenant_placeholder: 'Optional — Projekt- oder Team-Slug', + kanban_priority: 'Priorität', + kanban_priority_hint: 'Höhere Zahlen laufen zuerst. Standard: 0.', + kanban_title_required: 'Titel ist erforderlich.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -5362,6 +5417,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '标题', + kanban_description: '描述', + kanban_description_placeholder: '可选 — 需要做什么、验收标准、链接', + kanban_status: '状态', + kanban_assignee: '负责人', + kanban_assignee_placeholder: '可选 — 留空表示任意工作器', + kanban_tenant: '租户', + kanban_tenant_placeholder: '可选 — 项目或团队标识', + kanban_priority: '优先级', + kanban_priority_hint: '数值越高越先执行,默认为 0。', + kanban_title_required: '标题为必填项。', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -7403,6 +7469,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descrição', + kanban_description_placeholder: 'Opcional — o que precisa ser feito, critérios de aceitação, links', + kanban_status: 'Status', + kanban_assignee: 'Responsável', + kanban_assignee_placeholder: 'Opcional — deixe em branco para qualquer worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug do projeto ou equipe', + kanban_priority: 'Prioridade', + kanban_priority_hint: 'Números maiores executam primeiro. Padrão: 0.', + kanban_title_required: 'O título é obrigatório.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -8341,6 +8418,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '제목', + kanban_description: '설명', + kanban_description_placeholder: '선택 — 해야 할 일, 수락 기준, 링크', + kanban_status: '상태', + kanban_assignee: '담당자', + kanban_assignee_placeholder: '선택 — 비워두면 누구나 가능', + kanban_tenant: '테넌트', + kanban_tenant_placeholder: '선택 — 프로젝트 또는 팀 슬러그', + kanban_priority: '우선순위', + kanban_priority_hint: '높은 숫자가 먼저 실행됩니다. 기본값: 0.', + kanban_title_required: '제목은 필수입니다.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', diff --git a/static/index.html b/static/index.html index 319901e1..fdd014d2 100644 --- a/static/index.html +++ b/static/index.html @@ -146,7 +146,7 @@
Kanban
- +
@@ -1262,5 +1262,48 @@ + + diff --git a/static/panels.js b/static/panels.js index fd10c820..8b9dfcc2 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1566,7 +1566,14 @@ function _kanbanLinksHtml(links){ async function createKanbanTask(){ const input = document.getElementById('kanbanNewTaskTitle'); const title = input ? input.value.trim() : ''; - if (!title) return; + if (!title) { + // Empty inline input (or a click on the panel-head "+" via openKanbanCreate) + // — open the full create-task modal so the user has somewhere obvious to + // type and configure the task. Mirrors the cron / skills pattern of routing + // header "+" clicks through to a clearly-modal create surface. + openKanbanCreate(); + return; + } try { const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { method: 'POST', @@ -1578,6 +1585,125 @@ async function createKanbanTask(){ } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task modal (panel-head "+" button entry point). +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . Submit hits the +// existing /api/kanban/tasks POST endpoint (which already accepts title, body, +// assignee, tenant, priority, status — see api/kanban_bridge.py:306). +// ──────────────────────────────────────────────────────────────────────────── + +function openKanbanCreate(){ + // Make sure the user is on the kanban panel so the resulting board reload is + // visible behind the modal. Without this the modal would still work but the + // user could lose context on which panel they triggered it from. + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + // Reset all form fields to defaults. + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + if (titleEl) titleEl.value = ''; + if (bodyEl) bodyEl.value = ''; + if (statusEl) statusEl.value = 'triage'; + if (assigneeEl) assigneeEl.value = ''; + if (tenantEl) tenantEl.value = ''; + if (priorityEl) priorityEl.value = '0'; + if (errEl) errEl.textContent = ''; + if (submitBtn) submitBtn.disabled = false; + // Populate datalists from the currently-loaded board so the user sees the + // assignees / tenants the dispatcher already knows about. + const assignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) ? _kanbanBoard.assignees : []; + const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : []; + const aList = document.getElementById('kanbanTaskModalAssigneeList'); + const tList = document.getElementById('kanbanTaskModalTenantList'); + if (aList) aList.innerHTML = assignees.map(v => ``).join(''); + if (tList) tList.innerHTML = tenants.map(v => ``).join(''); + modal.hidden = false; + // Auto-focus title field on open. setTimeout to wait for paint. + setTimeout(() => { if (titleEl) titleEl.focus(); }, 50); + // Bind ESC to close, and Enter on simple inputs to submit. + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +function closeKanbanTaskModal(){ + const modal = document.getElementById('kanbanTaskModal'); + if (modal) modal.hidden = true; + document.removeEventListener('keydown', _kanbanTaskModalKey); +} + +function _kanbanTaskModalKey(ev){ + if (ev.key === 'Escape') { + ev.preventDefault(); + closeKanbanTaskModal(); + return; + } + if (ev.key === 'Enter' && !ev.shiftKey) { + // Enter submits except when the focus is in the description textarea + // (where Enter should insert a newline). + const target = ev.target; + if (target && target.tagName === 'TEXTAREA') return; + const modal = document.getElementById('kanbanTaskModal'); + if (modal && !modal.hidden) { + ev.preventDefault(); + submitKanbanTaskModal(); + } + } +} + +async function submitKanbanTaskModal(){ + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + const title = titleEl ? titleEl.value.trim() : ''; + if (!title) { + if (errEl) errEl.textContent = t('kanban_title_required') || 'Title is required.'; + if (titleEl) titleEl.focus(); + return; + } + // Build payload — only include fields the user actually filled in so the + // backend can apply its own defaults rather than us forcing empty strings. + const payload = {title}; + if (bodyEl && bodyEl.value.trim()) payload.body = bodyEl.value; + if (statusEl && statusEl.value) payload.status = statusEl.value; + if (assigneeEl && assigneeEl.value.trim()) payload.assignee = assigneeEl.value.trim(); + if (tenantEl && tenantEl.value.trim()) payload.tenant = tenantEl.value.trim(); + if (priorityEl && priorityEl.value !== '' && priorityEl.value !== '0') { + const n = parseInt(priorityEl.value, 10); + if (!Number.isNaN(n)) payload.priority = n; + } + if (submitBtn) submitBtn.disabled = true; + if (errEl) errEl.textContent = ''; + try { + const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { + method: 'POST', + body: JSON.stringify(payload), + }); + closeKanbanTaskModal(); + await loadKanban(true); + if (created && created.task && created.task.id) { + await loadKanbanTask(created.task.id); + } + } catch(e) { + if (errEl) errEl.textContent = (e.message || String(e)); + if (submitBtn) submitBtn.disabled = false; + } +} + async function updateKanbanTask(taskId, patch){ if (!taskId || !patch) return; try { diff --git a/static/style.css b/static/style.css index 9476040c..f2118237 100644 --- a/static/style.css +++ b/static/style.css @@ -3504,7 +3504,15 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-modal-row-inline{display:flex;gap:10px;} .kanban-modal-row-inline > *{flex:1;min-width:0;} .kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;} -.kanban-modal-error{color:var(--danger);font-size:11px;margin-top:6px;min-height:14px;} +.kanban-modal-error{ + color:var(--danger,var(--error,#f87171)); + font-size:12px;font-weight:500;margin-top:8px;min-height:14px; +} +.kanban-modal-error:not(:empty){ + padding:8px 10px;border-radius:8px; + background:color-mix(in srgb,var(--danger,var(--error,#f87171)) 12%,transparent); + border:1px solid color-mix(in srgb,var(--danger,var(--error,#f87171)) 35%,transparent); +} .kanban-empty{padding:12px;color:var(--muted);font-size:12px;text-align:center;border:1px dashed var(--border);border-radius:8px;} .kanban-new-task-row{display:flex;gap:6px;align-items:center;} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 359aca22..8afb09d4 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -80,6 +80,108 @@ def test_kanban_write_mvp_has_native_controls_and_api_calls(): assert "kanban-comment-form" in PANELS +def test_kanban_new_task_header_button_opens_modal(): + """Regression: the panel-head '+' button must open a real `.kanban-modal-overlay` + create-task modal (matching the existing create-board modal pattern in the same + file) — NOT silently return when the inline #kanbanNewTaskTitle input is empty. + + Previously the header button was wired straight to createKanbanTask(), which + silently early-exits on empty title — the button looked completely dead. + Now the header button calls openKanbanCreate(), which opens the + #kanbanTaskModal overlay with title / description / status / priority / + assignee / tenant fields. + """ + # 1. Header "+" button is wired to openKanbanCreate(), NOT createKanbanTask(). + assert 'id="kanbanNewTaskBtn"' in INDEX + btn_html = INDEX[INDEX.find('id="kanbanNewTaskBtn"'):] + btn_html = btn_html[: btn_html.find("") + len("")] + assert 'onclick="openKanbanCreate()"' in btn_html, ( + "Panel-head '+' button must call openKanbanCreate() (modal), not " + "createKanbanTask() directly (which silently returns on empty title)." + ) + + # 2. The create-task modal markup exists in index.html, with all the field + # ids the JS / API contract expects. + assert 'id="kanbanTaskModal"' in INDEX + assert 'class="kanban-modal-overlay"' in INDEX[INDEX.find('id="kanbanTaskModal"') - 80:] + for field_id in ( + "kanbanTaskModalTitleInput", + "kanbanTaskModalBody", + "kanbanTaskModalStatus", + "kanbanTaskModalPriority", + "kanbanTaskModalAssignee", + "kanbanTaskModalTenant", + "kanbanTaskModalError", + "kanbanTaskModalSubmit", + ): + assert f'id="{field_id}"' in INDEX, f"create-task modal missing #{field_id}" + + # 3. Modal closes via Cancel button AND backdrop click AND ESC. + assert 'onclick="closeKanbanTaskModal()"' in INDEX + assert "if(event.target===this)closeKanbanTaskModal()" in INDEX + + # 4. openKanbanCreate() unhides the modal, focuses the title field, populates + # assignee/tenant datalists, binds keydown listener. + assert "function openKanbanCreate()" in PANELS + open_fn = re.search( + r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert open_fn, "openKanbanCreate() not found" + body = open_fn.group(1) + assert "modal.hidden = false" in body + assert "kanbanTaskModalAssigneeList" in body + assert "kanbanTaskModalTenantList" in body + assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached + + # 5. closeKanbanTaskModal() hides the modal and unbinds the listener. + assert "function closeKanbanTaskModal()" in PANELS + close_fn = re.search( + r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert close_fn and "modal.hidden = true" in close_fn.group(1) + assert "removeEventListener('keydown', _kanbanTaskModalKey)" in close_fn.group(1) + + # 6. ESC closes; Enter submits (except in the description textarea). + assert "function _kanbanTaskModalKey" in PANELS + key_fn = re.search( + r"function _kanbanTaskModalKey\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert key_fn + key_body = key_fn.group(1) + assert "ev.key === 'Escape'" in key_body + assert "ev.key === 'Enter'" in key_body + assert "TEXTAREA" in key_body # textarea exception preserved + + # 7. submitKanbanTaskModal() POSTs to /api/kanban/tasks, closes modal, + # reloads board, opens detail. + assert "async function submitKanbanTaskModal()" in PANELS + submit_fn = re.search( + r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert submit_fn, "submitKanbanTaskModal() not found" + submit_body = submit_fn.group(1) + assert "api('/api/kanban/tasks'" in submit_body + assert "method: 'POST'" in submit_body + assert "JSON.stringify(payload)" in submit_body + assert "closeKanbanTaskModal()" in submit_body + assert "loadKanban(true)" in submit_body + assert "loadKanbanTask" in submit_body + + # 8. Inline quick-add still works for power-users — typing a title + Enter + # creates immediately. Empty submit falls through to the modal. + assert "async function createKanbanTask()" in PANELS + quick_add = re.search( + r"async function createKanbanTask\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert quick_add + qa_body = quick_add.group(1) + assert "openKanbanCreate()" in qa_body, ( + "Empty inline-input submit must open the modal, not silently return." + ) + assert "api('/api/kanban/tasks'" in qa_body + + + def test_kanban_board_has_native_css_classes(): for selector in ( ".kanban-board",