Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ const LOCALES = {
kanban_title_required: 'Title is required.',
kanban_new_task: 'New task',
kanban_edit_task: 'Edit task',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_run_dispatcher: 'Run dispatcher',
kanban_run_dispatcher_confirm: 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?',
kanban_assignee_profiles_label: 'Hermes profiles',
Expand Down Expand Up @@ -1589,6 +1590,7 @@ const LOCALES = {
kanban_status_running: '実行中',
kanban_status_blocked: 'ブロック中',
kanban_status_done: '完了',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'コメント ({0})',
kanban_events_count: 'イベント ({0})',
kanban_links: 'リンク',
Expand Down Expand Up @@ -2490,6 +2492,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down Expand Up @@ -3492,6 +3495,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down Expand Up @@ -4482,6 +4486,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down Expand Up @@ -5493,6 +5498,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down Expand Up @@ -7648,6 +7654,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down Expand Up @@ -8614,6 +8621,7 @@ const LOCALES = {
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
kanban_comments_count: 'Comments ({0})',
kanban_events_count: 'Events ({0})',
kanban_links: 'Links',
Expand Down
1 change: 1 addition & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,7 @@ <h3 id="kanbanTaskModalTitle" data-i18n="kanban_new_task">New task</h3>
<div class="kanban-modal-row-inline">
<div class="kanban-modal-row">
<label for="kanbanTaskModalStatus" data-i18n="kanban_status">Status</label>
<span id="kanbanTaskModalStatusOriginalHint" class="kanban-status-original-hint" hidden></span>
<select id="kanbanTaskModalStatus">
<option value="triage" data-i18n="kanban_status_triage">Triage</option>
<option value="todo" data-i18n="kanban_status_todo">Todo</option>
Expand Down
85 changes: 85 additions & 0 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,7 @@ async function createKanbanTask(){
let _kanbanTaskModalMode = 'create'; // 'create' | 'edit'
let _kanbanTaskModalEditingId = null; // task id when mode === 'edit'
let _kanbanProfileNamesCache = null; // populated lazily on first modal open
let _kanbanTaskModalFocusCleanup = null;
// Status the modal *displayed* on edit-mode open. If the user doesn't touch
// the dropdown, we must NOT send `status` in the PATCH payload — otherwise
// editing a task whose real status is non-editable in this dropdown
Expand All @@ -1687,6 +1688,7 @@ let _kanbanProfileNamesCache = null; // populated lazily on first modal open
// review: editing a 'running' task without touching status was reclaiming
// the worker and moving the task back to triage.
let _kanbanTaskModalInitialDisplayedStatus = null;
let _kanbanBoardModalFocusCleanup = null;

async function _kanbanLoadProfileNames(){
// Hit /api/profiles once per session and cache; refresh is cheap if needed.
Expand Down Expand Up @@ -1771,6 +1773,7 @@ function openKanbanCreate(){
// tasks that need human review before being marked actionable; users who
// want it can still pick it from the status dropdown.
_kanbanResetTaskModalFields({status: 'ready'});
_kanbanSetTaskModalStatusHint(null);
_kanbanSetTaskModalLabels('create');
_kanbanPopulateAssigneeSelect('').then(() => {
// After the dropdown is populated, default-select the first profile (not
Expand All @@ -1784,6 +1787,11 @@ function openKanbanCreate(){
});
_kanbanPopulateTenantDatalist();
modal.hidden = false;
if (_kanbanTaskModalFocusCleanup) {
_kanbanTaskModalFocusCleanup();
_kanbanTaskModalFocusCleanup = null;
}
_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => {
const titleEl = document.getElementById('kanbanTaskModalTitleInput');
if (titleEl) titleEl.focus();
Expand Down Expand Up @@ -1817,6 +1825,7 @@ async function openKanbanEdit(taskId){
// (the mapped 'triage' would land in the PATCH payload, and _patch_task
// would call _set_status_direct → reclaim worker → move to triage).
const initialDisplayedStatus = _kanbanEditableStatusFor(task.status);
const originalStatus = task.status || initialDisplayedStatus;
_kanbanTaskModalInitialDisplayedStatus = initialDisplayedStatus;
_kanbanResetTaskModalFields({
title: task.title || '',
Expand All @@ -1828,9 +1837,15 @@ async function openKanbanEdit(taskId){
// Populate the assignee select AFTER reset so the option exists when we
// call sel.value = currentAssignee.
await _kanbanPopulateAssigneeSelect(task.assignee || '');
_kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus);
_kanbanSetTaskModalLabels('edit');
_kanbanPopulateTenantDatalist();
modal.hidden = false;
if (_kanbanTaskModalFocusCleanup) {
_kanbanTaskModalFocusCleanup();
_kanbanTaskModalFocusCleanup = null;
}
_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => {
const titleEl = document.getElementById('kanbanTaskModalTitleInput');
if (titleEl) { titleEl.focus(); titleEl.select(); }
Expand Down Expand Up @@ -1879,18 +1894,74 @@ function _kanbanSetTaskModalLabels(mode){
}
}

function _kanbanSetTaskModalStatusHint(realStatus, editableStatus){
const hintEl = document.getElementById('kanbanTaskModalStatusOriginalHint');
if (!hintEl) return;
if (!realStatus || realStatus === editableStatus) {
hintEl.hidden = true;
hintEl.textContent = '';
return;
}
const statusLabel = t(`kanban_status_${realStatus}`) || realStatus;
hintEl.textContent = String(t('kanban_status_original_hint')).replace('{0}', statusLabel);
hintEl.hidden = false;
}

function _kanbanPopulateTenantDatalist(){
const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : [];
const tList = document.getElementById('kanbanTaskModalTenantList');
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).join('');
}

function _trapModalFocus(modalEl){
if (!modalEl) return () => {};
const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])';
const collect = () => {
const candidates = Array.from(modalEl.querySelectorAll(selector));
return candidates.filter((el) => {
if (el.disabled || el.hidden) return false;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
return el.tabIndex >= 0;
});
};
let focusableEls = collect();
const onKeyDown = (ev) => {
if (ev.key !== 'Tab') return;
if (!focusableEls.length) {
ev.preventDefault();
return;
}
const current = document.activeElement;
let idx = focusableEls.indexOf(current);
if (idx === -1) {
ev.preventDefault();
focusableEls[0].focus();
return;
}
if (ev.shiftKey) idx -= 1;
else idx += 1;
idx = (idx + focusableEls.length) % focusableEls.length;
ev.preventDefault();
focusableEls[idx].focus();
};
modalEl.addEventListener('keydown', onKeyDown);
return () => {
modalEl.removeEventListener('keydown', onKeyDown);
};
}

function closeKanbanTaskModal(){
const modal = document.getElementById('kanbanTaskModal');
if (modal) modal.hidden = true;
_kanbanTaskModalMode = 'create';
_kanbanTaskModalEditingId = null;
_kanbanTaskModalInitialDisplayedStatus = null;
_kanbanSetTaskModalStatusHint(null, null);
if (_kanbanTaskModalFocusCleanup) {
_kanbanTaskModalFocusCleanup();
_kanbanTaskModalFocusCleanup = null;
}
document.removeEventListener('keydown', _kanbanTaskModalKey);
}

Expand Down Expand Up @@ -2329,6 +2400,11 @@ function openKanbanCreateBoard(){
document.getElementById('kanbanBoardModalColor').value = '#7aa2ff';
document.getElementById('kanbanBoardModalError').textContent = '';
modal.hidden = false;
if (_kanbanBoardModalFocusCleanup) {
_kanbanBoardModalFocusCleanup();
_kanbanBoardModalFocusCleanup = null;
}
_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);
// Auto-focus name field
setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50);
// Auto-suggest slug from name as user types
Expand Down Expand Up @@ -2368,6 +2444,11 @@ function openKanbanRenameBoard(){
document.getElementById('kanbanBoardModalColor').value = meta.color || '#7aa2ff';
document.getElementById('kanbanBoardModalError').textContent = '';
modal.hidden = false;
if (_kanbanBoardModalFocusCleanup) {
_kanbanBoardModalFocusCleanup();
_kanbanBoardModalFocusCleanup = null;
}
_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);
setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50);
document.addEventListener('keydown', _kanbanBoardModalEsc);
}
Expand All @@ -2379,6 +2460,10 @@ function _kanbanBoardModalEsc(ev){
function closeKanbanBoardModal(){
const modal = document.getElementById('kanbanBoardModal');
if (modal) modal.hidden = true;
if (_kanbanBoardModalFocusCleanup) {
_kanbanBoardModalFocusCleanup();
_kanbanBoardModalFocusCleanup = null;
}
document.removeEventListener('keydown', _kanbanBoardModalEsc);
}

Expand Down
6 changes: 6 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3521,6 +3521,12 @@ 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-hint{font-size:11px;color:var(--muted);line-height:1.5;margin-top:6px;}
.kanban-status-original-hint{
display:block;
margin-top:4px;
font-size:11px;
color:var(--muted);
}
.kanban-modal-hint code{background:var(--input-bg);padding:1px 5px;border-radius:4px;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:11px;color:var(--text);}
.kanban-modal-hint em{color:var(--text);font-style:normal;font-weight:600;}
.kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;}
Expand Down
61 changes: 61 additions & 0 deletions tests/test_kanban_ui_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"Edit-mode status preservation requires tracking the initial displayed "
"status so submit can detect whether the user actually changed it."
)
assert 'id="kanbanTaskModalStatusOriginalHint"' in INDEX
assert "_kanbanSetTaskModalStatusHint" in PANELS
assert "kanban_status_original_hint" in I18N
assert ".kanban-status-original-hint" in STYLE

# 2. openKanbanEdit captures the initial displayed status from the task.
open_edit_match = re.search(
Expand All @@ -268,6 +272,8 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"openKanbanEdit must record the initial displayed status."
)
assert "_kanbanEditableStatusFor(task.status)" in open_edit_body
assert "_kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus)" in open_edit_body
assert "const originalStatus = task.status || initialDisplayedStatus" in open_edit_body

# 3. Submit's edit branch only sends status when it differs from the
# initial displayed value.
Expand All @@ -292,6 +298,7 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
"openKanbanCreate must reset the tracker to null so create-mode "
"submits always include status in the POST payload."
)
assert "_kanbanSetTaskModalStatusHint(null);" in create_body

# 5. closeKanbanTaskModal clears the tracker so a stale value can't leak
# into the next open.
Expand All @@ -301,6 +308,60 @@ def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
assert close_match
close_body = close_match.group(1)
assert "_kanbanTaskModalInitialDisplayedStatus = null" in close_body
assert "_kanbanSetTaskModalStatusHint(null, null);" in close_body


def test_kanban_modal_focus_trap_helper_exists():
"""Shared focus-trap helper should exist and attach/remove Tab key handling."""
assert "function _trapModalFocus" in PANELS
fn = re.search(r"function _trapModalFocus\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert fn, "_trapModalFocus() not found"
fn_body = fn.group(1)
assert "addEventListener('keydown'" in fn_body
assert "removeEventListener('keydown'" in fn_body
assert "ev.key !== 'Tab'" in fn_body or "ev.key === 'Tab'" in fn_body


def test_kanban_task_modal_focus_trap_is_installed_and_removed():
"""Task modal open calls should install focus trap and close should tear it down."""
create_match = re.search(r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert create_match, "openKanbanCreate() not found"
create_body = create_match.group(1)
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in create_body
assert "if (_kanbanTaskModalFocusCleanup) {" in create_body

edit_match = re.search(r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert edit_match, "openKanbanEdit() not found"
edit_body = edit_match.group(1)
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in edit_body
assert "if (_kanbanTaskModalFocusCleanup) {" in edit_body

close_match = re.search(r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert close_match, "closeKanbanTaskModal() not found"
close_body = close_match.group(1)
assert "if (_kanbanTaskModalFocusCleanup) {" in close_body
assert "_kanbanTaskModalFocusCleanup = null;" in close_body


def test_kanban_board_modal_focus_trap_is_installed_and_removed():
"""Board modal open calls should install focus trap and close should tear it down."""
create_board_match = re.search(r"function openKanbanCreateBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert create_board_match, "openKanbanCreateBoard() not found"
create_board_body = create_board_match.group(1)
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in create_board_body
assert "if (_kanbanBoardModalFocusCleanup) {" in create_board_body

rename_board_match = re.search(r"function openKanbanRenameBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert rename_board_match, "openKanbanRenameBoard() not found"
rename_board_body = rename_board_match.group(1)
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in rename_board_body
assert "if (_kanbanBoardModalFocusCleanup) {" in rename_board_body

close_board_match = re.search(r"function closeKanbanBoardModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert close_board_match, "closeKanbanBoardModal() not found"
close_board_body = close_board_match.group(1)
assert "if (_kanbanBoardModalFocusCleanup) {" in close_board_body
assert "_kanbanBoardModalFocusCleanup = null;" in close_board_body


def test_kanban_assignee_dropdown_uses_select_not_freetext():
Expand Down
Loading