feat: add Kanban consolidated view toggle

This commit is contained in:
Stefan Scharlott
2026-05-24 16:05:44 +12:00
parent cf003ae986
commit b6a2fda4f0
6 changed files with 236 additions and 2 deletions
+31
View File
@@ -550,6 +550,35 @@ def _config_payload(*, board=None):
}
def _update_config_payload(body):
if not isinstance(body, dict):
raise ValueError("JSON object body required")
if "lane_by_profile" not in body:
raise ValueError("lane_by_profile is required")
if not isinstance(body.get("lane_by_profile"), bool):
raise ValueError("lane_by_profile must be boolean")
from api import config
config_path = config._get_config_path()
with config._cfg_lock:
config_data = config._load_yaml_config_file(config_path)
dashboard_cfg = config_data.get("dashboard")
if not isinstance(dashboard_cfg, dict):
dashboard_cfg = {}
kanban_cfg = dashboard_cfg.get("kanban")
if not isinstance(kanban_cfg, dict):
kanban_cfg = {}
kanban_cfg["lane_by_profile"] = body["lane_by_profile"]
dashboard_cfg["kanban"] = kanban_cfg
config_data["dashboard"] = dashboard_cfg
config._save_yaml_config_file(config_path, config_data)
config.reload_config()
payload = _config_payload()
payload["lane_by_profile"] = body["lane_by_profile"]
return payload
def _stats_payload(*, board=None):
kb = _kb()
with _conn(board=board) as conn:
@@ -1193,6 +1222,8 @@ def handle_kanban_patch(handler, parsed, body) -> bool | None:
three-valued ``True | None | False`` contract (#1843)."""
path = parsed.path
try:
if path == "/api/kanban/config":
return j(handler, _update_config_payload(body)) or True
# /boards/<slug> routes operate on the on-disk board collection
# itself — the slug travels in the URL path, not via ?board=. Match
# them BEFORE resolving the board param so a stray ?board=ghost in
+55
View File
@@ -693,6 +693,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -1994,6 +1999,11 @@ const LOCALES = {
kanban_unblock: 'Sblocca',
kanban_back_to_board: 'Torna alla bacheca',
kanban_lanes_by_profile: 'Corsie per profilo',
kanban_view_lanes: 'Corsie per profilo',
kanban_view_consolidated: 'Vista consolidata',
kanban_view_lanes_saved: 'Vista Kanban: corsie per profilo',
kanban_view_consolidated_saved: 'Vista Kanban: consolidata',
kanban_view_update_failed: 'Impossibile aggiornare la vista Kanban: ',
kanban_new_board: 'Nuova bacheca…',
kanban_rename_board: 'Rinomina bacheca attuale…',
kanban_archive_board: 'Archivia bacheca attuale…',
@@ -3287,6 +3297,11 @@ const LOCALES = {
kanban_unblock: 'ブロック解除',
kanban_back_to_board: 'ボードに戻る',
kanban_lanes_by_profile: 'プロファイル別レーン',
kanban_view_lanes: 'プロファイル別レーン',
kanban_view_consolidated: '統合ビュー',
kanban_view_lanes_saved: 'Kanban 表示: プロファイル別レーン',
kanban_view_consolidated_saved: 'Kanban 表示: 統合ビュー',
kanban_view_update_failed: 'Kanban 表示の更新に失敗しました: ',
kanban_new_board: '新規ボード…',
kanban_rename_board: '現在のボード名を変更…',
kanban_archive_board: '現在のボードをアーカイブ…',
@@ -4375,6 +4390,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -5606,6 +5626,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -6820,6 +6845,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -8076,6 +8106,11 @@ const LOCALES = {
kanban_unblock: '解除阻塞',
kanban_back_to_board: '返回看板',
kanban_lanes_by_profile: '按配置文件分列',
kanban_view_lanes: '按配置文件分列',
kanban_view_consolidated: '合并视图',
kanban_view_lanes_saved: 'Kanban 视图:按配置文件分列',
kanban_view_consolidated_saved: 'Kanban 视图:已合并',
kanban_view_update_failed: '无法更新 Kanban 视图:',
kanban_new_board: '新建看板…',
kanban_rename_board: '重命名当前看板…',
kanban_archive_board: '归档当前看板…',
@@ -9317,6 +9352,11 @@ const LOCALES = {
kanban_unblock: '解除封鎖',
kanban_back_to_board: '返回看板',
kanban_lanes_by_profile: '按設定檔分泳道',
kanban_view_lanes: '按設定檔分泳道',
kanban_view_consolidated: '整合檢視',
kanban_view_lanes_saved: 'Kanban 檢視:按設定檔分泳道',
kanban_view_consolidated_saved: 'Kanban 檢視:已整合',
kanban_view_update_failed: '無法更新 Kanban 檢視:',
kanban_new_board: '新看板…',
kanban_rename_board: '重新命名目前看板…',
kanban_archive_board: '封存目前看板…',
@@ -10691,6 +10731,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -11886,6 +11931,11 @@ const LOCALES = {
kanban_unblock: 'Unblock',
kanban_back_to_board: 'Back to board',
kanban_lanes_by_profile: 'Lanes by profile',
kanban_view_lanes: 'Lanes by profile',
kanban_view_consolidated: 'Consolidated view',
kanban_view_lanes_saved: 'Kanban view: lanes by profile',
kanban_view_consolidated_saved: 'Kanban view: consolidated',
kanban_view_update_failed: 'Failed to update Kanban view: ',
kanban_new_board: 'New board…',
kanban_rename_board: 'Rename current board…',
kanban_archive_board: 'Archive current board…',
@@ -13696,6 +13746,11 @@ const LOCALES = {
kanban_card_archive: 'archiver',
kanban_card_complete: 'terminer',
kanban_lanes_by_profile: 'Colonnes par profil',
kanban_view_lanes: 'Colonnes par profil',
kanban_view_consolidated: 'Vue consolidée',
kanban_view_lanes_saved: 'Vue Kanban : colonnes par profil',
kanban_view_consolidated_saved: 'Vue Kanban : consolidée',
kanban_view_update_failed: 'Impossible de mettre à jour la vue Kanban : ',
kanban_new_board: 'Nouveau tableau…',
kanban_nudge_dispatcher: 'Aperçu du dispatcher',
kanban_only_mine: 'Seulement les miennes',
+1
View File
@@ -806,6 +806,7 @@
</div>
<div class="main-view-actions">
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="btnKanbanCreateBoard" onclick="openKanbanCreateBoard()" data-tooltip="New board" data-i18n-title="kanban_new_board" aria-label="New board"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><line x1="17.5" y1="14" x2="17.5" y2="21"/><line x1="14" y1="17.5" x2="21" y2="17.5"/></svg></button>
<button class="panel-head-btn kanban-view-toggle has-tooltip has-tooltip--bottom" id="btnKanbanViewToggle" type="button" onclick="toggleKanbanViewMode()" aria-pressed="false" data-tooltip="Consolidated view" data-i18n-title="kanban_view_consolidated" data-i18n-aria-label="kanban_view_consolidated" aria-label="Consolidated view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom kanban-nudge-dispatch-btn" id="btnKanbanPreviewDispatcher" onclick="nudgeKanbanDispatcher()" data-tooltip="Preview dispatcher (dry-run)" data-i18n-title="kanban_nudge_dispatcher" aria-label="Preview dispatcher (dry-run)"></button>
<button class="panel-head-btn has-tooltip has-tooltip--bottom kanban-run-dispatch-btn" id="btnKanbanRunDispatcher" onclick="runKanbanDispatcher()" data-tooltip="Run dispatcher — claim Ready tasks" data-i18n-title="kanban_run_dispatcher" aria-label="Run dispatcher"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg></button>
</div>
+34 -2
View File
@@ -1259,14 +1259,46 @@ function _kanbanCurrentFilters(){
}
function _kanbanApplyConfigDefaults(config){
if (!config || _kanbanConfigApplied) return;
if (!config) return;
_kanbanLanesByProfile = config.lane_by_profile === true;
syncKanbanViewToggle();
if (_kanbanConfigApplied) return;
if ($('kanbanTenantFilter') && config.default_tenant) $('kanbanTenantFilter').dataset.defaultValue = config.default_tenant;
if ($('kanbanIncludeArchived') && config.include_archived_by_default === true) $('kanbanIncludeArchived').checked = true;
if (config.lane_by_profile === true) _kanbanLanesByProfile = true;
_kanbanConfigApplied = true;
}
let _kanbanConfigApplied = false;
function syncKanbanViewToggle(){
const btn = $('btnKanbanViewToggle');
if (!btn) return;
const consolidated = !_kanbanLanesByProfile;
const label = t('kanban_view_consolidated');
btn.setAttribute('aria-pressed', consolidated ? 'true' : 'false');
btn.setAttribute('aria-label', label);
btn.setAttribute('data-i18n-title', 'kanban_view_consolidated');
btn.setAttribute('data-i18n-aria-label', 'kanban_view_consolidated');
if (typeof _setButtonTooltip === 'function') _setButtonTooltip(btn, label);
else btn.setAttribute('data-tooltip', label);
}
async function toggleKanbanViewMode(){
const btn = $('btnKanbanViewToggle');
const nextLaneByProfile = !_kanbanLanesByProfile;
if (btn) btn.disabled = true;
try {
const saved = await api('/api/kanban/config', {method: 'PATCH', body: JSON.stringify({lane_by_profile: nextLaneByProfile})});
_kanbanLanesByProfile = saved.lane_by_profile === true;
syncKanbanViewToggle();
_kanbanRenderBoard();
showToast(t(_kanbanLanesByProfile ? 'kanban_view_lanes_saved' : 'kanban_view_consolidated_saved'));
} catch(e) {
showToast(t('kanban_view_update_failed') + (e.message || e), 4000, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function _kanbanSetSelectOptions(el, values, allLabelKey){
if (!el) return;
const current = el.value || el.dataset.defaultValue || '';
+11
View File
@@ -4300,6 +4300,17 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-run-dispatch-btn:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
}
.kanban-view-toggle{
color:var(--muted);
}
.kanban-view-toggle[aria-pressed="true"]{
border-color:color-mix(in srgb,var(--accent,#FFD700) 58%,var(--border));
background:color-mix(in srgb,var(--accent,#FFD700) 13%,transparent);
color:var(--accent,#FFD700);
}
.kanban-view-toggle[aria-pressed="true"]:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 22%,transparent);
}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
+104
View File
@@ -0,0 +1,104 @@
"""Regression coverage for the Kanban consolidated-view toggle.
The Kanban board can be rendered as profile lanes or as one consolidated
status-column board. The browser control must persist that choice back to the
shared Kanban config so reloads and other browsers see the same mode.
"""
from pathlib import Path
import re
import pytest
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text()
PANELS_JS = (ROOT / "static" / "panels.js").read_text()
STYLE_CSS = (ROOT / "static" / "style.css").read_text()
I18N_JS = (ROOT / "static" / "i18n.js").read_text()
def test_kanban_header_exposes_accessible_view_toggle():
match = re.search(
r'<button[^>]+id="btnKanbanViewToggle"[^>]*>',
INDEX_HTML,
re.S,
)
assert match, "Kanban board header must expose a view-mode toggle"
tag = match.group(0)
assert 'class="panel-head-btn kanban-view-toggle' in tag
assert 'aria-pressed="false"' in tag
assert 'onclick="toggleKanbanViewMode()"' in tag
assert 'data-i18n-title="kanban_view_consolidated"' in tag
assert 'data-i18n-aria-label="kanban_view_consolidated"' in tag
kanban_start = INDEX_HTML.index('id="mainKanban"')
toggle_idx = INDEX_HTML.index('id="btnKanbanViewToggle"', kanban_start)
preview_idx = INDEX_HTML.index('id="btnKanbanPreviewDispatcher"', kanban_start)
assert toggle_idx < preview_idx, "view toggle should sit with Kanban board controls"
def test_kanban_view_toggle_persists_to_server_config_and_rerenders():
assert "function syncKanbanViewToggle" in PANELS_JS
assert "async function toggleKanbanViewMode" in PANELS_JS
assert "api('/api/kanban/config', {method: 'PATCH'" in PANELS_JS
assert "lane_by_profile: nextLaneByProfile" in PANELS_JS
assert "_kanbanLanesByProfile = saved.lane_by_profile === true" in PANELS_JS
assert "_kanbanRenderBoard();" in PANELS_JS
assert "syncKanbanViewToggle();" in PANELS_JS
def test_kanban_config_updates_lane_mode_even_after_defaults_applied():
apply_start = PANELS_JS.index("function _kanbanApplyConfigDefaults")
apply_end = PANELS_JS.index("let _kanbanConfigApplied", apply_start)
body = PANELS_JS[apply_start:apply_end]
lane_idx = body.index("_kanbanLanesByProfile = config.lane_by_profile === true")
applied_guard_idx = body.index("if (_kanbanConfigApplied) return")
assert lane_idx < applied_guard_idx, (
"lane mode must refresh from server config on every load, even after "
"one-time filter defaults have already been applied"
)
def test_kanban_view_toggle_has_css_and_i18n():
assert ".kanban-view-toggle" in STYLE_CSS
assert '.kanban-view-toggle[aria-pressed="true"]' in STYLE_CSS
for key in (
"kanban_view_lanes",
"kanban_view_consolidated",
"kanban_view_lanes_saved",
"kanban_view_consolidated_saved",
"kanban_view_update_failed",
):
assert f"{key}:" in I18N_JS
def test_kanban_config_patch_persists_lane_by_profile_to_config_yaml(tmp_path, monkeypatch):
import api.config as config
import api.kanban_bridge as bridge
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(
"dashboard:\n"
" kanban:\n"
" lane_by_profile: true\n"
" render_markdown: true\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: cfg_path)
config.reload_config()
payload = bridge._update_config_payload({"lane_by_profile": False})
assert payload["lane_by_profile"] is False
written = config._load_yaml_config_file(cfg_path)
assert written["dashboard"]["kanban"]["lane_by_profile"] is False
assert written["dashboard"]["kanban"]["render_markdown"] is True
def test_kanban_config_patch_rejects_non_boolean_lane_by_profile():
import api.kanban_bridge as bridge
with pytest.raises(ValueError, match="lane_by_profile must be boolean"):
bridge._update_config_payload({"lane_by_profile": "false"})