From 72ff9b9b736e1d7265fb8baaceed03c1acc377c3 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 13:36:03 +0200 Subject: [PATCH 1/3] feat: offer "refresh now" on header views too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh entry was hidden wherever there was no query (`queryRef == null`), which is exactly the header views. That was right while refreshing meant re-running a query, but since #654 it also re-resolves the view definition — and for a header view the definition is the whole of what it shows: title, description and actions. So it now has something to refresh, and the entry is offered. "show query" and "full screen" stay hidden there; they really do need a query. The click takes the same path a query-form view takes: nothing on screen to rebuild, so the version is re-checked and the page left alone unless a newer version turns up, in which case the structure is refreshed and the page re-rendered as before. Verified live on the home page's four header views: the entry now appears (with the query-dependent ones still absent), the click issues a forced get-latest-version-of-np lookup for the header's nanopub, and the page does not re-render. Co-Authored-By: Claude Opus 5 (1M context) --- .../component/menu/ViewDisplayMenu.java | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java index f2dff824..6615363f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java @@ -53,7 +53,9 @@ public class ViewDisplayMenu extends BaseDisplayMenu { * @param viewDisplay the view display this menu acts on (must have a non-null nanopub) * @param queryRef the query reference used by this view display, or null for a * query-less view (a header view), which hides the - * query-dependent entries (show query, full screen, refresh) + * query-dependent entries (show query, full screen). "refresh now" + * stays: since issue #654 it brings the view definition up to date + * too, which is the whole of what a header view has to refresh. * @param pageResource the page-level resource used to determine whether "adjust" is visible * @param viewActions the view-level actions to show as top entries (may be empty) */ @@ -209,7 +211,9 @@ protected void onConfigure() { AjaxFallbackLink refreshLink = new AjaxFallbackLink<>("refreshNow") { @Override public void onClick(Optional target) { - ApiCache.clearCache(queryRef, 0); + // A header view has no query, and so nothing to mark outdated here — its + // whole content comes from the view definition re-checked just below. + if (queryRef != null) ApiCache.clearCache(queryRef, 0); // Bringing a view up to date is not only a matter of re-running its query: // the view definition itself can have been superseded since this page was // built, and neither the memoized resolution nor the version the page's @@ -233,11 +237,12 @@ public void onClick(Optional target) { if (view == null && target.isPresent()) { // Not every view display puts results in the page. A query-form view // shows a form, and the results it leads to live on the page it submits - // to, so there is nothing here to bring up to date: the query has just - // been marked outdated for that next submit, and the view definition has - // been re-checked above. Re-rendering the page on top of that would - // repaint everything for no visible change — which is what made these - // views, alone among the display types, flicker on every refresh. + // to; a header view has no query at all. Either way there is nothing + // here to bring up to date once the view definition has been re-checked + // above (and, for the form, its query marked outdated for the next + // submit). Re-rendering the page on top of that would repaint everything + // for no visible change — which is what made query-form views, alone + // among the display types then offering a refresh, flicker on every one. return; } // A view is not always what stands in the page: while it waits for its first @@ -269,7 +274,10 @@ public void onClick(Optional target) { } }; - refreshLink.setVisible(session.getUserIri() != null && queryRef != null); + // Offered wherever there is something to bring up to date. That used to mean a query, + // but since issue #654 the refresh re-resolves the view definition as well, which is + // all a query-less header view consists of — so it is offered there too. + refreshLink.setVisible(session.getUserIri() != null && (queryRef != null || shownViewId != null)); addEntry("refreshNow", refreshLink); BookmarkablePageLink viewDeclarationLink = new BookmarkablePageLink<>("viewDeclaration", ExplorePage.class, From 81c1d2340fcb91203a30b4ed6d2a9369155cf147 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 13:49:47 +0200 Subject: [PATCH 2/3] fix: show the update spinner for a refresh the user asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "refresh now" answered with nothing visible on a header view, and for two reasons. The client-side indicator looks for a ".paneltitlerow" to put the spinner in, and a header view's title row is built differently — an h3 above a rule rather than an h4 — so no row was found and the panel was skipped entirely. Both row shapes are now recognised, with CSS placing the spinner in the header row the way it sits in a panel's: after the title, its auto right margin absorbing the space the title gives up, so nothing moves when it appears. The spinner is also held back 250ms, which is right for updates that happen as a side effect of typing or paging but wrong for one the user clicked for: these round trips take about a tenth of a second, so the delay alone left every "refresh now" silent. An explicitly requested refresh — the markup says which, via a class on the entry — now shows at once and stays 600ms, long enough to read. Verified live on the home page's header views and on a space page's regular and query-form views: the spinner appears ~5ms into the call where it previously never appeared at all (round trips measured 74-127ms), the title and the menu stay exactly where they were, it is gone afterwards, and the regular views still refresh in place. Co-Authored-By: Claude Opus 5 (1M context) --- .../component/menu/ViewDisplayMenu.html | 2 +- .../nanodash/script/nanodash.js | 54 ++++++++++++++++--- src/main/webapp/style.css | 24 +++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.html b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.html index 7209f8b0..60d53d7e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.html @@ -16,7 +16,7 @@ edit view display... deactivate view display... add to my own profile... - refresh now + refresh now show nanopub diff --git a/src/main/java/com/knowledgepixels/nanodash/script/nanodash.js b/src/main/java/com/knowledgepixels/nanodash/script/nanodash.js index f872715b..9211bc6e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/script/nanodash.js +++ b/src/main/java/com/knowledgepixels/nanodash/script/nanodash.js @@ -178,12 +178,35 @@ function makeSpinner() { var UPDATE_SPINNER_DELAY_MS = 250; /* Backstop for a call that never reports completion, so a spinner cannot get stuck. */ var UPDATE_SPINNER_MAX_MS = 30000; +/* An update the user explicitly asked for — a view's "refresh now" — is the exception to + the delay above: it shows at once and stays long enough to be read. The delay is there so + that updates happening *incidentally*, as a side effect of typing or paging, do not + flicker; a refresh someone clicked for is the opposite case, and answering it with + nothing visible reads as a click that did nothing. Most of these round trips finish in + about a tenth of a second, which is why the delay alone left them silent. */ +var REQUESTED_SPINNER_MIN_MS = 600; var updatingPanels = new Map(); function isVisible(el) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); } +/* The title rows a spinner can go in: a regular view panel's, and a header view's, which + is built differently (an h3 under a rule rather than an h4) but sits in the same place + and means the same thing. */ +var TITLE_ROW_SELECTOR = ".paneltitlerow, .view-header-titlerow"; +var TITLE_SELECTOR = "h4, h3"; + +/* Whether an Ajax call came from a control the user clicked to ask for a refresh, as + opposed to one where updating is a side effect (a filter field, a paging link). The + markup says so: "refresh now" carries the class. */ +function isRequestedRefresh(attributes) { + var id = attributes && attributes.c; + if (!id || typeof id !== "string") return false; + var el = document.getElementById(id); + return !!(el && el.classList.contains("refresh-request")); +} + /* The view panel an Ajax call was triggered from, or null for calls that belong to no single panel — the page-wide lazy-load and refresh-poll timers among them, which is why they never light up every panel on the page. */ @@ -195,7 +218,7 @@ function findUpdatingPanel(attributes) { var panel = el.closest('[class*="col-"]'); // A view panel is a column with a title row; anything else (a page-level column, a // form) is left alone, since the gutter position is meaningless there. - return panel && panel.querySelector(".paneltitlerow") ? panel : null; + return panel && panel.querySelector(TITLE_ROW_SELECTOR) ? panel : null; } function showUpdateSpinner(panel) { @@ -207,9 +230,9 @@ function showUpdateSpinner(panel) { if (existing && isVisible(existing)) return; // Right after the title, where the view's own spinner goes; the title row's layout keeps // it clear of the title icon and of the filter and menu on the right. - var titleRow = panel.querySelector(".paneltitlerow"); - var title = titleRow ? titleRow.querySelector("h4") : null; + var titleRow = panel.querySelector(TITLE_ROW_SELECTOR); if (!titleRow) return; + var title = titleRow.querySelector(TITLE_SELECTOR); var spinner = makeSpinner(); spinner.title = "Updating..."; panel.classList.add("view-refreshing"); @@ -219,8 +242,18 @@ function showUpdateSpinner(panel) { function hideUpdateSpinner(panel) { var state = updatingPanels.get(panel); - updatingPanels.delete(panel); if (!state) return; + // An explicitly requested refresh keeps its spinner until it has been visible long + // enough to register, however quickly the server answered. + if (state.minUntil) { + var left = state.minUntil - performance.now(); + if (left > 0) { + state.minUntil = null; + setTimeout(function () { hideUpdateSpinner(panel); }, left); + return; + } + } + updatingPanels.delete(panel); if (state.showTimer) clearTimeout(state.showTimer); if (state.maxTimer) clearTimeout(state.maxTimer); if (!state.spinner) return; @@ -232,16 +265,21 @@ function hideUpdateSpinner(panel) { } } -function onUpdateStart(panel) { +function onUpdateStart(panel, requested) { var state = updatingPanels.get(panel); if (state) { state.count++; return; } - state = {count: 1, spinner: null, showTimer: null, maxTimer: null}; + state = {count: 1, spinner: null, showTimer: null, maxTimer: null, minUntil: null}; updatingPanels.set(panel, state); - state.showTimer = setTimeout(function () { showUpdateSpinner(panel); }, UPDATE_SPINNER_DELAY_MS); state.maxTimer = setTimeout(function () { hideUpdateSpinner(panel); }, UPDATE_SPINNER_MAX_MS); + if (requested) { + state.minUntil = performance.now() + REQUESTED_SPINNER_MIN_MS; + showUpdateSpinner(panel); + } else { + state.showTimer = setTimeout(function () { showUpdateSpinner(panel); }, UPDATE_SPINNER_DELAY_MS); + } } function onUpdateEnd(panel) { @@ -255,7 +293,7 @@ function trackAjaxUpdates() { if (typeof Wicket === "undefined" || !Wicket.Event) return; Wicket.Event.subscribe("/ajax/call/before", function (jqEvent, attributes) { var panel = findUpdatingPanel(attributes); - if (panel) onUpdateStart(panel); + if (panel) onUpdateStart(panel, isRequestedRefresh(attributes)); }); Wicket.Event.subscribe("/ajax/call/complete", function (jqEvent, attributes) { var panel = findUpdatingPanel(attributes); diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 0f183866..4f2b9f91 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -2515,6 +2515,30 @@ p.waiting { margin-left: 0; } +/* A header view's title row is built differently — an h3 above a rule, baseline-aligned, + with no filter field — but the spinner means the same thing and goes to the same place. + Centred rather than baseline-aligned: an empty inline-block's baseline is its own bottom + edge, which would hang it below the title's. */ +.view-header-titlerow > .refresh-spinner { + width: 16px; + height: 16px; + border-width: 2px; + margin-left: 10px; + margin-right: auto; + align-self: center; + flex: none; +} + +/* As in a panel title row: the title stops filling the row so the spinner takes that space + instead, and the buttons' auto left margin gives way to the spinner's auto right one. */ +[class*="col-"].section-header .view-header-titlerow:has(> .refresh-spinner) h3 { + flex: 0 1 auto; +} + +.view-header-titlerow:has(> .refresh-spinner) > span.buttons { + margin-left: 0; +} + /* Standing on its own — a whole page section or panel body with nothing in it yet — the same spinner is drawn larger: there is no title or message beside it to carry the eye, and none of the gutter's width limit applies. */ From dce479bf19c389bf263b3a0938572bf6f28f41d9 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 14:01:01 +0200 Subject: [PATCH 3/3] fix: sit the header view's refresh spinner on the title's baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centring it in the row put it 6.8px above the middle of the title text: the row is taller than the text by the heading's 18.9px top margin, so the row's middle is not the text's. It now rides the row's baseline. An empty inline-block has no line box of its own, so its baseline is its bottom edge, which lands the circle on the title's baseline like a capital letter — no magic number, and it follows the heading if its size ever changes. Measured on the home page's header views: the spinner's middle is now 0.9px from the text's (was 6.8px above it) and its bottom sits on the baseline, 4.9px above the line box's bottom. Title and menu still do not move when it appears. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/webapp/style.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 4f2b9f91..2b92b5cd 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -2517,15 +2517,17 @@ p.waiting { /* A header view's title row is built differently — an h3 above a rule, baseline-aligned, with no filter field — but the spinner means the same thing and goes to the same place. - Centred rather than baseline-aligned: an empty inline-block's baseline is its own bottom - edge, which would hang it below the title's. */ + It rides the row's baseline: an empty inline-block has no line box of its own, so its + baseline is its bottom edge, which lands the circle on the title's baseline like a + capital letter. Centring it in the row instead would sit it 7px high, since the row is + taller than the text by the heading's top margin. */ .view-header-titlerow > .refresh-spinner { width: 16px; height: 16px; border-width: 2px; margin-left: 10px; margin-right: auto; - align-self: center; + align-self: baseline; flex: none; }