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

knowledgepixels / nanodash / 28793681458

06 Jul 2026 01:05PM UTC coverage: 28.475% (-0.03%) from 28.504%
28793681458

Pull #537

github

web-flow
Merge 58b5e64fc into 5ef3629cc
Pull Request #537: fix: treat home resource context as plain Home in navigation

1811 of 7191 branches covered (25.18%)

Branch coverage included in aggregate %.

3710 of 12198 relevant lines covered (30.41%)

4.52 hits per line

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

68.78
src/main/java/com/knowledgepixels/nanodash/component/TitleBar.java
1
package com.knowledgepixels.nanodash.component;
2

3
import java.io.Serializable;
4
import java.util.ArrayList;
5
import java.util.List;
6

7
import org.apache.wicket.behavior.AttributeAppender;
8
import org.apache.wicket.markup.html.WebMarkupContainer;
9
import org.apache.wicket.markup.html.WebPage;
10
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
11
import org.apache.wicket.markup.html.panel.EmptyPanel;
12
import org.apache.wicket.markup.html.panel.Panel;
13
import org.apache.wicket.markup.repeater.Item;
14
import org.apache.wicket.markup.repeater.data.DataView;
15
import org.apache.wicket.markup.repeater.data.ListDataProvider;
16

17
import org.apache.wicket.request.mapper.parameter.PageParameters;
18

19
import com.knowledgepixels.nanodash.NanodashPageRef;
20
import com.knowledgepixels.nanodash.NanodashPreferences;
21
import com.knowledgepixels.nanodash.NavigationContext;
22
import com.knowledgepixels.nanodash.Utils;
23
import com.knowledgepixels.nanodash.page.ExplorePage;
24
import com.knowledgepixels.nanodash.page.HomePage;
25
import com.knowledgepixels.nanodash.page.NanodashPage;
26
import com.knowledgepixels.nanodash.page.PublishPage;
27
import com.knowledgepixels.nanodash.page.QueryListPage;
28
import com.knowledgepixels.nanodash.page.SpaceListPage;
29
import com.knowledgepixels.nanodash.page.UserListPage;
30

31
/**
32
 * TitleBar is the top bar of the Nanodash application, which contains
33
 * navigation elements such as profile, my channel, users, connectors,
34
 * publish, query, and breadcrumb navigation.
35
 */
