From b6a2fda4f0bcbea6d74a0b66fc7f35658b02017a Mon Sep 17 00:00:00 2001 From: Stefan Scharlott Date: Sun, 24 May 2026 16:05:44 +1200 Subject: [PATCH] feat: add Kanban consolidated view toggle --- api/kanban_bridge.py | 31 +++++++++ static/i18n.js | 55 ++++++++++++++++ static/index.html | 1 + static/panels.js | 36 ++++++++++- static/style.css | 11 ++++ tests/test_kanban_view_toggle.py | 104 +++++++++++++++++++++++++++++++ 6 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 tests/test_kanban_view_toggle.py diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index f0d5d261c..0ef0465dd 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -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/ 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 diff --git a/static/i18n.js b/static/i18n.js index c8d9fb45f..3daef6e1e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/index.html b/static/index.html index 8e54fd649..8563e5ab7 100644 --- a/static/index.html +++ b/static/index.html @@ -806,6 +806,7 @@
+
diff --git a/static/panels.js b/static/panels.js index fc8d4757f..913debb48 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 || ''; diff --git a/static/style.css b/static/style.css index d932d8f3e..4d817ed8c 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/tests/test_kanban_view_toggle.py b/tests/test_kanban_view_toggle.py new file mode 100644 index 000000000..c321bbbd6 --- /dev/null +++ b/tests/test_kanban_view_toggle.py @@ -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']+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"})