mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 327: PR #1965 — fix(kanban): header + button opens create-task modal (#1964) by @nesquena-hermes
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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;}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user