36
public class TitleBar extends Panel {
37

38
    private String highlight;
39

40
    /**
41
     * The breadcrumb/tab strip row (the grey band just below the nav bar). Holds
42
     * the breadcrumb links on the left and, optionally, the resource tab strip on
43
     * the right (see {@link #setTabs(ResourceTabs)}).
44
     */
45
    private WebMarkupContainer breadcrumbPath;
46

47
    /**
48
     * Constructs a TitleBar with the specified id, page, highlight element,
49
     * and an array of path references for breadcrumb navigation.
50
     *
51
     * @param id        the component id
52
     * @param page      the current Nanodash page
53
     * @param highlight the id of the element to highlight
54
     * @param pathRefs  an array of NanodashPageRef for breadcrumb navigation
55
     */
56
    public TitleBar(String id, NanodashPage page, String highlight, NanodashPageRef... pathRefs) {
57
        super(id);
9✔
58
        this.highlight = highlight;
9✔
59
        add(new ProfileItem("profile", page));
39✔
60
        // Centered title-bar message: the post-publish confirmation and/or the
61
        // always-on "you haven't published an introduction yet" warning.
62
        add(new JustPublishedMessagePanel("justPublishedMessage", page.getPageParameters()));
42✔
63

64
        // Nav links carry the page's navigation context along, so e.g. publishing via
65
        // the "publish" link forwards back to the space/user/resource one came from.
66
        PageParameters navParams = NavigationContext.withContext(new PageParameters(), page.getContextId());
21✔
67
        createNavLink("users", UserListPage.class, navParams);
18✔
68
        createNavLink("connectors", SpaceListPage.class, navParams);
18✔
69
        createNavLink("publish", PublishPage.class, navParams).setVisible(!NanodashPreferences.get().isReadOnlyMode());
36!
70
        createNavLink("query", QueryListPage.class, navParams);
18✔
71

72
        breadcrumbPath = new WebMarkupContainer("breadcrumbpath");
18✔
73
        if (page.hasFullWidthContent()) {
9!
74
            // Full-width pages (e.g. query pages): the strip's row follows the
75
            // content to the left edge instead of centering at the standard width.
76
            breadcrumbPath.add(new AttributeAppender("class", " fullwidth"));
×
77
        }
78
        WebMarkupContainer breadcrumbLinks = new WebMarkupContainer("breadcrumblinks");
15✔
79
        List<CrumbPart> crumbParts = buildCrumbParts(pathRefs);
9✔
80
        if (!crumbParts.isEmpty()) {
9!
81
            CrumbPart first = crumbParts.get(0);
×
82
            breadcrumbLinks.add(first.ref().createComponent("firstpathelement", first.label()));
×
83
            // Getting serialization exception if not using 'new ArrayList<...>(...)' here:
84
            List<CrumbPart> moreParts = new ArrayList<CrumbPart>(crumbParts.subList(1, crumbParts.size()));
×
85
            breadcrumbLinks.add(new DataView<CrumbPart>("morepathelements", new ListDataProvider<CrumbPart>(moreParts)) {
×
86

87
                @Override
88
                protected void populateItem(Item<CrumbPart> item) {
89
                    CrumbPart part = item.getModelObject();
×
90
                    item.add(part.ref().createComponent("furtherpathelement", part.label()));
×
91
                }
×
92

93
            });
94
        } else {
×
95
            breadcrumbLinks.setVisible(false);
12✔
96
        }
97
        breadcrumbPath.add(breadcrumbLinks);
30✔
98
        // Back-to-context link: on pages without a breadcrumb of their own that are not
99
        // a context resource's page, show "< XXX" pointing to the navigation context
100
        // (or "< Home" if none), so the user can always get back to it.
101
        WebMarkupContainer backContextContainer = new WebMarkupContainer("backcontext-container");
15✔
102
        boolean backCrumbVisible = false;
6✔
103
        if (crumbParts.isEmpty() && !page.isContextPage()) {
18!
104
            NanodashPageRef backRef = createBackContextRef(page);
9✔
105
            if (backRef != null) {
6!
106
                backContextContainer.add(backRef.createComponent("backcontext", Utils.truncateLinkLabel(backRef.getLabel())));
42✔
107
                backCrumbVisible = true;
6✔
108
            }
109
        }
110
        backContextContainer.setVisible(backCrumbVisible);
12✔
111
        breadcrumbPath.add(backContextContainer);
30✔
112
        // Tab strip placeholder (right side of the strip); replaced via setTabs().
113
        breadcrumbPath.add(new EmptyPanel("tabs").setVisible(false));
45✔
114
        // The strip shows when there is a breadcrumb to display; setTabs() also
115
        // forces it visible so a tab strip shows even without a breadcrumb.
116
        breadcrumbPath.setVisible(pathRefs.length > 0 || backCrumbVisible);
33!
117
        add(breadcrumbPath);
30✔
118
    }
3✔
119

120
    /**
121
     * The target of the back-to-context link for the given page: the navigation context
122
     * resource, the home page if no context is set, or an explore-page link if the
123
     * context id cannot (yet) be resolved. Null when the link would point to the page
124
     * itself.
125
     */
126
    private static NanodashPageRef createBackContextRef(NanodashPage page) {
127
        String contextId = page.getContextId();
9✔
128
        if (contextId == null) {
6!
129
            if (page instanceof HomePage) return null;
9!
130
            return new NanodashPageRef(HomePage.class, "Home");
18✔
131
        }
132
        NanodashPageRef ref = NavigationContext.getPageRef(contextId);
×
133
        if (page instanceof HomePage && ref != null && HomePage.class.equals(ref.getPageClass())) return null;
×
134
        if (ref == null) {
×
135
            // Stale or not-yet-loaded context id: link via the explore page, which
136
            // forwards known resources to their own pages once the caches are warm.
137
            ref = new NanodashPageRef(ExplorePage.class, new PageParameters().set("id", contextId), Utils.getShortNameFromURI(contextId));
×
138
        }
139
        if (ref.getPageClass().equals(page.getClass()) && contextId.equals(page.getPageParameters().get("id").toString(""))) {
×
140
            return null;
×
141
        }
142
        return ref;
×
143
    }
144

145
    /**
146
     * One breadcrumb segment: the {@link NanodashPageRef} it links to and the
147
     * (possibly split) label text to show for it.
148
     */
149
    public record CrumbPart(NanodashPageRef ref, String label) implements Serializable {
27✔
150
    }
151

152
    /**
153
     * Flattens a breadcrumb path into the segments to render. Three transforms are
154
     * applied to each ref's label:
155
     * <ul>
156
     *   <li>For each non-root crumb, the part it shares with its parent label
157
     *       is stripped (e.g. parent "Knowledge Pixels", child "Knowledge
158
     *       Pixels Incubator" renders as "Incubator"). The shared part is the
159
     *       longest common character prefix, but only stripped when it covers
160
     *       (almost) all of the parent and ends on a non-letter/digit boundary
161
     *       in the child — this also catches singular/plural variations like
162
     *       parent "Nano Sessions", child "Nano Session #30" → "#30".</li>
163
     *   <li>For each non-root crumb, a word-level overlap where the child's
164
     *       leading word(s) restate the parent's trailing word(s) is stripped
165
     *       (e.g. parent "Abc Def Ghi", child "Ghi Jkl" renders as "Jkl"). The
166
     *       longest such parent-suffix/child-prefix overlap wins, matched on
167
     *       whole words case-insensitively, and never strips the whole child
168
     *       away.</li>
169
     *   <li>Only the leading segment of the remaining label is kept — the part
170
     *       before the first list/title separator ({@code ,}, {@code :},
171
     *       {@code ;}, {@code |}, or a spaced {@code -}) — so each path level
172
     *       renders as one concise crumb. E.g. "FIP.38.T.8 | FAIR Implementation
173
     *       Profile Training Session 8" renders as "FIP.38.T.8" and "3PFF: the
174
     *       Three Point FAIRification Framework" as "3PFF".</li>
175
     * </ul>
176
     * The parent-prefix comparison uses the original (un-simplified) labels.
177
     */
178
    static List<CrumbPart> buildCrumbParts(NanodashPageRef[] pathRefs) {
179
        List<CrumbPart> parts = new ArrayList<>();
12✔
180
        for (int i = 0; i < pathRefs.length; i++) {
24✔
181
            String label = pathRefs[i].getLabel();
15✔
182
            if (label == null) {
6!
183
                parts.add(new CrumbPart(pathRefs[i], null));
×
184
                continue;
×
185
            }
186
            if (i > 0) {
6✔
187
                String parentLabel = pathRefs[i - 1].getLabel();
21✔
188
                label = stripParentPrefix(parentLabel, label);
12✔
189
                label = stripParentOverlap(parentLabel, label);
12✔
190
            }
191
            // Show only the leading segment (before the first list/title separator)
192
            // so each path level renders as one concise crumb — e.g.
193
            // "FIP.38.T.8 | FAIR Implementation Profile Training Session 8" → "FIP.38.T.8",
194
            // "3PFF: the Three Point FAIRification Framework" → "3PFF".
195
            List<String> segments = splitLabel(label);
9✔
196
            if (!segments.isEmpty()) {
9!
197
                parts.add(new CrumbPart(pathRefs[i], truncateLabel(segments.get(0))));
42✔
198
            }
199
        }
200
        return parts;
6✔
201
    }
202

203
    /**
204
     * Truncates an over-long crumb label so a single crumb never blows up the
205
     * breadcrumb strip. Delegates to {@link Utils#truncateLinkLabel(String)} so
206
     * breadcrumbs and entity links share one truncation rule.
207
     */
208
    static String truncateLabel(String label) {
209
        return Utils.truncateLinkLabel(label);
9✔
210
    }
211

212
    /**
213
     * Splits a label on list/title separators — comma, colon, semicolon, pipe
214
     * (with or without surrounding spaces), or a space-surrounded hyphen — into
215
     * trimmed, non-empty segments. Returns the trimmed whole label if there is
216
     * no separator (so the result always has at least one element).
217
     */
218
    static List<String> splitLabel(String label) {
219
        List<String> segments = new ArrayList<>();
12✔
220
        if (label == null) return segments;
6!
221
        for (String s : label.split("\\s*[,;:]\\s+|\\s*\\|\\s*|\\s+-\\s+")) {
54✔
222
            String trimmed = s.trim();
9✔
223
            if (!trimmed.isEmpty()) segments.add(trimmed);
21!
224
        }
225
        if (segments.isEmpty()) {
9!
226
            String trimmed = label.trim();
×
227
            if (!trimmed.isEmpty()) segments.add(trimmed);
×
228
        }
229
        return segments;
6✔
230
    }
231

232
    /**
233
     * If the child label appears to restate the parent's "topic" at the start,
234
     * strips that shared prefix and returns the remainder. Otherwise returns
235
     * the child label unchanged.
236
     */
237
    private static String stripParentPrefix(String parent, String child) {
238
        if (parent == null || parent.isEmpty() || child == null) return child;
21!
239
        int lcp = 0;
6✔
240
        int max = Math.min(parent.length(), child.length());
18✔
241
        while (lcp < max && parent.charAt(lcp) == child.charAt(lcp)) lcp++;
36✔
242
        // Require a substantial match: at least 3 chars, and within 2 chars of
243
        // the full parent length (so things like "Sessions" vs "Session" still
244
        // match, but "Nanopublication Sessions" vs "Nano Session #30" doesn't).
245
        if (lcp < 3 || lcp < parent.length() - 2) return child;
33!
246
        // The boundary in the child must not fall mid-word, otherwise we'd be
247
        // chopping off a partial word like "Foo Bar" -> "Foo Baz Quux" -> "z Quux".
248
        if (lcp >= child.length() || Character.isLetterOrDigit(child.charAt(lcp))) return child;
27!
249
        String remainder = child.substring(lcp).replaceAll("^\\s+", "");
21✔
250
        if (remainder.isEmpty()) return child;
9!
251
        return remainder;
6✔
252
    }
253

254
    /**
255
     * If the child label's leading word(s) restate the parent label's trailing
256
     * word(s), strips that overlap and returns the remainder. E.g. parent
257
     * "Abc Def Ghi", child "Ghi Jkl" → "Jkl", or parent "Knowledge Pixels
258
     * Incubator Office Hours", child "Office Hour 24 June 2026" → "24 June 2026".
259
     * The overlap is matched on whole words, case-insensitively and tolerating
260
     * singular/plural variation ("Hours" ≈ "Hour"); the longest parent-suffix that
261
     * matches a child-prefix wins. Returns the child unchanged when there is no
262
     * overlap, or when the overlap would consume the whole child label (at least
263
     * one word is always kept).
264
     */
265
    static String stripParentOverlap(String parent, String child) {
266
        if (parent == null || parent.isEmpty() || child == null || child.isEmpty()) return child;
36!
267
        String[] parentWords = parent.trim().split("\\s+");
15✔
268
        String[] childWords = child.trim().split("\\s+");
15✔
269
        // Keep at least one child word, so the overlap can cover at most all but
270
        // the last child word.
271
        int maxK = Math.min(parentWords.length, childWords.length - 1);
24✔
272
        for (int k = maxK; k >= 1; k--) {
21✔
273
            boolean match = true;
6✔
274
            for (int j = 0; j < k; j++) {
21✔
275
                if (!wordsEquivalent(parentWords[parentWords.length - k + j], childWords[j])) {
39✔
276
                    match = false;
6✔
277
                    break;
3✔
278
                }
279
            }
280
            if (match) {
6✔
281
                return String.join(" ", java.util.Arrays.asList(childWords).subList(k, childWords.length));
27✔
282
            }
283
        }
284
        return child;
6✔
285
    }
286

287
    /**
288
     * Whether two breadcrumb words count as the same topic word: equal ignoring
289
     * case, or equal once a single trailing plural "s" is dropped from each (so
290
     * "Hours" matches "Hour", "Sessions" matches "Session").
291
     */
292
    private static boolean wordsEquivalent(String a, String b) {
293
        if (a.equalsIgnoreCase(b)) return true;
18✔
294
        return depluralize(a).equalsIgnoreCase(depluralize(b));
18✔
295
    }
296

297
    private static String depluralize(String word) {
298
        return word.length() > 1 && (word.endsWith("s") || word.endsWith("S"))
39!
299
                ? word.substring(0, word.length() - 1)
24✔
300
                : word;
3✔
301
    }
302

303
    /**
304
     * Places a resource tab strip (Content | About | Raw) on the right side of
305
     * the breadcrumb strip, and forces the strip visible so the tabs show even on
306
     * pages without a breadcrumb (e.g. user pages). Returns {@code this} for
307
     * fluent use at the {@code add(...)} call site.
308
     *
309
     * @param tabs the tab strip (its markup id must be {@code "tabs"})
310
     * @return this TitleBar
311
     */
312
    public TitleBar setTabs(ResourceTabs tabs) {
313
        breadcrumbPath.addOrReplace(tabs);
×
314
        breadcrumbPath.setVisible(true);
×
315
        return this;
×
316
    }
317

318
    private BookmarkablePageLink<Void> createNavLink(String id, Class<? extends WebPage> pageClass, PageParameters params) {
319
        BookmarkablePageLink<Void> link = new BookmarkablePageLink<>(id, pageClass, params);
21✔
320
        if (id.equals(highlight)) {
15!
321
            link.add(new AttributeAppender("class", "selected"));
×
322
        }
323
        add(link);
27✔
324
        return link;
6✔
325
    }
326

327
}
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