• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanodash / 34462115786

10 Sep 2026 09:42AM UTC coverage: 47.846% (+0.5%) from 47.343%
34462115786

push

github

web-flow
Merge pull request #703 from knowledgepixels/feat/701-paragraph-part-page-link

feat(views): let a paragraph link to its own part page

4707 of 10579 branches covered (44.49%)

Branch coverage included in aggregate %.

8400 of 16815 relevant lines covered (49.96%)

8.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.61
src/main/java/com/knowledgepixels/nanodash/QueryResult.java
1
package com.knowledgepixels.nanodash;
2

3
import com.knowledgepixels.nanodash.component.QueryResultComponentFactory;
4
import com.knowledgepixels.nanodash.component.menu.ViewDisplayMenu;
5
import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile;
6
import com.knowledgepixels.nanodash.page.NanodashPage;
7
import com.knowledgepixels.nanodash.page.PublishPage;
8
import org.apache.wicket.Component;
9
import org.apache.wicket.markup.html.WebMarkupContainer;
10
import org.apache.wicket.markup.html.basic.Label;
11
import org.apache.wicket.markup.html.panel.Panel;
12
import org.apache.wicket.request.mapper.parameter.PageParameters;
13
import org.apache.wicket.util.string.Strings;
14
import org.nanopub.extra.services.ApiResponse;
15
import org.nanopub.extra.services.QueryRef;
16

17
import java.io.Serializable;
18
import java.util.ArrayList;
19
import java.util.List;
20
import java.util.Objects;
21
import java.util.regex.Matcher;
22
import java.util.regex.Pattern;
23

24
/**
25
 * Abstract base class for displaying query results in different formats.
26
 */
