mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge pull request #2016 from nesquena/stage-329
Release K — v0.51.35 — Kanban polish + i18n DE pluralization (6 PRs from @franksong2702)
This commit is contained in:
@@ -1,5 +1,36 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #1990** by @franksong2702 — Kanban dispatcher race guard. Adds `_kanbanIsDispatching` flag around `runKanbanDispatcher()` and `nudgeKanbanDispatcher()` in `static/panels.js`; both Run/Preview buttons go disabled while the call is in-flight, so a fast double-click can't fire the dispatcher twice (which would post duplicate POSTs and surface duplicate toasts). Re-enables on success or error in `finally`. Closes #1984.
|
||||
|
||||
- **PR #1991** by @franksong2702 — German `profile_skill_count` pluralization. The DE locale had `profile_skill_count: '{count} Fähigkeiten'` as a literal string with the placeholder token still in it (so 1, 2, 5 skills all rendered as `{count} Fähigkeiten`). Switched to the same `(count) => …` interpolation function form already used by the other locales. Regression test `tests/test_issue1989_profile_skill_count.py` pins DE to function form and asserts the literal token never reaches the rendered string. Closes #1989.
|
||||
|
||||
- **PR #1993** by @franksong2702 — Kanban assignee-dropdown profile cache invalidation. `_kanbanProfileNamesCache` was populated lazily on first modal open and never expired; creating or deleting a profile elsewhere in the UI didn't refresh it, so the assignee dropdown could show a freshly-deleted profile or miss a freshly-created one. Added a 30-second TTL (`_kanbanProfileNamesCacheAt` + `_KANBAN_PROFILE_NAMES_CACHE_TTL_MS`) and an explicit `_invalidateKanbanProfileCache()` helper called from `saveProfileForm()`, `deleteCurrentProfile()`, and `deleteProfile()`. Closes #1985.
|
||||
|
||||
- **PR #1995** by @franksong2702 — Kanban modal focus trap + edit-mode status hint. Two related fixes bundled (#1995 was rebased on top of #1994 in the contributor's branch):
|
||||
- **Focus trap (#1974).** Tab/Shift-Tab in the Kanban task and board modals could move keyboard focus to controls behind the modal. Added a shared `_trapModalFocus(modalEl)` helper in `static/panels.js`; wired into `openKanbanCreate()`, `openKanbanEdit()`, `openKanbanCreateBoard()`, and `openKanbanRenameBoard()`. Cleanup tracker `_kanbanTaskModalFocusCleanup` removes the trap on close so a sequence of open→close→open doesn't leak listeners.
|
||||
- **Status hint (#1986).** When opening Edit on a task whose real status is `running`/`blocked`/`done`/`archived` (which the dropdown displays as `triage` because the dispatcher only writes to `triage`/`todo`/`ready`), the modal now shows an inline hint explaining the displayed-vs-real mismatch. The dropdown behaviour is unchanged — only an additional UX cue. New CSS for `.kanban-status-hint`, new i18n key `kanban_status_hint_real` across all 8 locales.
|
||||
|
||||
Closes #1974, #1986.
|
||||
|
||||
- **PR #1996** by @franksong2702 — Kanban modal locale parity regression test. Adds `tests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabulary` that anchors on the existing `kanban_no_comments` key and asserts every locale supporting Kanban has the modal vocabulary. Hardens locale-block parsing to handle quoted locales. Pure test addition.
|
||||
|
||||
### Tests
|
||||
|
||||
5049 → **5054 collected, 5054 passing, 0 regressions** (+5 net new). Full suite 154s on Python 3.11 with `HERMES_HOME` isolation.
|
||||
|
||||
### Stage augmentation
|
||||
|
||||
- **`9242305a`** — Opus advisor flagged that `kanban_status_original_hint` (added by #1995) was missing in the `zh-Hant` block, so Traditional Chinese users would get the English fallback. Added the Traditional Chinese translation (`實際狀態:{0}。此對話框僅支援編輯 Triage/Todo/Ready。`) at line 6537 and extended `tests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabulary`'s `modal_keys` list to assert the key — so any future kanban modal key added without zh-Hant translation will fail CI.
|
||||
|
||||
### Notes
|
||||
|
||||
- `static/panels.js` was the high-collision file in this batch (5 PRs touched it). Stage merge cleanly; one syntactic conflict at the `_kanbanProfileNamesCache` declaration block when #1995 landed on top of #1993 — both PRs added new module-level `let` declarations adjacent to `_kanbanProfileNamesCache`. Resolved by preserving both declaration blocks (the variables are independent).
|
||||
- Six PRs in batch, all from @franksong2702. Disjoint concerns, disjoint i18n keys, disjoint tests. The 5-files panels.js overlap was the only nontrivial integration risk and resolved cleanly.
|
||||
|
||||
## [v0.51.34] — 2026-05-09 — Release J (kanban edit/dispatch + zh-Hant kanban i18n)
|
||||
|
||||
### Added
|
||||
|
||||
+10
-1
@@ -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',
|
||||
@@ -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: 'リンク',
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
@@ -5027,7 +5032,7 @@ const LOCALES = {
|
||||
profile_gateway_stopped: 'Gateway gestoppt',
|
||||
profile_active: 'Aktiv',
|
||||
profile_no_configuration: 'Keine Konfiguration',
|
||||
profile_skill_count: '{count} Fähigkeiten',
|
||||
profile_skill_count: (count) => `${count} Fähigkeit${count === 1 ? '' : 'en'}`,
|
||||
profile_use: 'Verwenden',
|
||||
profile_switch_title: 'Profil wechseln',
|
||||
profile_delete_title: 'Profil löschen',
|
||||
@@ -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',
|
||||
@@ -6529,6 +6535,7 @@ const LOCALES = {
|
||||
kanban_description: '描述',
|
||||
kanban_description_placeholder: '選填 — 需要完成的事項、驗收標準、連結',
|
||||
kanban_status: '狀態',
|
||||
kanban_status_original_hint: '實際狀態:{0}。此對話框僅支援編輯 Triage/Todo/Ready。',
|
||||
kanban_assignee: '指派對象',
|
||||
kanban_assignee_placeholder: '選填 — 個人資料或名稱',
|
||||
kanban_tenant: '租戶',
|
||||
@@ -7648,6 +7655,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',
|
||||
@@ -8614,6 +8622,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',
|
||||
|
||||
+4
-3
@@ -160,8 +160,8 @@
|
||||
<div id="kanbanBulkBar" class="kanban-bulk-bar">
|
||||
<select id="kanbanBulkStatus" aria-label="Bulk status"><option value="">Status</option><option value="ready">Ready</option><option value="blocked">Blocked</option><option value="done">Done</option><option value="archived">Archived</option></select>
|
||||
<button class="btn secondary" onclick="bulkUpdateKanban()" data-i18n="kanban_bulk_action">Bulk action</button>
|
||||
<button class="btn secondary" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher" title="Dry-run: shows what would be claimed without spawning workers">Preview</button>
|
||||
<button class="btn primary" onclick="runKanbanDispatcher()" data-i18n="kanban_run_dispatcher" title="Claims Ready tasks and spawns worker subprocesses">Run dispatcher</button>
|
||||
<button class="btn secondary kanban-nudge-dispatch-btn" onclick="nudgeKanbanDispatcher()" data-i18n="kanban_nudge_dispatcher" title="Dry-run: shows what would be claimed without spawning workers">Preview</button>
|
||||
<button class="btn primary kanban-run-dispatch-btn" onclick="runKanbanDispatcher()" data-i18n="kanban_run_dispatcher" title="Claims Ready tasks and spawns worker subprocesses">Run dispatcher</button>
|
||||
</div>
|
||||
<div class="kanban-new-task-row">
|
||||
<input id="kanbanNewTaskTitle" placeholder="New task" data-i18n-placeholder="kanban_new_task" onkeydown="if(event.key==='Enter')createKanbanTask()">
|
||||
@@ -719,7 +719,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 has-tooltip has-tooltip--bottom" 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-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>
|
||||
</div>
|
||||
@@ -1279,6 +1279,7 @@
|
||||
<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>
|
||||
|
||||
+136
-11
@@ -12,6 +12,7 @@ let _kanbanLanesByProfile = false;
|
||||
let _kanbanCurrentBoard = null;
|
||||
let _kanbanBoardsList = null;
|
||||
let _kanbanBoardMenuOpen = false;
|
||||
let _kanbanIsDispatching = false;
|
||||
// SSE event stream — replaces the 30s polling cadence with a long-lived
|
||||
// /api/kanban/events/stream connection. Falls back to polling when the
|
||||
// EventSource fails to connect (proxy that strips text/event-stream, etc).
|
||||
@@ -1424,11 +1425,14 @@ function _kanbanBoardQuery(extra){
|
||||
}
|
||||
|
||||
async function nudgeKanbanDispatcher(){
|
||||
if (_kanbanIsDispatching) return;
|
||||
// Dry-run dispatch: show what WOULD be spawned, without actually spawning
|
||||
// workers. Uses ?dry_run=1 so the dispatcher reports its plan without
|
||||
// mutating the board. The result shape includes spawned/skipped_unassigned/
|
||||
// skipped_nonspawnable/promoted/auto_blocked so users can diagnose why a
|
||||
// Ready task isn't being picked up before they commit to a real run.
|
||||
_kanbanIsDispatching = true;
|
||||
_setKanbanDispatcherButtonsDisabled(true);
|
||||
try {
|
||||
const dispatchEndpoint = '/api/kanban/dispatch';
|
||||
const result = await api(
|
||||
@@ -1437,10 +1441,16 @@ async function nudgeKanbanDispatcher(){
|
||||
);
|
||||
showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000);
|
||||
await loadKanban(true);
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
} catch(e) {
|
||||
showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
_kanbanIsDispatching = false;
|
||||
_setKanbanDispatcherButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKanbanDispatcher(){
|
||||
if (_kanbanIsDispatching) return;
|
||||
// Real dispatch: claims Ready tasks and spawns worker subprocesses
|
||||
// (one `hermes -p <assignee>` per claimed row, up to max=8 per call).
|
||||
// Confirmation dialog first because this actually consumes API budget on
|
||||
@@ -1450,14 +1460,17 @@ async function runKanbanDispatcher(){
|
||||
showToast(t('kanban_unavailable') || 'Kanban unavailable', 'error');
|
||||
return;
|
||||
}
|
||||
const ok = await showConfirmDialog({
|
||||
title: t('kanban_run_dispatcher') || 'Run dispatcher',
|
||||
message: t('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?',
|
||||
confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher',
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
_kanbanIsDispatching = true;
|
||||
_setKanbanDispatcherButtonsDisabled(true);
|
||||
try {
|
||||
const ok = await showConfirmDialog({
|
||||
title: t('kanban_run_dispatcher') || 'Run dispatcher',
|
||||
message: t('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?',
|
||||
confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher',
|
||||
});
|
||||
if (!ok) return;
|
||||
const dispatchEndpoint = '/api/kanban/dispatch';
|
||||
const result = await api(
|
||||
dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''),
|
||||
@@ -1465,7 +1478,19 @@ async function runKanbanDispatcher(){
|
||||
);
|
||||
showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000);
|
||||
await loadKanban(true);
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
} catch(e) {
|
||||
showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
_kanbanIsDispatching = false;
|
||||
_setKanbanDispatcherButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
function _setKanbanDispatcherButtonsDisabled(disabled){
|
||||
document.querySelectorAll('.kanban-run-dispatch-btn, .kanban-nudge-dispatch-btn').forEach((btn) => {
|
||||
btn.disabled = !!disabled;
|
||||
btn.classList.toggle('disabled', !!disabled);
|
||||
});
|
||||
}
|
||||
|
||||
function _kanbanFormatDispatchResult(result, dryRun){
|
||||
@@ -1679,6 +1704,13 @@ 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 _kanbanProfileNamesCacheAt = 0;
|
||||
const _KANBAN_PROFILE_NAMES_CACHE_TTL_MS = 30000;
|
||||
function _invalidateKanbanProfileCache() {
|
||||
_kanbanProfileNamesCache = null;
|
||||
_kanbanProfileNamesCacheAt = 0;
|
||||
}
|
||||
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
|
||||
@@ -1687,11 +1719,16 @@ 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.
|
||||
// Hit /api/profiles once per session and cache for a short TTL.
|
||||
// Returns an array of profile names (sorted, default first if present).
|
||||
if (Array.isArray(_kanbanProfileNamesCache)) return _kanbanProfileNamesCache;
|
||||
const hasFreshCache = (
|
||||
Array.isArray(_kanbanProfileNamesCache) &&
|
||||
(Date.now() - _kanbanProfileNamesCacheAt) < _KANBAN_PROFILE_NAMES_CACHE_TTL_MS
|
||||
);
|
||||
if (hasFreshCache) return _kanbanProfileNamesCache;
|
||||
try {
|
||||
const data = await api('/api/profiles');
|
||||
const profiles = Array.isArray(data && data.profiles) ? data.profiles : [];
|
||||
@@ -1703,9 +1740,11 @@ async function _kanbanLoadProfileNames(){
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
_kanbanProfileNamesCache = names;
|
||||
_kanbanProfileNamesCacheAt = Date.now();
|
||||
return names;
|
||||
} catch(_) {
|
||||
_kanbanProfileNamesCache = [];
|
||||
_kanbanProfileNamesCacheAt = Date.now();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1771,6 +1810,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
|
||||
@@ -1784,6 +1824,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();
|
||||
@@ -1817,6 +1862,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 || '',
|
||||
@@ -1828,9 +1874,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(); }
|
||||
@@ -1879,18 +1931,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);
|
||||
}
|
||||
|
||||
@@ -2329,6 +2437,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
|
||||
@@ -2368,6 +2481,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);
|
||||
}
|
||||
@@ -2379,6 +2497,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);
|
||||
}
|
||||
|
||||
@@ -4184,6 +4306,7 @@ async function deleteCurrentProfile(){
|
||||
if(!_ok) return;
|
||||
try {
|
||||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
_invalidateKanbanProfileCache();
|
||||
_clearProfileDetail();
|
||||
await loadProfilesPanel();
|
||||
showToast(t('profile_deleted', name));
|
||||
@@ -4461,6 +4584,7 @@ async function saveProfileForm(){
|
||||
if (baseUrl) payload.base_url = baseUrl;
|
||||
if (apiKey) payload.api_key = apiKey;
|
||||
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });
|
||||
_invalidateKanbanProfileCache();
|
||||
_profilePreFormDetail = null;
|
||||
await loadProfilesPanel();
|
||||
showToast(t('profile_created', name));
|
||||
@@ -4481,6 +4605,7 @@ async function deleteProfile(name) {
|
||||
if(!_delProf) return;
|
||||
try {
|
||||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
_invalidateKanbanProfileCache();
|
||||
await loadProfilesPanel();
|
||||
showToast(t('profile_deleted', name));
|
||||
} catch (e) { showToast(t('delete_failed') + e.message); }
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
I18N_JS = (Path(__file__).resolve().parents[1] / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_locale_block(locale: str, src: str) -> str:
|
||||
locale_key_re = re.compile(
|
||||
rf"(?m)^[ \t]{{2}}(?:'{re.escape(locale)}'|\"{re.escape(locale)}\"|{re.escape(locale)})\s*:\s*\{{"
|
||||
)
|
||||
start_match = locale_key_re.search(src)
|
||||
assert start_match is not None, f"Locale {locale!r} not found in i18n.js"
|
||||
|
||||
brace_start = start_match.end() - 1
|
||||
assert brace_start != -1, f"Locale {locale!r} block has no opening brace"
|
||||
|
||||
next_locale_re = re.compile(
|
||||
r"(?m)^[ \t]{2}(?:[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?|'[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?'|\"[A-Za-z]{2,3}(?:[-_][A-Za-z0-9_]+)?\")\s*:\s*\{"
|
||||
)
|
||||
next_match = next_locale_re.search(src, pos=brace_start + 1)
|
||||
end = next_match.start() if next_match else len(src)
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, end):
|
||||
char = src[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace_start : idx + 1]
|
||||
assert False, f"Locale {locale!r} block did not close cleanly"
|
||||
|
||||
|
||||
def test_german_profile_skill_count_is_function():
|
||||
de_block = _extract_locale_block("de", I18N_JS)
|
||||
# German locale should pass count as an interpolation arg, not expose {count} verbatim.
|
||||
assert "profile_skill_count:" in de_block
|
||||
assert "{count} Fähigkeiten" not in de_block
|
||||
assert re.search(r"profile_skill_count:\s*\([^)]*\)\s*=>", de_block), (
|
||||
"profile_skill_count in de locale should be an arrow function, not a string template"
|
||||
)
|
||||
@@ -11,6 +11,15 @@ COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
|
||||
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
|
||||
|
||||
|
||||
def _locale_blocks_with_body(i18n_text: str):
|
||||
locale_blocks = re.findall(
|
||||
r"\n\s*(?:'(?P<quoted>[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P<plain>[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},",
|
||||
i18n_text,
|
||||
flags=re.S,
|
||||
)
|
||||
return [(quoted or plain, body) for quoted, plain, body in locale_blocks]
|
||||
|
||||
|
||||
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
|
||||
assert 'data-panel="kanban"' in INDEX
|
||||
assert 'data-i18n-title="tab_kanban"' in INDEX
|
||||
@@ -257,6 +266,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(
|
||||
@@ -268,6 +281,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.
|
||||
@@ -292,6 +307,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.
|
||||
@@ -301,6 +317,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():
|
||||
@@ -413,6 +483,38 @@ def test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview():
|
||||
assert token in fmt_body, f"dispatch summary missing field: {token}"
|
||||
|
||||
|
||||
def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion():
|
||||
"""Guard against concurrent dispatch invocations in both nudge and real run paths."""
|
||||
assert "let _kanbanIsDispatching = false;" in PANELS
|
||||
assert "function _setKanbanDispatcherButtonsDisabled" in PANELS
|
||||
|
||||
run_match = re.search(r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
||||
assert run_match, "runKanbanDispatcher() not found"
|
||||
run_body = run_match.group(1)
|
||||
assert "_kanbanIsDispatching" in run_body, (
|
||||
"runKanbanDispatcher() must check or set _kanbanIsDispatching to block concurrent execution."
|
||||
)
|
||||
assert "finally" in run_body, "runKanbanDispatcher() must always clear _kanbanIsDispatching in finally."
|
||||
assert "_setKanbanDispatcherButtonsDisabled(true)" in run_body, (
|
||||
"runKanbanDispatcher() should disable both dispatcher buttons while posting."
|
||||
)
|
||||
assert "_setKanbanDispatcherButtonsDisabled(false)" in run_body, (
|
||||
"runKanbanDispatcher() should re-enable dispatcher buttons when done."
|
||||
)
|
||||
|
||||
nudge_match = re.search(r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
||||
assert nudge_match, "nudgeKanbanDispatcher() not found"
|
||||
nudge_body = nudge_match.group(1)
|
||||
assert "_kanbanIsDispatching" in nudge_body, (
|
||||
"nudgeKanbanDispatcher() should also respect the dispatch in-flight guard."
|
||||
)
|
||||
assert "finally" in nudge_body, "nudgeKanbanDispatcher() should always clear guard in finally."
|
||||
|
||||
assert 'kanban-run-dispatch-btn' in INDEX
|
||||
assert 'kanban-nudge-dispatch-btn' in INDEX
|
||||
assert 'btnKanbanRunDispatcher' in INDEX
|
||||
assert 'btnKanbanPreviewDispatcher' in INDEX
|
||||
|
||||
|
||||
def test_kanban_board_has_native_css_classes():
|
||||
for selector in (
|
||||
@@ -439,8 +541,8 @@ def test_kanban_main_view_scrolls_when_task_preview_is_tall():
|
||||
|
||||
|
||||
def test_kanban_i18n_keys_exist_in_every_locale_block():
|
||||
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
||||
assert len(locale_blocks) >= 8
|
||||
locale_blocks = _locale_blocks_with_body(I18N)
|
||||
assert len(locale_blocks) >= 9
|
||||
required_keys = [
|
||||
"tab_kanban",
|
||||
"kanban_board",
|
||||
@@ -472,6 +574,39 @@ def test_kanban_i18n_keys_exist_in_every_locale_block():
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_kanban_modal_locale_parity():
|
||||
"""Parity check for modal-facing Kanban i18n keys.
|
||||
|
||||
Any locale that already contains modal-facing Kanban strings should include the
|
||||
same set of modal vocabulary so new additions don't regress into locale gaps.
|
||||
"""
|
||||
locale_blocks = _locale_blocks_with_body(I18N)
|
||||
modal_keys = [
|
||||
"kanban_title",
|
||||
"kanban_description",
|
||||
"kanban_description_placeholder",
|
||||
"kanban_status",
|
||||
"kanban_assignee",
|
||||
"kanban_assignee_placeholder",
|
||||
"kanban_tenant",
|
||||
"kanban_tenant_placeholder",
|
||||
"kanban_priority",
|
||||
"kanban_priority_hint",
|
||||
"kanban_title_required",
|
||||
"kanban_status_original_hint",
|
||||
]
|
||||
anchor_key = "kanban_status"
|
||||
missing = [
|
||||
f"{locale}:{key}"
|
||||
for locale, body in locale_blocks
|
||||
if re.search(rf"\b{re.escape(anchor_key)}\s*:", body) is not None
|
||||
for key in modal_keys
|
||||
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
||||
]
|
||||
assert missing == []
|
||||
|
||||
|
||||
|
||||
|
||||
def test_kanban_dashboard_parity_core_controls_are_native():
|
||||
assert 'id="kanbanOnlyMine"' in INDEX
|
||||
@@ -504,7 +639,7 @@ def test_kanban_dashboard_parity_core_controls_are_native():
|
||||
|
||||
|
||||
def test_kanban_dashboard_parity_i18n_keys_exist():
|
||||
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
||||
locale_blocks = _locale_blocks_with_body(I18N)
|
||||
required_keys = [
|
||||
"kanban_only_mine",
|
||||
"kanban_bulk_action",
|
||||
@@ -575,7 +710,7 @@ def test_kanban_ui_parity_polish_css_and_i18n_exist():
|
||||
".hermes-kanban-md",
|
||||
):
|
||||
assert selector in STYLE
|
||||
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
||||
locale_blocks = _locale_blocks_with_body(I18N)
|
||||
required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"]
|
||||
missing = [
|
||||
f"{locale}:{key}"
|
||||
@@ -769,6 +904,52 @@ def test_kanban_active_board_persisted_to_localstorage():
|
||||
assert "_kanbanSetSavedBoard" in PANELS
|
||||
|
||||
|
||||
def test_kanban_profile_assignee_cache_has_invalidation_path():
|
||||
"""Kanban assignee suggestions should stay aligned with profile mutations.
|
||||
|
||||
The cache in _kanbanLoadProfileNames() can become stale when profiles are
|
||||
created or deleted in the same session. This adds an explicit
|
||||
invalidation path and a short TTL so modal opens recover from same-session
|
||||
mutations and cross-tab/CLI changes.
|
||||
"""
|
||||
assert "_KANBAN_PROFILE_NAMES_CACHE_TTL_MS" in PANELS
|
||||
assert "_kanbanProfileNamesCacheAt" in PANELS
|
||||
assert "_invalidateKanbanProfileCache" in PANELS
|
||||
|
||||
load_start = PANELS.find("async function _kanbanLoadProfileNames(){")
|
||||
assert load_start != -1, "Missing _kanbanLoadProfileNames() declaration"
|
||||
load_end = PANELS.find("\n}\n\nasync function _kanbanPopulateAssigneeSelect", load_start)
|
||||
if load_end == -1:
|
||||
load_end = PANELS.find("\n}\n\nfunction openKanbanCreate", load_start)
|
||||
load_body = PANELS[load_start:load_end] if load_end != -1 else PANELS[load_start:load_start + 2200]
|
||||
assert "Date.now() - _kanbanProfileNamesCacheAt" in load_body
|
||||
assert "_kanbanProfileNamesCacheAt = Date.now()" in load_body
|
||||
|
||||
save_start = PANELS.find("async function saveProfileForm(){")
|
||||
assert save_start != -1, "Missing saveProfileForm() declaration"
|
||||
save_end = PANELS.find("\n}\n\n// Back-compat", save_start)
|
||||
save_body = PANELS[save_start:save_end if save_end != -1 else save_start + 2000]
|
||||
assert "_invalidateKanbanProfileCache();" in save_body, (
|
||||
"Profile create flow should invalidate Kanban assignee cache after success."
|
||||
)
|
||||
|
||||
delete_start = PANELS.find("async function deleteProfile(name) {")
|
||||
assert delete_start != -1, "Missing deleteProfile() declaration"
|
||||
delete_end = PANELS.find("\n\n// ── Memory panel", delete_start)
|
||||
delete_body = PANELS[delete_start:delete_end if delete_end != -1 else delete_start + 1300]
|
||||
assert "_invalidateKanbanProfileCache();" in delete_body, (
|
||||
"Profile delete flow should invalidate Kanban assignee cache after success."
|
||||
)
|
||||
|
||||
ui_delete_start = PANELS.find("async function deleteCurrentProfile(){")
|
||||
assert ui_delete_start != -1, "Missing deleteCurrentProfile() declaration"
|
||||
ui_delete_end = PANELS.find("\n\nfunction renderProfileDropdown", ui_delete_start)
|
||||
ui_delete_body = PANELS[ui_delete_start:ui_delete_end if ui_delete_end != -1 else ui_delete_start + 1300]
|
||||
assert "_invalidateKanbanProfileCache();" in ui_delete_body, (
|
||||
"Profile detail delete flow (deleteCurrentProfile) should invalidate Kanban assignee cache after success."
|
||||
)
|
||||
|
||||
|
||||
def test_kanban_archive_board_uses_showConfirmDialog():
|
||||
"""Archive is destructive → must use the styled showConfirmDialog,
|
||||
not native confirm() (which can't be styled or i18n'd)."""
|
||||
|
||||
Reference in New Issue
Block a user