Stage 327: PR #1965 — fix(kanban): header + button opens create-task modal (#1964) by @nesquena-hermes

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes
2026-05-09 19:51:30 +00:00
5 changed files with 370 additions and 3 deletions
+88
View File
@@ -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',
+44 -1
View File
@@ -146,7 +146,7 @@
<div class="panel-head">
<span data-i18n="tab_kanban">Kanban</span>
<div class="panel-head-actions">
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="kanbanNewTaskBtn" onclick="createKanbanTask()" data-tooltip="New task" data-i18n-title="kanban_new_task" aria-label="New task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="kanbanNewTaskBtn" onclick="openKanbanCreate()" data-tooltip="New task" data-i18n-title="kanban_new_task" aria-label="New task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="kanbanRefreshBtn" onclick="loadKanban(true)" data-tooltip="Refresh" data-i18n-title="kanban_refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
@@ -1262,5 +1262,48 @@
</div>
</div>
</div>
<!-- Kanban: create-task modal — same overlay pattern as the create-board modal above. -->
<div class="kanban-modal-overlay" id="kanbanTaskModal" hidden onclick="if(event.target===this)closeKanbanTaskModal()">
<div class="kanban-modal" role="dialog" aria-modal="true" aria-labelledby="kanbanTaskModalTitle">
<h3 id="kanbanTaskModalTitle" data-i18n="kanban_new_task">New task</h3>
<div class="kanban-modal-row">
<label for="kanbanTaskModalTitleInput" data-i18n="kanban_title">Title</label>
<input type="text" id="kanbanTaskModalTitleInput" maxlength="500" autocomplete="off" required>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalBody" data-i18n="kanban_description">Description</label>
<textarea id="kanbanTaskModalBody" rows="4" data-i18n-placeholder="kanban_description_placeholder" placeholder="Optional — what needs to happen, acceptance criteria, links"></textarea>
</div>
<div class="kanban-modal-row-inline">
<div class="kanban-modal-row">
<label for="kanbanTaskModalStatus" data-i18n="kanban_status">Status</label>
<select id="kanbanTaskModalStatus">
<option value="triage" data-i18n="kanban_status_triage">Triage</option>
<option value="todo" data-i18n="kanban_status_todo">Todo</option>
<option value="ready" data-i18n="kanban_status_ready">Ready</option>
</select>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalPriority" data-i18n="kanban_priority">Priority</label>
<input type="number" id="kanbanTaskModalPriority" value="0" min="-100" max="100" step="1">
</div>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalAssignee" data-i18n="kanban_assignee">Assignee</label>
<input type="text" id="kanbanTaskModalAssignee" list="kanbanTaskModalAssigneeList" maxlength="64" autocomplete="off" data-i18n-placeholder="kanban_assignee_placeholder" placeholder="Optional — leave blank for any worker">
<datalist id="kanbanTaskModalAssigneeList"></datalist>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalTenant" data-i18n="kanban_tenant">Tenant</label>
<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-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>
<button type="button" class="btn primary" id="kanbanTaskModalSubmit" onclick="submitKanbanTaskModal()" data-i18n="create">Create</button>
</div>
</div>
</div>
</body>
</html>
+127 -1
View File
@@ -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 </body>. 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 => `<option value="${esc(v)}"></option>`).join('');
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).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 {
+9 -1
View File
@@ -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;}
+102
View File
@@ -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("</button>") + len("</button>")]
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",