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

knowledgepixels / nanodash / 27622721129

16 Jun 2026 01:55PM UTC coverage: 26.963% (+6.3%) from 20.697%
27622721129

Pull #483

github

web-flow
Merge 73a4d0fe1 into 663f14f46
Pull Request #483: Space/resource About pages, ref-aware spaces, and magic query params

1542 of 6717 branches covered (22.96%)

Branch coverage included in aggregate %.

3407 of 11638 relevant lines covered (29.27%)

4.31 hits per line

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

0.0
src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java
1
package com.knowledgepixels.nanodash.component;
2

3
import com.knowledgepixels.nanodash.*;
4
import com.knowledgepixels.nanodash.component.menu.EntryActionMenu;
5
import com.knowledgepixels.nanodash.domain.MaintainedResource;
6
import com.knowledgepixels.nanodash.page.ExplorePage;
7
import com.knowledgepixels.nanodash.page.NanodashPage;
8
import com.knowledgepixels.nanodash.page.PublishPage;
9
import com.knowledgepixels.nanodash.repository.MaintainedResourceRepository;
10
import com.knowledgepixels.nanodash.template.Template;
11
import org.apache.wicket.Component;
12
import org.apache.wicket.ajax.AjaxRequestTarget;
13
import org.apache.wicket.behavior.AttributeAppender;
14
import org.apache.wicket.extensions.ajax.markup.html.repeater.data.table.AjaxFallbackHeadersToolbar;
15
import org.apache.wicket.extensions.ajax.markup.html.repeater.data.table.AjaxNavigationToolbar;
16
import org.apache.wicket.extensions.markup.html.repeater.data.grid.ICellPopulator;
17
import org.apache.wicket.extensions.markup.html.repeater.data.table.*;
18
import org.apache.wicket.markup.html.WebMarkupContainer;
19
import org.apache.wicket.markup.html.basic.Label;
20
import org.apache.wicket.markup.html.form.TextField;
21
import org.apache.wicket.markup.html.link.AbstractLink;
22
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
23
import org.apache.wicket.markup.html.list.ListItem;
24
import org.apache.wicket.markup.html.list.ListView;
25
import org.apache.wicket.util.string.Strings;
26
import org.apache.wicket.markup.repeater.Item;
27
import org.apache.wicket.model.IModel;
28
import org.apache.wicket.model.Model;
29
import org.apache.wicket.model.util.ListModel;
30
import org.apache.wicket.request.mapper.parameter.PageParameters;
31
import org.eclipse.rdf4j.model.IRI;
32
import org.nanopub.extra.services.ApiResponse;
33
import org.nanopub.extra.services.ApiResponseEntry;
34
import org.nanopub.extra.services.QueryRef;
35
import org.slf4j.Logger;
36
import org.slf4j.LoggerFactory;
37

38
import java.util.ArrayList;
39
import java.util.Collections;
40
import java.util.List;
41
import java.util.Set;
42

43
/**
44
 * Component for displaying query results in a table format.
45
 */
46
public class QueryResultTable extends QueryResult {
47

48
    private static final Logger logger = LoggerFactory.getLogger(QueryResultTable.class);
×
49

50
    private Model<String> errorMessages = Model.of("");
×
51
    private DataTable<ApiResponseEntry, String> table;
52
    private Label noRecordsLabel;
53
    private Label errorLabel;
54
    private FilteredQueryResultDataProvider filteredDataProvider;
55
    private Model<String> filterModel = Model.of("");
×
56
    // The source-nanopub column ("np"/"nps"), folded into the per-row actions dropdown.
57
    private String sourceColumnKey;
58

59
    QueryResultTable(String id, QueryRef queryRef, ApiResponse response, ViewDisplay viewDisplay, boolean plain) {
60
        super(id, queryRef, response, viewDisplay);
×
61

62
        if (plain) {
×
63
            add(new Label("label").setVisible(false));
×
64
            add(new Label("np").setVisible(false));
×
65
            showViewDisplayMenu = false;
×
66
        } else {
67
            String label = grlcQuery.getLabel();
×
68
            if (viewDisplay.getTitle() != null) {
×
69
                label = viewDisplay.getTitle();
×
70
            }
71
            add(new Label("label", label));
×
72
        }
73

74
        errorLabel = new Label("error-messages", errorMessages);
×
75
        errorLabel.setVisible(false);
×
76
        add(errorLabel);
×
77

78
        TextField<String> filterField = new TextField<>("filter", filterModel);
×
79
        filterField.setOutputMarkupId(true);
×
80
        filterField.add(new FilterUpdatingBehavior() {
×
81
            @Override
82
            protected void onUpdate(AjaxRequestTarget target) {
83
                if (filteredDataProvider != null && table != null) {
×
84
                    filteredDataProvider.setFilterText(filterModel.getObject());
×
85
                    target.add(table);
×
86
                    if (noRecordsLabel != null) target.add(noRecordsLabel);
×
87
                }
88
            }
×
89
        });
90
        filterField.setVisible(!fitsOnFirstPage());
×
91
        add(filterField);
×
92

93
        populateComponent();
×
94
    }
×
95

96
    private void addErrorMessage(String errorMessage) {
97
        String s = errorMessages.getObject();
×
98
        if (s.isEmpty()) {
×
99
            s = "Error: " + errorMessage;
×
100
        } else {
101
            s += ", " + errorMessage;
×
102
        }
103
        errorMessages.setObject(s);
×
104
        errorLabel.setVisible(true);
×
105
        if (table != null) table.setVisible(false);
×
106
    }
×
107

108
    @Override
109
    protected void populateComponent() {
110
        List<IColumn<ApiResponseEntry, String>> columns = new ArrayList<>();
×
111
        QueryResultDataProvider dataProvider;
112
        try {
113
            // Columns that only feed action query mappings (conditional targets, the
114
            // local-key bundle) carry action data, not row content — don't render them.
115
            Set<String> hiddenColumns = viewDisplay.getView() != null
×
116
                    ? viewDisplay.getView().getActionMappingSourceColumns() : Collections.emptySet();
×
117
            // The source-nanopub column ("np"/"nps") is no longer rendered as its own
118
            // column; it becomes the "source" entry of this row's actions dropdown.
119
            sourceColumnKey = null;
×
120
            for (String h : response.getHeader()) {
×
121
                if (h.equals("np") || h.equals("nps")) sourceColumnKey = h;
×
122
            }
123
            // Whether any rendered column carries a visible header label. If none do
124
            // (every column is "_noheader"), the entire header row is dropped — see the
125
            // gated addTopToolbar below.
126
            boolean anyHeaderShown = false;
×
127
            for (String h : response.getHeader()) {
×
128
                if (h.endsWith("_label") || h.endsWith("_label_multi")
×
129
                        || hiddenColumns.contains(h) || h.equals(sourceColumnKey)) {
×
130
                    continue;
×
131
                }
132
                // A trailing "_noheader" hides this column's header label while still
133
                // rendering the column. It is stripped first to recover the logical
134
                // column key, so every other rule (type suffix, _label companion,
135
                // action mappings) operates unchanged on the unmarked name.
136
                boolean noHeader = h.endsWith("_noheader");
×
137
                String key = noHeader ? h.substring(0, h.length() - "_noheader".length()) : h;
×
138
                String displayLabel = key;
×
139
                if (displayLabel.endsWith("_multi_iri")) {
×
140
                    displayLabel = displayLabel.substring(0, displayLabel.length() - "_multi_iri".length());
×
141
                } else if (displayLabel.endsWith("_multi_val")) {
×
142
                    displayLabel = displayLabel.substring(0, displayLabel.length() - "_multi_val".length());
×
143
                } else if (displayLabel.endsWith("_multi")) {
×
144
                    displayLabel = displayLabel.substring(0, displayLabel.length() - "_multi".length());
×
145
                } else if (displayLabel.endsWith("_iri")) {
×
146
                    displayLabel = displayLabel.substring(0, displayLabel.length() - "_iri".length());
×
147
                }
148
                String columnHeader = displayLabel.replaceAll("_", " ");
×
149
                if (noHeader) {
×
150
                    columns.add(new Column("", h, key, null));
×
151
                } else {
152
                    anyHeaderShown = true;
×
153
                    columns.add(new Column(columnHeader, h, key, null));
×
154
                }
155
            }
156
            // A single trailing dropdown column bundling this row's entry-level actions
157
            // and its "source" link, shown whenever either is present.
158
            boolean hasEntryActions = viewDisplay.getView() != null
×
159
                    && !viewDisplay.getView().getViewEntryActionList().isEmpty();
×
160
            if (hasEntryActions || sourceColumnKey != null) {
×
161
                columns.add(new Column("", Column.ACTIONS, "cell-right"));
×
162
            }
163
            dataProvider = new QueryResultDataProvider(response.getData());
×
164
            filteredDataProvider = new FilteredQueryResultDataProvider(dataProvider, response);
×
165
            // The whole table (header included) is hidden when there is nothing to show;
166
            // a "(nothing found)" note is shown instead. No NoRecordsToolbar, since that
167
            // would leave the header row visible.
168
            table = new DataTable<>("table", columns, filteredDataProvider, viewDisplay.getPageSize() < 1 ? Integer.MAX_VALUE : viewDisplay.getPageSize()) {
×
169
                @Override
170
                protected void onConfigure() {
171
                    super.onConfigure();
×
172
                    setVisible(errorMessages.getObject().isEmpty() && filteredDataProvider.size() > 0);
×
173
                }
×
174
            };
175
            table.setOutputMarkupPlaceholderTag(true);
×
176
            // Marker class so nanodash.js can wrap emoji in body cells with the same
177
            // monochrome .emoji styling used for headings (e.g. the ✅/⚠️ key-approval
178
            // annotations in the "keys" column).
179
            table.add(new AttributeAppender("class", "result-table"));
×
180
            table.addBottomToolbar(new AjaxNavigationToolbar(table));
×
181
            // Drop the header row entirely when no column has a visible header label.
182
            if (anyHeaderShown) {
×
183
                table.addTopToolbar(new AjaxFallbackHeadersToolbar<String>(table, dataProvider));
×
184
            }
185
            add(table);
×
186
            // Hidden when the empty-actions line below shows instead, which carries
187
            // its own "Nothing here yet:" text; this note still covers the case of
188
            // the filter text matching no row.
189
            noRecordsLabel = new Label("no-records", "(nothing found)") {
×
190
                @Override
191
                protected void onConfigure() {
192
                    super.onConfigure();
×
193
                    setVisible(errorMessages.getObject().isEmpty() && filteredDataProvider.size() == 0 && !hasEmptyStateActions());
×
194
                }
×
195
            };
196
            noRecordsLabel.setOutputMarkupPlaceholderTag(true);
×
197
            add(noRecordsLabel);
×
198
            // When the result is genuinely empty (not merely filtered down to zero
199
            // rows), the view-level actions are promoted from the dropdown menu to
200
            // visible buttons in the empty state, pointing e.g. a space admin to
201
            // "add preset..." as the next step. menuActions only ever contains
202
            // actions the viewer is entitled to (see QueryResultTableBuilder), so
203
            // everyone else just gets the plain note. The same actions stay in the
204
            // dropdown menu, which remains their place once the table has content.
205
            WebMarkupContainer emptyActions = new WebMarkupContainer("empty-actions") {
×
206
                @Override
207
                protected void onConfigure() {
208
                    super.onConfigure();
×
209
                    setVisible(errorMessages.getObject().isEmpty() && hasEmptyStateActions());
×
210
                }
×
211
            };
212
            // menuActions is filled by the builder after construction, so the list
213
            // is wrapped as a live model rather than copied here.
214
            emptyActions.add(new ListView<MenuAction>("actions", new ListModel<>(menuActions)) {
×
215
                @Override
216
                protected void populateItem(ListItem<MenuAction> item) {
217
                    MenuAction action = item.getModelObject();
×
218
                    AbstractLink link = new BookmarkablePageLink<NanodashPage>("link", action.pageClass(), action.params());
×
219
                    link.setBody(Model.of(action.label()));
×
220
                    item.add(link);
×
221
                }
×
222
            });
223
            add(emptyActions);
×
224
        } catch (Exception ex) {
×
225
            logger.error("Error creating table for query {}", grlcQuery.getQueryId(), ex);
×
226
            add(new Label("table", "").setVisible(false));
×
227
            add(new Label("no-records", "").setVisible(false));
×
228
            add(new Label("empty-actions", "").setVisible(false));
×
229
            addErrorMessage(ex.getMessage());
×
230
        }
×
231
    }
×
232

233
    private class Column extends AbstractColumn<ApiResponseEntry, String> implements IStyledColumn<ApiResponseEntry, String> {
234

235
        private String key;
236
        // The actual response-column name to read row data from. Differs from the
237
        // logical key only for "_noheader" columns, whose marker is kept here but
238
        // stripped from key so all name-matching uses the unmarked name.
239
        private String dataKey;
240
        private String cssClass;
241
        public static final String ACTIONS = "*actions*";
242

243
        public Column(String title, String key) {
244
            this(title, key, key, null);
×
245
        }
×
246

247
        public Column(String title, String key, String cssClass) {
248
            this(title, key, key, cssClass);
×
249
        }
×
250

251
        public Column(String title, String dataKey, String key, String cssClass) {
×
252
            super(new Model<String>(title), dataKey);
×
253
            this.key = key;
×
254
            this.dataKey = dataKey;
×
255
            this.cssClass = cssClass;
×
256
        }
×
257

258
        @Override
259
        public String getCssClass() {
260
            return cssClass;
×
261
        }
262

263
        @Override
264
        public void populateItem(Item<ICellPopulator<ApiResponseEntry>> cellItem, String componentId, IModel<ApiResponseEntry> rowModel) {
265
            try {
266
                View view = viewDisplay.getView();
×
267
                if (key.equals(ACTIONS)) {
×
268
                    List<AbstractLink> links = new ArrayList<>();
×
269
                    if (view != null) {
×
270
                        for (IRI actionIri : view.getViewEntryActionList()) {
×
271
                            // Per-action role gating (docs/role-specific-views.md): skip an
272
                            // action whose gen:isVisibleTo the viewer does not satisfy.
273
                            // Additive — actions without gen:isVisibleTo are unaffected.
274
                            if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), resourceWithProfile)) continue;
×
275
                            // TODO Copied code and adjusted from QueryResultTableBuilder:
276
                            Template t = view.getTemplateForAction(actionIri);
×
277
                            if (t == null) continue;
×
278
                            String targetField = view.getTemplateTargetFieldForAction(actionIri);
×
279
                            if (targetField == null) targetField = "resource";
×
280
                            String label = view.getLabelForAction(actionIri);
×
281
                            if (label == null) label = "action...";
×
282
                            if (!label.endsWith("...")) label += "...";
×
283
                            PageParameters params = new PageParameters().set("template", t.getId())
×
284
                                    .set("param_" + targetField, contextId)
×
285
                                    .set("context", contextId)
×
286
                                    .set("template-version", "latest");
×
287
                            if (partId != null && contextId != null && !partId.equals(contextId)) {
×
288
                                params.set("part", partId);
×
289
                            }
290
                            String partField = view.getTemplatePartFieldForAction(actionIri);
×
291
                            if (partField != null) {
×
292
                                // TODO Find a better way to pass the MaintainedResource object to this method:
293
                                MaintainedResource r = MaintainedResourceRepository.get().findById(contextId);
×
294
                                if (r != null && r.getNamespace() != null) {
×
295
                                    params.set("param_" + partField, r.getNamespace() + "<SET-SUFFIX>");
×
296
                                }
297
                            }
298
                            // Apply the action's query mappings; hide the button for this row
299
                            // if any required mapped value is empty (docs/magic-query-params.md).
300
                            if (!ViewActionMappings.applyEntryMappings(view, actionIri, rowModel.getObject(), params)) {
×
301
                                continue;
×
302
                            }
303
                            params.set("refresh-upon-publish", queryRef.getAsUrlString());
×
304
                            if (postPublishTab != null) params.set("postpub-tab", postPublishTab);
×
305
                            AbstractLink button = new BookmarkablePageLink<NanodashPage>("link", PublishPage.class, params);
×
306
                            // A label that starts with a leading symbol/emoji renders that as the entry icon.
307
                            String iconBody = Utils.menuEntryIconBodyHtml(label);
×
308
                            if (iconBody != null) {
×
309
                                button.setBody(Model.of(iconBody)).setEscapeModelStrings(false);
×
310
                            } else {
311
                                button.setBody(Model.of(label));
×
312
                            }
313
                            links.add(button);
×
314
                        }
×
315
                    }
316
                    // The former "^" source link joins the same dropdown, as a "source" entry.
317
                    if (sourceColumnKey != null) {
×
318
                        String sourceUri = rowModel.getObject().get(sourceColumnKey);
×
319
                        if (sourceUri != null && !sourceUri.isBlank()) {
×
320
                            AbstractLink sourceLink = new BookmarkablePageLink<NanodashPage>("link", ExplorePage.class,
×
321
                                    new PageParameters().set("id", sourceUri));
×
322
                            sourceLink.setBody(Model.of("<span class=\"actionmenu-icon\">↗︎</span>source")).setEscapeModelStrings(false);
×
323
                            links.add(sourceLink);
×
324
                        }
325
                    }
326
                    if (links.isEmpty()) {
×
327
                        cellItem.add(new Label(componentId).setVisible(false));
×
328
                    } else {
329
                        cellItem.add(new EntryActionMenu(componentId, links));
×
330
                    }
331
                } else {
×
332
                    String value = rowModel.getObject().get(dataKey);
×
333
                    if (key.endsWith("_multi_iri")) {
×
334
                        String labelKey = key.substring(0, key.length() - "_multi_iri".length()) + "_label_multi";
×
335
                        String labelValue = rowModel.getObject().get(labelKey);
×
336
                        String[] uris = (value == null || value.isBlank()) ? new String[0] : value.split("\\s+");
×
337
                        String[] labels = labelValue != null ? labelValue.split("\n", -1) : null;
×
338
                        List<Component> links = new ArrayList<>();
×
339
                        for (int i = 0; i < uris.length; i++) {
×
340
                            String uri = uris[i];
×
341
                            if (uri.isBlank()) continue;
×
342
                            String rawLabel = (labels != null && i < labels.length && !labels[i].isBlank()) ? Utils.unescapeMultiValue(labels[i]) : null;
×
343
                            // SPARQL coalesce often falls back to the URI string itself; treat that as no label
344
                            // so NanodashLink can derive a short name from the URI.
345
                            if (rawLabel != null && rawLabel.equals(uri)) rawLabel = null;
×
346
                            String label = truncateLabel(rawLabel);
×
347
                            links.add(new NanodashLink("component", uri, null, null, label, contextId));
×
348
                        }
349
                        cellItem.add(new ComponentSequence(componentId, ", ", links));
×
350
                    } else if (key.endsWith("_multi_val")) {
×
351
                        String labelKey = key.substring(0, key.length() - "_multi_val".length()) + "_label_multi";
×
352
                        String labelValue = rowModel.getObject().get(labelKey);
×
353
                        String[] parts = (value == null) ? new String[0] : value.split("\n", -1);
×
354
                        String[] labels = labelValue != null ? labelValue.split("\n", -1) : null;
×
355
                        List<Component> components = new ArrayList<>();
×
356
                        for (int i = 0; i < parts.length; i++) {
×
357
                            String part = parts[i];
×
358
                            String rawLabel = (labels != null && i < labels.length && !labels[i].isBlank()) ? Utils.unescapeMultiValue(labels[i]) : null;
×
359
                            if (part.matches("https?://.+")) {
×
360
                                if (rawLabel != null && rawLabel.equals(part)) rawLabel = null;
×
361
                                String label = truncateLabel(rawLabel);
×
362
                                components.add(new NanodashLink("component", part, null, null, label, contextId));
×
363
                            } else {
×
364
                                String label = truncateLabel(rawLabel);
×
365
                                String display = label != null ? label : Utils.unescapeMultiValue(part);
×
366
                                if (Utils.looksLikeHtml(display)) {
×
367
                                    components.add(new Label("component", Utils.sanitizeHtml(display))
×
368
                                            .setEscapeModelStrings(false)
×
369
                                            .add(new AttributeAppender("class", "cell-data-html")));
×
370
                                } else {
371
                                    components.add(new Label("component", display));
×
372
                                }
373
                            }
374
                        }
375
                        cellItem.add(new ComponentSequence(componentId, ", ", components));
×
376
                    } else if (key.endsWith("_multi")) {
×
377
                        String labelKey = key.substring(0, key.length() - "_multi".length()) + "_label_multi";
×
378
                        String labelValue = rowModel.getObject().get(labelKey);
×
379
                        String[] parts = (value == null) ? new String[0] : value.split("\n", -1);
×
380
                        String[] labels = labelValue != null ? labelValue.split("\n", -1) : null;
×
381
                        List<Component> components = new ArrayList<>();
×
382
                        for (int i = 0; i < parts.length; i++) {
×
383
                            String display;
384
                            if (labels != null && i < labels.length && !labels[i].isBlank()) {
×
385
                                display = Utils.unescapeMultiValue(labels[i]);
×
386
                            } else {
387
                                display = Utils.unescapeMultiValue(parts[i]);
×
388
                            }
389
                            if (Utils.looksLikeHtml(display)) {
×
390
                                components.add(new Label("component", Utils.sanitizeHtml(display))
×
391
                                        .setEscapeModelStrings(false)
×
392
                                        .add(new AttributeAppender("class", "cell-data-html")));
×
393
                            } else {
394
                                components.add(new Label("component", display));
×
395
                            }
396
                        }
397
                        cellItem.add(new ComponentSequence(componentId, ", ", components));
×
398
                    } else if (key.endsWith("template_iri")) {
×
399
                        String label = truncateLabel(rowModel.getObject().get(key + "_label"));
×
400
                        if (label == null || label.isBlank()) label = truncateLabel(value);
×
401
                        String templateUrl = PublishPage.MOUNT_PATH + "?template=" + Utils.urlEncode(value) + "&template-version=latest";
×
402
                        String html = "<a href=\"" + Strings.escapeMarkup(templateUrl) + "\">" + Strings.escapeMarkup(label) + "</a>";
×
403
                        cellItem.add(new Label(componentId, html).setEscapeModelStrings(false));
×
404
                    } else if (value.matches("https?://.+")) {
×
405
                        String label = truncateLabel(rowModel.getObject().get(key + "_label"));
×
406
                        cellItem.add(new NanodashLink(componentId, value, null, null, label, contextId));
×
407
                    } else {
×
408
                        if (key.startsWith("pubkey")) {
×
409
                            cellItem.add(new Label(componentId, value).add(new AttributeAppender("style", "overflow-wrap: anywhere;")));
×
410
                        } else if (Utils.isDateTimeLiteral(value)) {
×
411
                            // Show a friendly relative time (client-side); raw ISO value stays as no-script fallback.
412
                            cellItem.add(new Label(componentId, Utils.friendlyDateHtml(value, value)).setEscapeModelStrings(false));
×
413
                        } else {
414
                            Label cellLabel;
415
                            if (Utils.looksLikeHtml(value)) {
×
416
                                cellLabel = (Label) new Label(componentId, Utils.sanitizeHtml(value))
×
417
                                        .setEscapeModelStrings(false)
×
418
                                        .add(new AttributeAppender("class", "cell-data-html"));
×
419
                            } else {
420
                                cellLabel = new Label(componentId, value);
×
421
                            }
422
                            cellItem.add(cellLabel);
×
423
                        }
424
                    }
425
                }
426
            } catch (Exception ex) {
×
427
                logger.error("Failed to populate table column: ", ex);
×
428
                cellItem.add(new Label(componentId).setVisible(false));
×
429
                addErrorMessage(ex.getMessage());
×
430
            }
×
431
        }
×
432

433
    }
434

435
    private static String truncateLabel(String label) {
436
        return Utils.truncateLabel(label);
×
437
    }
438

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

© 2026 Coveralls, Inc