UI - Fix for Mark all viewed" doesn't update UI until page is manually refreshed #4021

This commit is contained in:
dgtlmoon
2026-08-16 15:55:43 +02:00
parent ee5bbcdfc6
commit aea042f8a4
6 changed files with 93 additions and 5 deletions
@@ -121,7 +121,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
deals_count=deals_count,
unread_count=unread_count,
processor_counts=processor_counts,
extra_classes=' '.join(filter(None, ['has-queue' if not update_q.empty() else '', 'llm-configured' if llm_configured else ''])),
# body classes for app-wide state; realtime.js keeps these in sync live
# (has-any-unviewed reveals the "Mark all viewed" button - see _watch_table.scss)
extra_classes=' '.join(filter(None, ['has-queue' if not update_q.empty() else '',
'llm-configured' if llm_configured else '',
'has-any-unviewed' if datastore.unread_changes_count else ''])),
form=form,
generate_tag_colors=processors.generate_processor_badge_colors,
wcag_text_color=processors.wcag_text_color,
@@ -268,9 +268,11 @@ window.watchOverviewI18n = {
</div>
{% endif %}
<div id="buttons-for-all-watches">
{%- if unread_count -%}
{# Always rendered, hidden by CSS unless #watch-table-wrapper has .has-unread-changes.
It used to be {% if unread_count %}-gated, which meant it could not exist in the DOM
of another open tab — so marking viewed (or a new change arriving) in one tab could
never show/hide it in the other. Presence in the DOM is what lets realtime toggle it. #}
<a id="post-list-mark-views" class="cdio-btn" href="{{ filtered_action_url('ui.mark_all_viewed') }}"><i data-feather="eye"></i>{{ _('Mark all viewed') }}</a>
{%- endif -%}
<a id="recheck-all" class="cdio-btn" href="{{ filtered_action_url('ui.form_watch_checknow') }}"><i data-feather="refresh-cw"></i>{{ _('Recheck all') }} {% if active_tag_uuid %}{{ _("in '%(title)s'", title=active_tag.title) }}{% endif %}</a>
</div>
<a title="{{ _('RSS Feed') }}" href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
+5
View File
@@ -242,6 +242,11 @@ $(document).ready(function () {
})
socket.on('general_stats_update', function (general_stats) {
// Drives the "Mark all viewed" button, which is always in the DOM and revealed by
// this class (see body.has-any-unviewed in _watch_table.scss). Emitted whenever a
// worker finishes a watch, and once after bulk ops like mark-all-viewed - so every
// open tab shows/hides the button without needing a reload.
document.body.classList.toggle('has-any-unviewed', general_stats.unread_changes_count !== 0);
$('#watch-table-wrapper').toggleClass("has-unread-changes", general_stats.unread_changes_count !==0)
$('#watch-table-wrapper').toggleClass("has-error", general_stats.count_errors !== 0)
// NB: the watch-list status .seg counts (unread/errors/deals) are
@@ -425,6 +425,20 @@ body.watch-selection-active {
flex-wrap: wrap;
gap: $common-gap;
margin: 0;
/* Always in the DOM so realtime can reveal/hide it in any open tab (it used to be
{% if unread_count %}'d away, so another tab could never show it). Revealed by
body.has-any-unviewed - a body class rather than a selector tied to this toolbar,
so it keeps working if the button moves, and it follows the same convention as
body.has-queue / body.is-checking-now. Set server-side via extra_classes and kept
live by the general_stats_update socket event. */
#post-list-mark-views {
display: none;
}
}
body.has-any-unviewed #post-list-mark-views {
display: inline-flex !important;
}
#watch-table-wrapper {
@@ -452,9 +466,11 @@ body.watch-selection-active {
}
}
/* #post-list-mark-views is no longer keyed off this class see body.has-any-unviewed.
Two classes tracking one fact is how they drift, so prefer the body class for anything new. */
&.has-unread-changes {
#list-related-buttons {
#post-list-unread, #post-list-mark-views {
#post-list-unread {
display: inline-flex !important;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Re #4021 - the "Mark all viewed" button must be driven by live state, not by render-time presence.
The button used to be wrapped in {% if unread_count %}, so when nothing was unread it did not
exist in the DOM at all. A second tab could therefore never show it after a change arrived, and
never hide it after another tab marked everything viewed - no socket event can style an element
that isn't there.
It is now always rendered and revealed by body.has-any-unviewed, which is set server-side via
extra_classes and kept live by the general_stats_update socket event. These assertions pin both
halves: the element is always present, and the body class tracks the unviewed state.
"""
from flask import url_for
from .util import set_original_response, set_modified_response, wait_for_all_checks, delete_all_watches
def _body_has_any_unviewed(res):
"""The <body> class list is what CSS keys off - realtime.js toggles the same class."""
for line in res.data.decode('utf-8').split('\n'):
if '<body class="' in line:
return 'has-any-unviewed' in line
raise AssertionError("no <body class=...> found in response")
def test_mark_all_viewed_button_is_always_in_the_dom(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": url_for('test_endpoint', _external=True), "tags": ''},
follow_redirects=True
)
wait_for_all_checks(client)
# First snapshot only - nothing is unread yet
res = client.get(url_for("watchlist.index"))
assert b'id="post-list-mark-views"' in res.data, \
"Button must be in the DOM even with nothing unread, or another tab can never reveal it"
assert not _body_has_any_unviewed(res), "Nothing unread, so the body class must be absent"
# A real change lands -> unviewed
set_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'id="post-list-mark-views"' in res.data
assert _body_has_any_unviewed(res), "Unviewed change present, so the body class must be set"
# Mark all viewed runs synchronously (Re #4021), so the redirect must already reflect it
res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
assert not _body_has_any_unviewed(res), \
"mark_all_viewed must be applied before the redirect renders - it used to run in a " \
"background thread and the page rendered mid-marking"
assert b'id="post-list-mark-views"' in res.data, "Still in the DOM, just hidden by CSS"
assert b'class="has-unread-changes' not in res.data
delete_all_watches(client)