diff --git a/CHANGELOG.md b/CHANGELOG.md index 165446580..680917401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **The Kanban "New task" modal now exposes skills, max runtime, and parent dependencies.** The WebUI task creator reaches parity with the CLI Kanban fields: a Skills input (comma-separated, e.g. `python,git`), a Max runtime (seconds) field (empty = unlimited), and a Parent dependencies (task ID) field. Max-runtime input is validated (`/^[1-9]\d*$/`) with an inline localized error so a junk value can't silently become 1 or unlimited; all fields are XSS-safe. Thanks @rodboev. (#4881, #4470) + - **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814) - **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520) diff --git a/static/i18n.js b/static/i18n.js index c884c5e53..d2922d68b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -961,6 +961,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -2609,6 +2616,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -4248,6 +4262,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -5589,6 +5610,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -7165,6 +7193,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -8719,6 +8754,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -10328,6 +10370,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -12194,6 +12243,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -13721,6 +13777,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -15258,6 +15321,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -16915,6 +16985,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Répertoire', kanban_workspace_path_required: 'Le chemin de l\'espace de travail est requis pour les espaces de travail non-brouillon.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Ajouter une dépendance', kanban_remove_dependency: 'Supprimer la dépendance', kanban_dependency_placeholder: 'ID de la tâche à lier', @@ -18515,6 +18592,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Directory', kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Add dependency', kanban_remove_dependency: 'Remove dependency', kanban_dependency_placeholder: 'Task ID to link', @@ -20169,6 +20253,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Katalog', kanban_workspace_path_required: 'Ścieżka do obszaru roboczego jest wymagana dla obszarów innych niż tymczasowe (scratch).', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Dodaj zależność', kanban_remove_dependency: 'Usuń zależność', kanban_dependency_placeholder: 'Identyfikator zadania do powiązania', @@ -21653,6 +21744,13 @@ const LOCALES = { kanban_workspace_worktree: 'Worktree', kanban_workspace_dir: 'Thư mục', kanban_workspace_path_required: 'Workspace không phải tạm thời cần có đường dẫn.', + kanban_skills: 'Skills', + kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git', + kanban_max_runtime_seconds: 'Max runtime (seconds)', + kanban_max_runtime_hint: 'Leave empty for unlimited.', + kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.', + kanban_parents_modal: 'Parent dependencies', + kanban_parents_placeholder: 'Parent task ID', kanban_add_dependency: 'Thêm phụ thuộc', kanban_remove_dependency: 'Xóa phụ thuộc', kanban_dependency_placeholder: 'ID tác vụ cần liên kết', diff --git a/static/index.html b/static/index.html index 249e0cd50..5f66310c0 100644 --- a/static/index.html +++ b/static/index.html @@ -1748,6 +1748,7 @@
+
Higher numbers run first. Default 0.
@@ -1772,7 +1773,22 @@
+
+ + +
+
+ + +
Leave empty for unlimited.
+
+
+ + +
diff --git a/static/panels.js b/static/panels.js index 2bb7e2635..25db47f77 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2784,6 +2784,8 @@ function openKanbanCreate(){ } }); _kanbanPopulateTenantDatalist(); + _kanbanPopulateWorkspacePathDatalist(); + _kanbanPopulateParentsDatalist(); modal.hidden = false; if (_kanbanTaskModalFocusCleanup) { _kanbanTaskModalFocusCleanup(); @@ -2874,6 +2876,9 @@ function _kanbanResetTaskModalFields(values){ // .value before the options exist would silently fail. set('kanbanTaskModalTenant', v.tenant || ''); set('kanbanTaskModalPriority', v.priority != null ? v.priority : 0); + set('kanbanTaskModalSkills', Array.isArray(v.skills) ? v.skills.join(', ') : (v.skills || '')); + set('kanbanTaskModalMaxRuntimeSeconds', v.max_runtime_seconds != null ? v.max_runtime_seconds : ''); + set('kanbanTaskModalParents', ''); const errEl = document.getElementById('kanbanTaskModalError'); if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; } const submitBtn = document.getElementById('kanbanTaskModalSubmit'); @@ -2883,20 +2888,25 @@ function _kanbanResetTaskModalFields(values){ function _kanbanSetTaskModalLabels(mode){ const titleH = document.getElementById('kanbanTaskModalTitle'); const submitBtn = document.getElementById('kanbanTaskModalSubmit'); - const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind'); - const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath'); if (mode === 'edit') { if (titleH) titleH.textContent = t('kanban_edit_task') || 'Edit task'; if (submitBtn) submitBtn.textContent = t('save') || 'Save'; - // Disable workspace fields during edit since they are not handled by the backend - if (workspaceKindEl) workspaceKindEl.disabled = true; - if (workspacePathEl) workspacePathEl.disabled = true; } else { if (titleH) titleH.textContent = t('kanban_new_task') || 'New task'; if (submitBtn) submitBtn.textContent = t('create') || 'Create'; - // Enable workspace fields during create - if (workspaceKindEl) workspaceKindEl.disabled = false; - if (workspacePathEl) workspacePathEl.disabled = false; + } + // Workspace and new backend fields are create-only; backend patch doesn't handle them. + const createOnlyIds = [ + 'kanbanTaskModalWorkspaceKind', + 'kanbanTaskModalWorkspacePath', + 'kanbanTaskModalSkills', + 'kanbanTaskModalMaxRuntimeSeconds', + 'kanbanTaskModalParents', + ]; + const disabled = mode === 'edit'; + for (const id of createOnlyIds) { + const el = document.getElementById(id); + if (el) el.disabled = disabled; } } @@ -2919,6 +2929,27 @@ function _kanbanPopulateTenantDatalist(){ if (tList) tList.innerHTML = tenants.map(v => ``).join(''); } +function _kanbanPopulateWorkspacePathDatalist(){ + const cols = (_kanbanBoard && _kanbanBoard.columns) || []; + const seen = new Set(); + const opts = []; + for (const col of cols) { + for (const task of (col.tasks || [])) { + const path = task && task.workspace_path; + if (!path || seen.has(path)) continue; + seen.add(path); + opts.push(``); + } + } + const list = document.getElementById('kanbanTaskModalWorkspacePathList'); + if (list) list.innerHTML = opts.join(''); +} + +function _kanbanPopulateParentsDatalist(){ + const list = document.getElementById('kanbanTaskModalParentsList'); + if (list) list.innerHTML = _kanbanLinkableTaskOptions(null); +} + function _trapModalFocus(modalEl){ if (!modalEl) return () => {}; const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])'; @@ -3007,6 +3038,9 @@ async function submitKanbanTaskModal(){ const priorityEl = document.getElementById('kanbanTaskModalPriority'); const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind'); const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath'); + const skillsEl = document.getElementById('kanbanTaskModalSkills'); + const maxRuntimeEl = document.getElementById('kanbanTaskModalMaxRuntimeSeconds'); + const parentsEl = document.getElementById('kanbanTaskModalParents'); const errEl = document.getElementById('kanbanTaskModalError'); const submitBtn = document.getElementById('kanbanTaskModalSubmit'); const title = titleEl ? titleEl.value.trim() : ''; @@ -3035,6 +3069,9 @@ async function submitKanbanTaskModal(){ const statusVal = statusEl ? statusEl.value : ''; const priorityRaw = priorityEl ? priorityEl.value : ''; const workspacePathVal = workspacePathEl ? workspacePathEl.value.trim() : ''; + const skillsRaw = skillsEl ? skillsEl.value.trim() : ''; + const maxRuntimeRaw = maxRuntimeEl ? maxRuntimeEl.value.trim() : ''; + const parentsRaw = parentsEl ? parentsEl.value.trim() : ''; if (isEdit) { payload.body = bodyVal; payload.assignee = assigneeVal || null; @@ -3061,6 +3098,19 @@ async function submitKanbanTaskModal(){ } payload.workspace_kind = workspaceKind; if (workspacePathVal) payload.workspace_path = workspacePathVal; + if (skillsRaw) { + payload.skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean); + } + if (maxRuntimeRaw) { + if (!/^[1-9]\d*$/.test(maxRuntimeRaw)) { + if (errEl) errEl.textContent = t('kanban_max_runtime_invalid') + || 'Max runtime must be a whole number of seconds greater than 0.'; + if (maxRuntimeEl) maxRuntimeEl.focus(); + return; + } + payload.max_runtime_seconds = Number(maxRuntimeRaw); + } + if (parentsRaw) payload.parents = [parentsRaw]; } // Soft warning: a Ready task with the explicit "Unassigned" option will sit // forever because the dispatcher skips unassigned rows (kanban_db.py:3567). diff --git a/tests/test_issue3797_kanban_cli_parity.py b/tests/test_issue3797_kanban_cli_parity.py index ff87df7c5..ca1fd9f21 100644 --- a/tests/test_issue3797_kanban_cli_parity.py +++ b/tests/test_issue3797_kanban_cli_parity.py @@ -90,8 +90,9 @@ class TestWorkspacePathValidation: """Modal must disable workspace fields when editing to prevent confusion.""" # The _kanbanSetTaskModalLabels function must disable fields during edit assert "_kanbanSetTaskModalLabels" in PANELS_JS - assert "workspaceKindEl.disabled = true" in PANELS_JS - assert "workspacePathEl.disabled = true" in PANELS_JS + assert "kanbanTaskModalWorkspaceKind" in PANELS_JS + assert "kanbanTaskModalWorkspacePath" in PANELS_JS + assert "el.disabled = disabled" in PANELS_JS class TestDependencyControls: diff --git a/tests/test_issue4470_kanban_task_editor_fields.py b/tests/test_issue4470_kanban_task_editor_fields.py new file mode 100644 index 000000000..66ba14fdd --- /dev/null +++ b/tests/test_issue4470_kanban_task_editor_fields.py @@ -0,0 +1,259 @@ +"""Source-inspection and behavioral tests for Kanban modal fields added in #4470.""" +from __future__ import annotations +import json +import re +import shutil +import subprocess +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[1] +INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +KANBAN_BRIDGE = (ROOT / "api" / "kanban_bridge.py").read_text(encoding="utf-8") + +NODE = shutil.which("node") + + +def test_modal_html_exposes_new_fields(): + for field_id in ( + "kanbanTaskModalSkills", + "kanbanTaskModalMaxRuntimeSeconds", + "kanbanTaskModalParents", + "kanbanTaskModalParentsList", + "kanbanTaskModalWorkspacePathList", + "kanbanTaskModalPriorityHint", + ): + assert f'id="{field_id}"' in INDEX_HTML, f"Missing: {field_id}" + assert 'list="kanbanTaskModalParentsList"' in INDEX_HTML + assert 'list="kanbanTaskModalWorkspacePathList"' in INDEX_HTML + assert 'data-i18n="kanban_priority_hint"' in INDEX_HTML + + +def _submit_body(): + m = re.search( + r"async function submitKanbanTaskModal\(\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "submitKanbanTaskModal() not found" + return m.group(1) + + +def test_submit_reads_and_sends_skills_as_array(): + body = _submit_body() + assert "kanbanTaskModalSkills" in body + assert "payload.skills" in body + assert ".split(',')" in body or ".split(', ')" in body + + +def test_submit_reads_and_sends_max_runtime_as_integer(): + body = _submit_body() + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "payload.max_runtime_seconds" in body + assert "/^[1-9]\\d*$/" in body + assert "Number(maxRuntimeRaw)" in body + assert "kanban_max_runtime_invalid" in body + + +def test_submit_reads_and_sends_parents_as_list(): + body = _submit_body() + assert "kanbanTaskModalParents" in body + assert "payload.parents" in body + assert "[parentsRaw]" in body or "payload.parents = [" in body + + +def test_new_fields_only_in_create_branch(): + body = _submit_body() + edit_match = re.search(r"if \(isEdit\) \{(.*?)\} else \{", body, re.DOTALL) + assert edit_match, "isEdit...else block not found" + edit_branch = edit_match.group(1) + assert "payload.skills" not in edit_branch + assert "payload.max_runtime_seconds" not in edit_branch + assert "payload.parents" not in edit_branch + + +def test_reset_handler_covers_new_fields(): + m = re.search( + r"function _kanbanResetTaskModalFields\([^)]*\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "_kanbanResetTaskModalFields() not found" + body = m.group(1) + assert "kanbanTaskModalSkills" in body + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "kanbanTaskModalParents" in body + + +def test_labels_handler_disables_new_fields_in_edit(): + m = re.search( + r"function _kanbanSetTaskModalLabels\([^)]*\)\s*\{(.*?)\n\}", + PANELS_JS, re.DOTALL, + ) + assert m, "_kanbanSetTaskModalLabels() not found" + body = m.group(1) + assert "kanbanTaskModalSkills" in body + assert "kanbanTaskModalMaxRuntimeSeconds" in body + assert "kanbanTaskModalParents" in body + assert "disabled" in body + + +def test_datalist_helpers_exist_and_are_called(): + fn_wp = "function _kanbanPopulateWorkspacePathDatalist" + fn_par = "function _kanbanPopulateParentsDatalist" + assert fn_wp in PANELS_JS + assert "kanbanTaskModalWorkspacePathList" in PANELS_JS + # Check that workspace_path appears inside the function definition body + fn_start = PANELS_JS.find(fn_wp) + assert "workspace_path" in PANELS_JS[fn_start:fn_start + 500], \ + "workspace_path not found in _kanbanPopulateWorkspacePathDatalist body" + assert fn_par in PANELS_JS + assert "_kanbanLinkableTaskOptions(null)" in PANELS_JS + + m = re.search(r"function openKanbanCreate\(\)\s*\{(.*?)\n\}", PANELS_JS, re.DOTALL) + assert m, "openKanbanCreate() not found" + body = m.group(1) + assert "_kanbanPopulateWorkspacePathDatalist()" in body + assert "_kanbanPopulateParentsDatalist()" in body + + +def test_i18n_new_keys_in_all_locales(): + required = [ + "kanban_priority_hint", + "kanban_skills", + "kanban_skills_placeholder", + "kanban_max_runtime_seconds", + "kanban_max_runtime_hint", + "kanban_max_runtime_invalid", + "kanban_parents_modal", + "kanban_parents_placeholder", + ] + for key in required: + count = I18N_JS.count(f"{key}:") + assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)" + + +def test_backend_already_accepts_new_fields(): + assert "skills=body.get(" in KANBAN_BRIDGE + assert "max_runtime_seconds=body.get(" in KANBAN_BRIDGE + assert "parents=body.get(" in KANBAN_BRIDGE + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_submit_payload_behavioral_and_validation(): + """Node.js execution: extract submitKanbanTaskModal and verify payload construction.""" + fn_start = PANELS_JS.find("async function submitKanbanTaskModal(") + assert fn_start >= 0, "submitKanbanTaskModal not found" + brace_start = PANELS_JS.index("{", fn_start) + depth = 0 + fn_end = brace_start + for i in range(brace_start, len(PANELS_JS)): + if PANELS_JS[i] == "{": + depth += 1 + elif PANELS_JS[i] == "}": + depth -= 1 + if depth == 0: + fn_end = i + 1 + break + fn_body = PANELS_JS[fn_start:fn_end] + + harness = ( + "// Stub minimal DOM\n" + "let lastFocused = null;\n" + "function field(id, value = '') {\n" + " return { value, disabled: false, focus() { lastFocused = id; } };\n" + "}\n" + "const elements = {\n" + " kanbanTaskModalTitleInput: field('kanbanTaskModalTitleInput', 'Test Task'),\n" + " kanbanTaskModalBody: field('kanbanTaskModalBody', ''),\n" + " kanbanTaskModalStatus: field('kanbanTaskModalStatus', 'triage'),\n" + " kanbanTaskModalAssignee: field('kanbanTaskModalAssignee', 'agent1'),\n" + " kanbanTaskModalTenant: field('kanbanTaskModalTenant', ''),\n" + " kanbanTaskModalPriority: field('kanbanTaskModalPriority', '0'),\n" + " kanbanTaskModalWorkspaceKind: field('kanbanTaskModalWorkspaceKind', 'scratch'),\n" + " kanbanTaskModalWorkspacePath: field('kanbanTaskModalWorkspacePath', ''),\n" + " kanbanTaskModalSkills: field('kanbanTaskModalSkills', 'python, git'),\n" + " kanbanTaskModalMaxRuntimeSeconds: field('kanbanTaskModalMaxRuntimeSeconds', '120'),\n" + " kanbanTaskModalParents: field('kanbanTaskModalParents', 't_abc'),\n" + " kanbanTaskModalError: { textContent: '', dataset: {} },\n" + " kanbanTaskModalSubmit: { disabled: false },\n" + "};\n" + "global.document = { getElementById: (id) => elements[id] || null };\n" + "function t(k) { return k; }\n" + "let capturedPayload = null;\n" + "async function api(url, opts) {\n" + " capturedPayload = opts ? JSON.parse(opts.body) : null;\n" + " return { task: { id: 't_new' } };\n" + "}\n" + "async function loadKanban() {}\n" + "async function loadKanbanTask() {}\n" + "function _kanbanBoardQuery() { return ''; }\n" + "function closeKanbanTaskModal() {}\n" + "let _kanbanTaskModalMode = 'create';\n" + "let _kanbanTaskModalEditingId = null;\n" + "let _kanbanTaskModalInitialDisplayedStatus = null;\n" + + fn_body + "\n" + "function resetForCase(maxRuntimeValue) {\n" + " capturedPayload = null;\n" + " lastFocused = null;\n" + " elements.kanbanTaskModalError.textContent = '';\n" + " delete elements.kanbanTaskModalError.dataset.warningShown;\n" + " elements.kanbanTaskModalSubmit.disabled = false;\n" + " elements.kanbanTaskModalTitleInput.value = 'Test Task';\n" + " elements.kanbanTaskModalBody.value = '';\n" + " elements.kanbanTaskModalStatus.value = 'triage';\n" + " elements.kanbanTaskModalAssignee.value = 'agent1';\n" + " elements.kanbanTaskModalTenant.value = '';\n" + " elements.kanbanTaskModalPriority.value = '0';\n" + " elements.kanbanTaskModalWorkspaceKind.value = 'scratch';\n" + " elements.kanbanTaskModalWorkspacePath.value = '';\n" + " elements.kanbanTaskModalSkills.value = 'python, git';\n" + " elements.kanbanTaskModalMaxRuntimeSeconds.value = maxRuntimeValue;\n" + " elements.kanbanTaskModalParents.value = 't_abc';\n" + "}\n" + "async function runCase(maxRuntimeValue) {\n" + " resetForCase(maxRuntimeValue);\n" + " await submitKanbanTaskModal();\n" + " return {\n" + " payload: capturedPayload,\n" + " error: elements.kanbanTaskModalError.textContent,\n" + " focused: lastFocused,\n" + " submitDisabled: elements.kanbanTaskModalSubmit.disabled,\n" + " };\n" + "}\n" + "(async () => {\n" + " const results = [];\n" + " for (const value of ['120', '', '0', '-5', '1.5', '1e2', 'abc']) {\n" + " results.push(await runCase(value));\n" + " }\n" + " console.log(JSON.stringify(results));\n" + "})().catch(e => { process.stderr.write(String(e)); process.exit(1); });\n" + ) + + result = subprocess.run( + [NODE, "-e", harness], + capture_output=True, text=True, timeout=15, + ) + assert result.returncode == 0, f"Node error: {result.stderr}" + output = result.stdout.strip() + assert output, f"No payload captured. stderr: {result.stderr}" + results = json.loads(output) + + valid, empty, zero, negative, decimal, scientific, alpha = results + valid_payload = valid["payload"] + assert valid_payload.get("skills") == ["python", "git"], f"skills wrong: {valid_payload.get('skills')}" + assert valid_payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {valid_payload.get('max_runtime_seconds')}" + assert valid_payload.get("parents") == ["t_abc"], f"parents wrong: {valid_payload.get('parents')}" + assert valid["error"] == "" + assert valid["focused"] is None + + empty_payload = empty["payload"] + assert "max_runtime_seconds" not in empty_payload, f"empty max runtime should be omitted: {empty_payload}" + assert empty["error"] == "" + assert empty["focused"] is None + + for invalid_case in (zero, negative, decimal, scientific, alpha): + assert invalid_case["payload"] is None, f"invalid runtime should not submit: {invalid_case}" + assert invalid_case["error"] == "kanban_max_runtime_invalid", f"wrong validation message: {invalid_case}" + assert invalid_case["focused"] == "kanbanTaskModalMaxRuntimeSeconds", f"wrong focus target: {invalid_case}" + assert invalid_case["submitDisabled"] is False, f"submit button should stay enabled: {invalid_case}"