27
public abstract class QueryResult extends Panel {
28

29
    /**
30
     * A view-level action, shown as a top entry of the view's dropdown menu.
31
     */
32
    public record MenuAction(String label, Class<? extends NanodashPage> pageClass, PageParameters params) implements Serializable {
36✔
33
    }
34

35
    protected final List<MenuAction> menuActions = new ArrayList<>();
15✔
36
    protected String contextId;
37
    protected String partId;
38
    protected String postPublishTab;
39
    // The ref (root definition) this view is pinned to (?root=), used to scope per-entry
40
    // action visibility to the claimant being viewed. Null = the resource's representative
41
    // ref. See docs/space-ref-identity.md.
42
    protected String refRoot;
43
    protected boolean finalized = false;
9✔
44
    protected final QueryRef queryRef;
45
    protected final ViewDisplay viewDisplay;
46
    protected final ApiResponse response;
47
    protected AbstractResourceWithProfile resourceWithProfile;
48
    protected AbstractResourceWithProfile pageResource;
49
    protected boolean showViewDisplayMenu = true;
9✔
50
    protected final GrlcQuery grlcQuery;
51

52
    /**
53
     * Constructor for QueryResult.
54
     *
55
     * @param markupId    the markup ID
56
     * @param queryRef    the query reference
57
     * @param response    the API response
58
     * @param viewDisplay the view display
59
     */
60
    public QueryResult(String markupId, QueryRef queryRef, ApiResponse response, ViewDisplay viewDisplay) {
61
        super(markupId);
9✔
62
        this.queryRef = queryRef;
9✔
63
        this.viewDisplay = viewDisplay;
9✔
64
        this.response = response;
9✔
65
        this.grlcQuery = GrlcQuery.get(queryRef);
12✔
66

67
        // Every view carries an id in the rendered page, so that it can be replaced on its
68
        // own over Ajax — which is how "refresh now" updates one view without re-rendering
69
        // the page. Without it the replacement would be written under an id the browser has
70
        // never seen, leaving the old markup (and its now-removed links) in place.
71
        setOutputMarkupId(true);
12✔
72

73
        // The spinner shown beside the title while this view's results are being brought up
74
        // to date; hidden until someone turns it on (see RefreshingResultPanel). It lives in
75
        // the title row, right after the title, where the row's own layout keeps it clear of
76
        // everything — see the .refresh-spinner rules in style.css.
77
        refreshIndicator = new WebMarkupContainer("refresh-indicator");
18✔
78
        refreshIndicator.setOutputMarkupPlaceholderTag(true);
15✔
79
        refreshIndicator.setVisible(false);
15✔
80
        add(refreshIndicator);
30✔
81
    }
3✔
82

83
    private final WebMarkupContainer refreshIndicator;
84

85
    /**
86
     * Shows or hides the spinner beside this view's title.
87
     *
88
     * @param refreshing true while the view's results are being brought up to date
89
     */
90
    /**
91
     * The query reference this view's results come from.
92
     *
93
     * @return the query reference
94
     */
95
    public QueryRef getQueryRef() {
96
        return queryRef;
×
97
    }
98

99
    /**
100
     * The results this view is showing. Available to the action-link builder, which runs
101
     * after the response has arrived, so a view-level action can take a value from the rows
102
     * (see {@link com.knowledgepixels.nanodash.component.ViewActionMappings}).
103
     *
104
     * @return the API response
105
     */
106
    public ApiResponse getApiResponse() {
107
        return response;
9✔
108
    }
109

110
    /**
111
     * The version of the view definition this view display is showing, as the id to hand to
112
     * {@link View#refreshLatestVersion(String)} when checking for a newer one.
113
     *
114
     * @return the shown view's id, or null if this result has no view behind it
115
     */
116
    public String getShownViewId() {
117
        View view = (viewDisplay == null ? null : viewDisplay.getView());
×
118
        return view == null ? null : view.getId();
×
119
    }
120

121
    public void setRefreshing(boolean refreshing) {
122
        refreshIndicator.setVisible(refreshing);
×
123
    }
×
124

125
    /**
126
     * The spinner component itself, so a caller that turns it off over Ajax can repaint just
127
     * that instead of the whole view.
128
     *
129
     * @return the refresh indicator
130
     */
131
    public Component getRefreshIndicator() {
132
        return refreshIndicator;
×
133
    }
134

135
    /**
136
     * Builds this view again from the current state of the cache, as a component that can
137
     * take this one's place. Used to refresh a single view where it stands (see the "refresh
138
     * now" entry of its menu) instead of re-rendering the page around it.
139
     * <p>
140
     * With the view's results just marked as outdated, the rebuild comes back as the results
141
     * that are on screen now plus a spinner, which swaps in the new ones by itself once the
142
     * query has run.
143
     *
144
     * @param markupId the id the replacement must take, i.e. that of the component it replaces
145
     * @return the replacement component
146
     */
147
    public Component rebuild(String markupId) {
148
        // The part id is only set when it differs from the context; otherwise the view is
149
        // shown for the context resource itself.
150
        String id = partId != null ? partId : contextId;
×
151
        Component rebuilt = QueryResultComponentFactory.build(markupId, queryRef, viewDisplay,
×
152
                resourceWithProfile, id, contextId, refRoot);
153
        if (rebuilt != null) rebuilt.setOutputMarkupId(true);
×
154
        return rebuilt;
×
155
    }
156

157
    @Override
158
    protected void onBeforeRender() {
159
        if (!finalized) {
9!
160
            // View-level actions used to render as a button strip in the header here;
161
            // they now live as the top entries of the view's dropdown menu instead.
162
            add(new Label("buttons").setVisible(false));
42✔
163
            if (showViewDisplayMenu) {
9!
164
                if (viewDisplay.getNanopubId() != null || !menuActions.isEmpty()) {
24!
165
                    add(new ViewDisplayMenu("np", viewDisplay, queryRef, pageResource, menuActions));
×
166
                } else {
167
                    add(new Label("np").setVisible(false));
42✔
168
                }
169
            }
170
            finalized = true;
9✔
171
        }
172
        super.onBeforeRender();
6✔
173
    }
3✔
174

175
    /**
176
     * The view-level actions to render as top entries of the view's dropdown menu.
177
     *
178
     * @return the collected view-level menu actions
179
     */
180
    public List<MenuAction> getMenuActions() {
181
        return menuActions;
9✔
182
    }
183

184
    /**
185
     * Set the resource with profile for this component.
186
     *
187
     * @param resourceWithProfile The resource with profile to set.
188
     */
189
    public void setResourceWithProfile(AbstractResourceWithProfile resourceWithProfile) {
190
        this.resourceWithProfile = resourceWithProfile;
×
191
    }
×
192

193
    public void setPageResource(AbstractResourceWithProfile pageResource) {
194
        this.pageResource = pageResource;
×
195
    }
×
196

197
    /**
198
     * Set the context ID for this component.
199
     *
200
     * @param contextId The context ID to set.
201
     */
202
    public void setContextId(String contextId) {
203
        this.contextId = contextId;
9✔
204
    }
3✔
205

206
    /**
207
     * Set the part ID when this view is shown on a part page (e.g. paper collection).
208
     * Used for redirect-after-publish to return to the part page.
209
     *
210
     * @param partId The part ID to set, or null when on the main context page.
211
     */
212
    public void setPartId(String partId) {
213
        this.partId = partId;
×
214
    }
×
215

216
    /**
217
     * Set the tab to return to after publishing one of this view's action
218
     * buttons (e.g. {@code "about"} so a space's About-tab views send the user
219
     * back to About instead of the default Content tab). Null leaves the
220
     * post-publish redirect on its default tab.
221
     *
222
     * @param postPublishTab the tab name, or null for the default
223
     */
224
    public void setPostPublishTab(String postPublishTab) {
225
        this.postPublishTab = postPublishTab;
×
226
    }
×
227

228
    /**
229
     * Set the ref (root definition) this view is pinned to, so per-entry action visibility
230
     * is gated against that claimant's authority rather than the resource's representative
231
     * ref. Null = representative ref. See docs/space-ref-identity.md.
232
     *
233
     * @param refRoot the ref's root nanopub, or null
234
     */
235
    public void setRefRoot(String refRoot) {
236
        this.refRoot = refRoot;
×
237
    }
×
238

239
    /**
240
     * @return the tab to return to after publishing via an action button, or null for the default
241
     */
242
    public String getPostPublishTab() {
243
        return postPublishTab;
9✔
244
    }
245

246
    // A view-level action button; collected here and rendered as a top entry of the
247
    // view's dropdown menu (see ViewDisplayMenu).
248
    public void addButton(String label, Class<? extends NanodashPage> pageClass, PageParameters parameters) {
249
        if (parameters == null) {
6!
250
            parameters = new PageParameters();
×
251
        }
252
        if (contextId != null) {
9!
253
            parameters.set("context", contextId);
×
254
        }
255
        menuActions.add(new MenuAction(label, pageClass, parameters));
30✔
256
    }
3✔
257

258
    /**
259
     * The navigation context to stamp on links in result cells: this view's context
260
     * resource if bound to one, else the page's navigation context. Only usable at
261
     * render time (needs the page).
262
     *
263
     * @return the context id, or null if neither is set
264
     */
265
    private String renderContextId() {
266
        if (contextId != null) return contextId;
18✔
267
        if (getPage() instanceof NanodashPage nanodashPage) return nanodashPage.getContextId();
18!
268
        return null;
6✔
269
    }
270

271
    /**
272
     * The resource part to stamp on links in result cells, next to
273
     * {@link #renderContextId()}: the part this view is bound to, else the part the page
274
     * was reached under. A part only travels together with its own context (issue #697),
275
     * so it is dropped when the links carry a different one. Only usable at render time.
276
     *
277
     * @return the part id, or null if none applies
278
     */
279
    private String renderPartId() {
280
        if (partId != null) return partId;
9!
281
        if (getPage() instanceof NanodashPage nanodashPage
18!
282
                && Objects.equals(renderContextId(), nanodashPage.getIncomingContextId())) {
×
283
            return nanodashPage.getPartId();
×
284
        }
285
        return null;
6✔
286
    }
287

288
    /**
289
     * The label to carry along with {@link #renderPartId()}, so the target page's
290
     * back-link can name the part. Only known where the page is the part's own.
291
     *
292
     * @return the part label, or null if none is known
293
     */
294
    private String renderPartLabel() {
295
        if (getPage() instanceof NanodashPage nanodashPage
18!
296
                && Objects.equals(renderPartId(), nanodashPage.getPartId())) {
×
297
            return nanodashPage.getPartLabel();
×
298
        }
299
        return null;
6✔
300
    }
301

302
    /**
303
     * A page reference for a result row's own resource part, reached from this view: the
304
     * part page of the given IRI under this view's navigation context, carrying the given
305
     * label as the page title where the part declares none, plus the part this page was
306
     * reached under so the target's back-link can name it (issue #697). Only usable at
307
     * render time (needs the page).
308
     *
309
     * @param partId the row's resource IRI
310
     * @param label  the label to show for it, or null to fall back to its short name
311
     * @return the page reference, or null when no navigation context is known (a part
312
     * page cannot resolve a part without its maintaining resource)
313
     */
314
    protected NanodashPageRef partPageRef(String partId, String label) {
315
        String ctx = renderContextId();
9✔
316
        NanodashPageRef ref = NavigationContext.getPartPageRef(partId, label, ctx);
15✔
317
        if (ref == null) return null;
12✔
318
        NavigationContext.withPart(ref.getParameters(), renderPartId(), renderPartLabel(), ctx);
27✔
319
        return ref;
6✔
320
    }
321

322
    /**
323
     * The navigation parameters to append to a hand-built app-internal link in a result
324
     * cell: the {@code &context=...} suffix, plus the resource part the page was reached
325
     * under where that applies (issue #697). Empty string when no context is set. Only
326
     * usable at render time (needs the page).
327
     *
328
     * @param url the link the parameters are appended to, or null if not known yet
329
     * @return the URL parameter suffix, possibly empty
330
     */
331
    protected String linkNavParams(String url) {
332
        String ctx = renderContextId();
×
333
        if (ctx == null) return "";
×
334
        StringBuilder params = new StringBuilder("&context=").append(Utils.urlEncode(ctx));
×
335
        String part = renderPartId();
×
336
        // Not on a link to the part itself, nor on one up to the resource maintaining
337
        // it: the part is then either the destination or behind the user.
338
        if (part != null && !namesResource(url, part) && !namesResource(url, ctx)) {
×
339
            params.append("&part=").append(Utils.urlEncode(part));
×
340
            String partLabel = renderPartLabel();
×
341
            if (partLabel != null && !partLabel.isBlank()) {
×
342
                params.append("&part-label=").append(Utils.urlEncode(partLabel));
×
343
            }
344
        }
345
        return params.toString();
×
346
    }
347

348
    /**
349
     * Whether the given app-internal link points at the given resource, i.e. carries it
350
     * as its {@code id}. The sanitizer writes "=" as "&#61;", so both spellings count.
351
     *
352
     * @param url        the link to check, or null
353
     * @param resourceId the resource id to look for
354
     * @return true if the link's id is that resource
355
     */
356
    static boolean namesResource(String url, String resourceId) {
357
        if (url == null) return false;
12✔
358
        String encoded = Utils.urlEncode(resourceId);
9✔
359
        return url.contains("id=" + encoded) || url.contains("id&#61;" + encoded);
42✔
360
    }
361

362
    private static final Pattern INTERNAL_HREF_PATTERN = Pattern.compile("href=\"(/[^\"]*)\"");
9✔
363

364
    /**
365
     * Appends the navigation context, and the resource part where one applies, to
366
     * app-internal links ({@code href="/..."}) inside sanitized result-cell HTML, so
367
     * ready-made links coming from the query data itself (e.g. template or query links
368
     * emitted by the SPARQL) also lead back to where the user came from. Links already
369
     * carrying a context are left alone.
370
     *
371
     * @param sanitizedHtml the sanitized cell HTML, or null
372
     * @return the HTML with context-enriched internal links
373
     */
374
    protected String withNavParamsInHtmlLinks(String sanitizedHtml) {
375
        if (renderContextId() == null || sanitizedHtml == null) return sanitizedHtml;
21!
376
        Matcher m = INTERNAL_HREF_PATTERN.matcher(sanitizedHtml);
12✔
377
        StringBuilder sb = new StringBuilder();
12✔
378
        while (m.find()) {
9!
379
            String url = m.group(1);
×
380
            String replacement = m.group();
×
381
            // The sanitizer escapes "=" as "&#61;", so check both spellings.
382
            if (!url.contains("context=") && !url.contains("context&#61;")) {
×
383
                String separator = url.contains("?") ? "&amp;" : "?";
×
384
                // The suffix starts with "&", which the separator replaces.
385
                replacement = "href=\"" + url + separator + linkNavParams(url).substring(1).replace("&", "&amp;") + "\"";
×
386
            }
387
            m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
×
388
        }
×
389
        m.appendTail(sb);
12✔
390
        return sb.toString();
9✔
391
    }
392

393
    // An "<a ...>" start tag in sanitized cell HTML, with its attribute part.
394
    private static final Pattern ANCHOR_TAG_PATTERN = Pattern.compile("<a\\b([^>]*)>", Pattern.CASE_INSENSITIVE);
12✔
395
    private static final Pattern HREF_ATTRIBUTE_PATTERN = Pattern.compile("href=\"([^\"]*)\"");
12✔
396

397
    // The look given to publish links in result content: the small transparent button
398
    // used for secondary actions elsewhere in the app.
399
    private static final String PUBLISH_BUTTON_CLASSES = "smallbutton button light";
400

401
    /**
402
     * Prepares raw HTML coming from query data for display in result content: it is
403
     * sanitized, its app-internal links get the navigation context, and its links to
404
     * the publish form are turned into buttons.
405
     *
406
     * @param rawHtml the raw HTML from the query data, or null
407
     * @return the sanitized and enriched HTML
408
     */
409
    protected String cellHtml(String rawHtml) {
410
        return withPublishLinksAsButtons(withNavParamsInHtmlLinks(Utils.sanitizeHtml(rawHtml)));
18✔
411
    }
412

413
    /**
414
     * Shows links to the publish form as buttons, so that the actions a view offers
415
     * stand out from the links to content. Only links that don't bring their own class
416
     * are styled.
417
     *
418
     * @param sanitizedHtml the sanitized cell HTML, or null
419
     * @return the HTML with publish links marked as buttons
420
     */
421
    protected static String withPublishLinksAsButtons(String sanitizedHtml) {
422
        if (sanitizedHtml == null) return null;
12✔
423
        Matcher m = ANCHOR_TAG_PATTERN.matcher(sanitizedHtml);
12✔
424
        StringBuilder sb = new StringBuilder();
12✔
425
        while (m.find()) {
9✔
426
            String attributes = m.group(1);
12✔
427
            String replacement = m.group();
9✔
428
            Matcher hrefMatcher = HREF_ATTRIBUTE_PATTERN.matcher(attributes);
12✔
429
            if (hrefMatcher.find() && isPublishLink(hrefMatcher.group(1)) && !attributes.contains("class=")) {
36!
430
                replacement = "<a class=\"" + PUBLISH_BUTTON_CLASSES + "\"" + attributes + ">";
9✔
431
            }
432
            m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
18✔
433
        }
3✔
434
        m.appendTail(sb);
12✔
435
        return sb.toString();
9✔
436
    }
437

438
    /**
439
     * Whether the given value is a link to the publish form, i.e. the app-internal
440
     * {@code /publish} path, with or without query parameters. Only the path is
441
     * checked, so the sanitizer's escaping inside the parameters (it writes "=" as
442
     * "&#61;") makes no difference.
443
     *
444
     * @param value the value to check, or null
445
     * @return true if the value is a publish link
446
     */
447
    public static boolean isPublishLink(String value) {
448
        if (value == null) return false;
12✔
449
        return value.split("[?#]", 2)[0].equals(PublishPage.MOUNT_PATH);
27✔
450
    }
451

452
    /**
453
     * The content for a publish link that comes as a plain cell value: a button
454
     * carrying the label from the sibling label column where there is one, and a
455
     * generic label otherwise.
456
     *
457
     * @param url   the publish link
458
     * @param label the label from the sibling label column, or null
459
     * @return the HTML for the button
460
     */
461
    protected String publishButtonHtml(String url, String label) {
462
        String text = (label == null || label.isBlank() || label.equals(url)) ? "publish…" : label;
×
463
        String inner = Utils.looksLikeHtml(text) ? text : Strings.escapeMarkup(text).toString();
×
464
        return cellHtml("<a href=\"" + Strings.escapeMarkup(url) + "\">" + inner + "</a>");
×
465
    }
466

467
    /**
468
     * Whether all result rows fit on the first page, so no pagination is needed
469
     * and the filter textfield can be hidden. Also true when the page size is
470
     * unlimited ({@code < 1}).
471
     *
472
     * @return true if all entries fit on the first page
473
     */
474
    protected boolean fitsOnFirstPage() {
475
        int pageSize = viewDisplay.getPageSize();
15✔
476
        return pageSize < 1 || response.getData().size() <= pageSize;
36!
477
    }
478

479
    /**
480
     * Whether the empty state should point the viewer to the view-level actions:
481
     * the underlying response (not just a filtered view of it) has no rows, and
482
     * there is at least one action the viewer is entitled to.
483
     *
484
     * @return true if the empty-state call-to-action buttons should show
485
     */
486
    protected boolean hasEmptyStateActions() {
487
        return response.getData().isEmpty() && !menuActions.isEmpty();
×
488
    }
489

490
    /**
491
     * Populate the component with the query results.
492
     */
493
    protected abstract void populateComponent();
494

495
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc