feat(kanban): add task workspace and dependency controls (#3797)
This commit is contained in:
parent
c3041277d3
commit
947b770a57
4 changed files with 324 additions and 5 deletions
|
|
@ -847,6 +847,16 @@ const LOCALES = {
|
|||
kanban_dispatch_timed_out: 'timed out',
|
||||
kanban_dispatch_crashed: 'crashed',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
@ -2359,6 +2369,16 @@ const LOCALES = {
|
|||
kanban_dispatch_timed_out: 'scaduto',
|
||||
kanban_dispatch_crashed: 'crash',
|
||||
kanban_add_comment: 'Aggiungi commento',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Solo miei',
|
||||
kanban_bulk_action: 'Azione in blocco',
|
||||
kanban_nudge_dispatcher: 'Anteprima dispatcher',
|
||||
|
|
@ -3862,6 +3882,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: 'クラッシュ',
|
||||
kanban_new_task: '新規タスク',
|
||||
kanban_add_comment: 'コメント追加',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: '自分のみ',
|
||||
kanban_bulk_action: '一括操作',
|
||||
kanban_nudge_dispatcher: 'ディスパッチャープレビュー',
|
||||
|
|
@ -5135,6 +5165,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: 'падение',
|
||||
kanban_new_task: 'New task',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
@ -6575,6 +6615,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: 'fallaron',
|
||||
kanban_new_task: 'New task',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
@ -7993,6 +8043,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: 'abgestürzt',
|
||||
kanban_new_task: 'New task',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
@ -12518,6 +12578,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: 'falharam',
|
||||
kanban_new_task: 'New task',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
@ -13920,6 +13990,16 @@ const LOCALES = {
|
|||
kanban_dispatch_crashed: '충돌',
|
||||
kanban_new_task: 'New task',
|
||||
kanban_add_comment: 'Add comment',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_only_mine: 'Only mine',
|
||||
kanban_bulk_action: 'Bulk action',
|
||||
kanban_nudge_dispatcher: 'Preview dispatcher',
|
||||
|
|
|
|||
|
|
@ -1643,6 +1643,18 @@
|
|||
<input type="text" id="kanbanTaskModalTenant" list="kanbanTaskModalTenantList" maxlength="64" autocomplete="off" data-i18n-placeholder="kanban_tenant_placeholder" placeholder="Optional — project or team slug">
|
||||
<datalist id="kanbanTaskModalTenantList"></datalist>
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanTaskModalWorkspaceKind" data-i18n="kanban_workspace_kind">Workspace kind</label>
|
||||
<select id="kanbanTaskModalWorkspaceKind" onchange="_kanbanOnWorkspaceKindChange()">
|
||||
<option value="scratch" data-i18n="kanban_workspace_scratch">Scratch</option>
|
||||
<option value="worktree" data-i18n="kanban_workspace_worktree">Worktree</option>
|
||||
<option value="dir" data-i18n="kanban_workspace_dir">Directory</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kanban-modal-row" id="kanbanTaskModalWorkspacePathRow" style="display:none">
|
||||
<label for="kanbanTaskModalWorkspacePath" data-i18n="kanban_workspace_path">Workspace path</label>
|
||||
<input type="text" id="kanbanTaskModalWorkspacePath" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_workspace_path_placeholder" placeholder="Path to workspace">
|
||||
</div>
|
||||
<div class="kanban-modal-error" id="kanbanTaskModalError" aria-live="polite"></div>
|
||||
<div class="kanban-modal-actions">
|
||||
<button type="button" class="btn secondary" onclick="closeKanbanTaskModal()" data-i18n="cancel">Cancel</button>
|
||||
|
|
|
|||
|
|
@ -2347,11 +2347,18 @@ function _kanbanRunHtml(run){
|
|||
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>
|
||||
const taskId = _kanbanCurrentTaskId;
|
||||
const item = (id) => `<code>${esc(id)} <button class="btn mini" onclick="removeKanbanDependency('${esc(taskId)}','${esc(id)}')" data-i18n="kanban_remove_dependency" title="${esc(t('kanban_remove_dependency') || 'Remove')}">✕</button></code>`;
|
||||
const hasLinks = parents.length || children.length;
|
||||
return `<div class="kanban-detail-links-section">
|
||||
${hasLinks ? `<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>` : ''}
|
||||
<div class="kanban-detail-links-controls">
|
||||
<input type="text" id="kanbanDependencyInput" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_dependency_placeholder" placeholder="Task ID to link">
|
||||
<button class="btn secondary" onclick="addKanbanDependency('${esc(taskId)}')" data-i18n="kanban_add_dependency">Add dependency</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -2727,6 +2734,14 @@ function _kanbanTaskModalKey(ev){
|
|||
}
|
||||
}
|
||||
|
||||
function _kanbanOnWorkspaceKindChange(){
|
||||
const kindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
|
||||
const pathRowEl = document.getElementById('kanbanTaskModalWorkspacePathRow');
|
||||
if (!kindEl || !pathRowEl) return;
|
||||
const kind = kindEl.value;
|
||||
pathRowEl.style.display = (kind === 'scratch') ? 'none' : 'block';
|
||||
}
|
||||
|
||||
async function submitKanbanTaskModal(){
|
||||
const titleEl = document.getElementById('kanbanTaskModalTitleInput');
|
||||
const bodyEl = document.getElementById('kanbanTaskModalBody');
|
||||
|
|
@ -2734,6 +2749,8 @@ async function submitKanbanTaskModal(){
|
|||
const assigneeEl = document.getElementById('kanbanTaskModalAssignee');
|
||||
const tenantEl = document.getElementById('kanbanTaskModalTenant');
|
||||
const priorityEl = document.getElementById('kanbanTaskModalPriority');
|
||||
const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
|
||||
const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath');
|
||||
const errEl = document.getElementById('kanbanTaskModalError');
|
||||
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
|
||||
const title = titleEl ? titleEl.value.trim() : '';
|
||||
|
|
@ -2742,6 +2759,16 @@ async function submitKanbanTaskModal(){
|
|||
if (titleEl) titleEl.focus();
|
||||
return;
|
||||
}
|
||||
// Validate workspace path for non-scratch workspace kinds
|
||||
const workspaceKind = workspaceKindEl ? workspaceKindEl.value : 'scratch';
|
||||
if (workspaceKind !== 'scratch') {
|
||||
const workspacePath = workspacePathEl ? workspacePathEl.value.trim() : '';
|
||||
if (!workspacePath) {
|
||||
if (errEl) errEl.textContent = t('kanban_workspace_path_required') || 'Workspace path is required for non-scratch workspaces.';
|
||||
if (workspacePathEl) workspacePathEl.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Build payload — for create we omit defaulted fields so the backend chooses;
|
||||
// for edit we send every field so users can clear assignee/tenant/body.
|
||||
const isEdit = _kanbanTaskModalMode === 'edit';
|
||||
|
|
@ -2751,6 +2778,7 @@ async function submitKanbanTaskModal(){
|
|||
const tenantVal = tenantEl ? tenantEl.value.trim() : '';
|
||||
const statusVal = statusEl ? statusEl.value : '';
|
||||
const priorityRaw = priorityEl ? priorityEl.value : '';
|
||||
const workspacePathVal = workspacePathEl ? workspacePathEl.value.trim() : '';
|
||||
if (isEdit) {
|
||||
payload.body = bodyVal;
|
||||
payload.assignee = assigneeVal || null;
|
||||
|
|
@ -2764,6 +2792,8 @@ async function submitKanbanTaskModal(){
|
|||
}
|
||||
const n = parseInt(priorityRaw, 10);
|
||||
payload.priority = Number.isNaN(n) ? 0 : n;
|
||||
payload.workspace_kind = workspaceKind;
|
||||
if (workspacePathVal) payload.workspace_path = workspacePathVal;
|
||||
} else {
|
||||
if (bodyVal.trim()) payload.body = bodyVal;
|
||||
if (statusVal) payload.status = statusVal;
|
||||
|
|
@ -2773,6 +2803,8 @@ async function submitKanbanTaskModal(){
|
|||
const n = parseInt(priorityRaw, 10);
|
||||
if (!Number.isNaN(n)) payload.priority = n;
|
||||
}
|
||||
payload.workspace_kind = workspaceKind;
|
||||
if (workspacePathVal) payload.workspace_path = workspacePathVal;
|
||||
}
|
||||
// Soft warning: a Ready task with the explicit "Unassigned" option will sit
|
||||
// forever because the dispatcher skips unassigned rows (kanban_db.py:3567).
|
||||
|
|
@ -2845,6 +2877,31 @@ async function addKanbanComment(taskId){
|
|||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
async function addKanbanDependency(taskId){
|
||||
const input = document.getElementById('kanbanDependencyInput');
|
||||
const linkTo = input ? input.value.trim() : '';
|
||||
if (!taskId || !linkTo) return;
|
||||
try {
|
||||
await api('/api/kanban/links' + _kanbanBoardQuery(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({parent: taskId, child: linkTo}),
|
||||
});
|
||||
if (input) input.value = '';
|
||||
await loadKanbanTask(taskId);
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
async function removeKanbanDependency(taskId, linkId){
|
||||
if (!taskId || !linkId) return;
|
||||
try {
|
||||
await api('/api/kanban/links' + _kanbanBoardQuery(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({parent: taskId, child: linkId}),
|
||||
});
|
||||
await loadKanbanTask(taskId);
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
function _kanbanRenderTaskDetail(data){
|
||||
const task = data.task || {};
|
||||
const log = data.log || {};
|
||||
|
|
|
|||
170
tests/test_issue3797_kanban_cli_parity.py
Normal file
170
tests/test_issue3797_kanban_cli_parity.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""
|
||||
Regression tests for Kanban UI task workspace and dependency controls (#3797).
|
||||
Tests the Kanban workspace kind selector, workspace path validation, and dependency
|
||||
add/remove controls for tasks in the Kanban board detail view.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class TestKanbanWorkspaceSelector:
|
||||
"""Test workspace kind selector in the create/edit task modal."""
|
||||
|
||||
def test_modal_has_workspace_kind_dropdown(self):
|
||||
"""The modal must have a workspace kind dropdown with scratch, worktree, dir options."""
|
||||
soup = BeautifulSoup(INDEX_HTML, "html.parser")
|
||||
kind_select = soup.find("select", {"id": "kanbanTaskModalWorkspaceKind"})
|
||||
assert kind_select is not None, "Modal must have workspace kind dropdown"
|
||||
|
||||
options = [opt.get("value") for opt in kind_select.find_all("option")]
|
||||
assert "scratch" in options
|
||||
assert "worktree" in options
|
||||
assert "dir" in options
|
||||
|
||||
def test_modal_has_workspace_path_input_row(self):
|
||||
"""The modal must have a workspace path input row, hidden by default."""
|
||||
soup = BeautifulSoup(INDEX_HTML, "html.parser")
|
||||
path_row = soup.find("div", {"id": "kanbanTaskModalWorkspacePathRow"})
|
||||
assert path_row is not None, "Modal must have workspace path row"
|
||||
|
||||
path_input = soup.find("input", {"id": "kanbanTaskModalWorkspacePath"})
|
||||
assert path_input is not None, "Modal must have workspace path input"
|
||||
|
||||
def test_workspace_path_row_visibility_toggle(self):
|
||||
"""The _kanbanOnWorkspaceKindChange function must control path row visibility."""
|
||||
assert "_kanbanOnWorkspaceKindChange" in PANELS_JS
|
||||
assert "kanbanTaskModalWorkspaceKind" in PANELS_JS
|
||||
assert "kanbanTaskModalWorkspacePathRow" in PANELS_JS
|
||||
# When kind is "scratch", path row should be hidden (display: none)
|
||||
# When kind is "worktree" or "dir", path row should be visible
|
||||
|
||||
def test_i18n_keys_for_workspace_selector(self):
|
||||
"""All workspace-related i18n keys must be defined in all locales."""
|
||||
required_keys = [
|
||||
"kanban_workspace_kind",
|
||||
"kanban_workspace_path",
|
||||
"kanban_workspace_path_placeholder",
|
||||
"kanban_workspace_scratch",
|
||||
"kanban_workspace_worktree",
|
||||
"kanban_workspace_dir",
|
||||
"kanban_workspace_path_required",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert f"{key}:" in I18N_JS, f"i18n key '{key}' not found"
|
||||
|
||||
|
||||
class TestWorkspacePathValidation:
|
||||
"""Test client-side validation of workspace paths."""
|
||||
|
||||
def test_submit_blocks_empty_worktree_path(self):
|
||||
"""submitKanbanTaskModal must reject non-scratch workspaces with empty paths."""
|
||||
# The function must check if workspace_kind is not 'scratch' and
|
||||
# workspace_path is empty, then set error and return early
|
||||
assert 'if (workspaceKind !== \'scratch\')' in PANELS_JS
|
||||
assert "kanban_workspace_path_required" in PANELS_JS
|
||||
# Must call focus() on the path input for UX
|
||||
assert "workspacePathEl.focus()" in PANELS_JS
|
||||
|
||||
def test_submit_accepts_scratch_without_path(self):
|
||||
"""submitKanbanTaskModal must accept scratch workspace even without a path."""
|
||||
# When workspace_kind === 'scratch', skip path validation
|
||||
assert "if (workspaceKind !== 'scratch')" in PANELS_JS
|
||||
|
||||
def test_workspace_fields_sent_in_payload(self):
|
||||
"""The create/edit payload must include workspace_kind and workspace_path."""
|
||||
# In create branch (not isEdit)
|
||||
assert "payload.workspace_kind = workspaceKind" in PANELS_JS
|
||||
# Path is optional for non-create, but must be sent when present
|
||||
assert "if (workspacePathVal) payload.workspace_path = workspacePathVal" in PANELS_JS
|
||||
|
||||
|
||||
class TestDependencyControls:
|
||||
"""Test dependency add/remove controls in task detail view."""
|
||||
|
||||
def test_links_html_renders_dependency_section(self):
|
||||
"""_kanbanLinksHtml must render a dependency control section."""
|
||||
assert "_kanbanLinksHtml" in PANELS_JS
|
||||
# Must render parents and children lists
|
||||
assert "links.parents" in PANELS_JS
|
||||
assert "links.children" in PANELS_JS
|
||||
# Must have a controls section with input + button
|
||||
assert "kanban-detail-links-controls" in PANELS_JS
|
||||
|
||||
def test_add_dependency_function_exists(self):
|
||||
"""addKanbanDependency function must exist and hit /api/kanban/links POST."""
|
||||
assert "async function addKanbanDependency" in PANELS_JS
|
||||
assert "'/api/kanban/links'" in PANELS_JS
|
||||
assert "method: 'POST'" in PANELS_JS
|
||||
# Payload must have parent and child fields in the JSON
|
||||
assert "{parent:" in PANELS_JS or '{"parent":' in PANELS_JS or "{parent :" in PANELS_JS
|
||||
assert "child:" in PANELS_JS or '"child":' in PANELS_JS
|
||||
|
||||
def test_remove_dependency_function_exists(self):
|
||||
"""removeKanbanDependency function must exist and hit /api/kanban/links POST."""
|
||||
assert "async function removeKanbanDependency" in PANELS_JS
|
||||
|
||||
def test_dependency_input_and_button_rendered(self):
|
||||
"""_kanbanLinksHtml must render an input field and button for adding dependencies."""
|
||||
# Look for dependency input in the function
|
||||
assert "kanbanDependencyInput" in PANELS_JS
|
||||
# Button to add dependency
|
||||
assert "onclick=\"addKanbanDependency" in PANELS_JS
|
||||
|
||||
def test_remove_dependency_button_on_each_link(self):
|
||||
"""_kanbanLinksHtml must render a remove button for each parent/child link."""
|
||||
# Each link renders a remove button
|
||||
assert "onclick=\"removeKanbanDependency" in PANELS_JS
|
||||
|
||||
def test_i18n_keys_for_dependencies(self):
|
||||
"""All dependency-related i18n keys must be defined."""
|
||||
required_keys = [
|
||||
"kanban_add_dependency",
|
||||
"kanban_remove_dependency",
|
||||
"kanban_dependency_placeholder",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert f"{key}:" in I18N_JS, f"i18n key '{key}' not found"
|
||||
|
||||
|
||||
class TestAPIIntegration:
|
||||
"""Test that the frontend correctly calls the backend API."""
|
||||
|
||||
def test_add_dependency_payload_structure(self):
|
||||
"""addKanbanDependency must send correct payload to /api/kanban/links."""
|
||||
# Payload must include parent (task being edited) and child (linked task)
|
||||
assert "addKanbanDependency" in PANELS_JS
|
||||
# Must use board query suffix
|
||||
assert "_kanbanBoardQuery()" in PANELS_JS
|
||||
|
||||
def test_remove_dependency_deletes_via_post(self):
|
||||
"""removeKanbanDependency must POST to /api/kanban/links (not use DELETE)."""
|
||||
# Check that removeKanbanDependency uses POST, not DELETE
|
||||
# The backend handles both POST (to /api/kanban/links/delete or as POST to /api/kanban/links)
|
||||
assert "removeKanbanDependency" in PANELS_JS
|
||||
assert "method: 'POST'" in PANELS_JS
|
||||
|
||||
def test_links_refreshed_after_dependency_operation(self):
|
||||
"""After adding or removing a dependency, loadKanbanTask must refresh the detail view."""
|
||||
# Both functions must call loadKanbanTask to refresh
|
||||
assert "await loadKanbanTask(taskId)" in PANELS_JS
|
||||
|
||||
|
||||
def test_no_backend_modifications_required():
|
||||
"""Verify that backend kanban_bridge.py already has the routes we're using."""
|
||||
kanban_bridge = (ROOT / "api" / "kanban_bridge.py").read_text(encoding="utf-8")
|
||||
# Routes for links must exist and accept workspace_kind, workspace_path
|
||||
assert "/api/kanban/links" in kanban_bridge
|
||||
assert "/api/kanban/links/delete" in kanban_bridge
|
||||
# workspace_kind and workspace_path must be accepted in payload
|
||||
assert "workspace_kind" in kanban_bridge
|
||||
assert "workspace_path" in kanban_bridge
|
||||
Loading…
Reference in a new issue