diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 2a6a31d9b..d691798ec 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -952,8 +952,20 @@ function _libCachePut(key, value) { } } +function _resetBulkSelectionForContextChange({ rerender = false } = {}) { + const hadSelection = !!(state._selectedUids && state._selectedUids.size); + const wasSelectMode = !!state._selectMode; + if (state._selectedUids) state._selectedUids.clear(); + state._selectMode = false; + if (hadSelection || wasSelectMode) { + _updateBulkBar(); + if (rerender) _renderGrid(); + } +} + function _resetEmailListForFreshLoad() { _exitEmailReaderModeForList(); + _resetBulkSelectionForContextChange(); state._libOffset = 0; state._libEmails = []; state._libTotal = 0; @@ -2507,6 +2519,7 @@ function _clearFilterPillSideEffect() { function _addSearchPill(pill) { if (!pill) return; + _resetBulkSelectionForContextChange({ rerender: true }); if (!Array.isArray(state._libSearchPills)) state._libSearchPills = []; // Dedup by email (contact), text (text pill), or filter value. if (pill.type === 'contact') { @@ -2541,6 +2554,7 @@ function _searchQueryFromPills() { function _removeSearchPillAt(idx) { if (!Array.isArray(state._libSearchPills)) return; + _resetBulkSelectionForContextChange({ rerender: true }); const removed = state._libSearchPills[idx]; state._libSearchPills.splice(idx, 1); if (removed && removed.type === 'filter') _clearFilterPillSideEffect(); @@ -2718,6 +2732,7 @@ async function _initEmailSearchChipBar() { // directly. let _libSearchTypingTimer = null; input.addEventListener('input', async () => { + _resetBulkSelectionForContextChange({ rerender: true }); state._libSearchDraft = input.value; await _refreshSuggestions(); if (_libSearchTypingTimer) clearTimeout(_libSearchTypingTimer); @@ -2853,6 +2868,7 @@ window.addEventListener('click', (e) => { async function _doSearch() { _exitEmailReaderModeForList(); + _resetBulkSelectionForContextChange({ rerender: true }); const seq = ++_libSearchSeq; const derived = _deriveSearchScope(state._libSearch); const q = derived.q; diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 900e0a665..4434784cc 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -12,6 +12,16 @@ def _bulk_action_source() -> str: return text[start:end] +def _function_source(name: str) -> str: + text = _EMAIL_LIBRARY.read_text(encoding="utf-8") + start = text.index(f"function {name}") + next_function = text.find("\nfunction ", start + 1) + next_async = text.find("\nasync function ", start + 1) + candidates = [idx for idx in (next_function, next_async) if idx != -1] + end = min(candidates) if candidates else len(text) + return text[start:end] + + def test_email_bulk_read_unread_calls_provider_write_routes(): """Bulk read/unread must persist to IMAP/provider, not only mutate UI state. @@ -34,3 +44,33 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache(): assert "data?.success === false" in src assert "throw new Error(data?.error" in src assert "_libCacheWriteBack()" in src + + +def test_email_context_changes_clear_bulk_selection_state(): + """IMAP UIDs are folder/account scoped, so stale bulk selections must die. + + Folder, account, filter, quick-filter, attachment, and search basis changes + must exit select mode before the next list/search view can run bulk actions. + """ + text = _EMAIL_LIBRARY.read_text(encoding="utf-8") + reset_src = _function_source("_resetBulkSelectionForContextChange") + fresh_src = _function_source("_resetEmailListForFreshLoad") + add_pill_src = _function_source("_addSearchPill") + remove_pill_src = _function_source("_removeSearchPillAt") + search_src = text[text.index("async function _doSearch()"):text.index("// Custom dropdown", text.index("async function _doSearch()"))] + + assert "state._selectedUids.clear()" in reset_src + assert "state._selectMode = false" in reset_src + assert "_updateBulkBar()" in reset_src + + assert "_resetBulkSelectionForContextChange()" in fresh_src + assert "_resetBulkSelectionForContextChange({ rerender: true })" in add_pill_src + assert "_resetBulkSelectionForContextChange({ rerender: true })" in remove_pill_src + assert "_resetBulkSelectionForContextChange({ rerender: true })" in search_src + + assert "state._libFolder = e.target.value;" in text + assert "state._libFilter = e.target.value;" in text + assert "state._libHasAttachments = !state._libHasAttachments;" in text + assert "state._libAccountId = btn.dataset.accId || null;" in text + assert text.count("_loadEmailsFresh();") >= 5 + assert "state._libSearchDraft = input.value;" in text