mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
feat: add Kanban consolidated view toggle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 || '';
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user