From 90da48c1234aaf43bed3c2212bba0d69e21ecb8d Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sat, 24 Oct 2026 16:43:04 -0400
Subject: [PATCH 1/4] Admin UI polish: pagination, accordions, spacing, date
fields
- One pager for Entries, Comments and media search: First / Newer /
Page N of M / Older / Last. Backed by new count queries that share
the list queries' WHERE clauses. Media search results are reachable
past page 1 for the first time.
- Entry editor panels are Bootstrap 5 accordions with a chevron.
- Spacing for editor buttons, accordions and sidebar filter forms.
- Comment and entry tables fit narrow screens; long unbroken spam
URLs wrap.
- Date fields: attached calendar button that works, clear button on
filters, no longer greyed out; one shared script replaces five
per-page snippets.
- CommentTest had no @Test annotations and never ran; it does now.
---
.../weblogger/business/MediaFileManager.java | 7 +
.../business/WeblogEntryManager.java | 18 +
.../business/jpa/JPAMediaFileManagerImpl.java | 89 +-
.../jpa/JPAWeblogEntryManagerImpl.java | 134 ++-
.../admin/GlobalCommentManagement.java | 38 +-
.../weblogger/ui/struts2/editor/Comments.java | 34 +-
.../weblogger/ui/struts2/editor/Entries.java | 18 +-
.../struts2/editor/MediaFileSearchBean.java | 420 ++++----
.../ui/struts2/editor/MediaFileView.java | 978 +++++++++---------
.../ui/struts2/pagers/CommentsPager.java | 70 +-
.../ui/struts2/pagers/EntriesPager.java | 70 +-
.../ui/struts2/pagers/LinkPager.java | 110 ++
.../ui/struts2/pagers/MediaFilePager.java | 157 ++-
.../resources/ApplicationResources.properties | 8 +
.../webapp/WEB-INF/jsps/editor/Comments.jsp | 427 ++++----
.../WEB-INF/jsps/editor/CommentsSidebar.jsp | 50 +-
.../webapp/WEB-INF/jsps/editor/Entries.jsp | 208 ++--
.../WEB-INF/jsps/editor/EntriesSidebar.jsp | 38 +-
.../webapp/WEB-INF/jsps/editor/EntryEdit.jsp | 114 +-
.../WEB-INF/jsps/editor/EntryEditor.jsp | 23 +-
.../WEB-INF/jsps/editor/MediaFilePager.jsp | 83 ++
.../WEB-INF/jsps/editor/MediaFileSidebar.jsp | 8 +
.../WEB-INF/jsps/editor/MediaFileView.jsp | 8 +-
.../main/webapp/WEB-INF/jsps/editor/Pager.jsp | 78 ++
.../WEB-INF/jsps/editor/TemplateEdit.jsp | 24 +-
.../main/webapp/WEB-INF/jsps/tiles/head.jsp | 1 +
.../webapp/roller-ui/scripts/date-fields.js | 35 +
.../main/webapp/roller-ui/styles/roller.css | 44 +-
.../weblogger/business/CommentTest.java | 8 +
.../weblogger/business/MediaFileTest.java | 6 +
.../weblogger/business/WeblogEntryTest.java | 39 +
.../weblogger/ui/EntryEditorAssetsTest.java | 23 +-
.../ui/struts2/pagers/LinkPagerTest.java | 98 ++
33 files changed, 1915 insertions(+), 1551 deletions(-)
create mode 100644 app/src/main/java/org/apache/roller/weblogger/ui/struts2/pagers/LinkPager.java
create mode 100644 app/src/main/webapp/WEB-INF/jsps/editor/MediaFilePager.jsp
create mode 100644 app/src/main/webapp/WEB-INF/jsps/editor/Pager.jsp
create mode 100644 app/src/main/webapp/roller-ui/scripts/date-fields.js
create mode 100644 app/src/test/java/org/apache/roller/weblogger/ui/struts2/pagers/LinkPagerTest.java
diff --git a/app/src/main/java/org/apache/roller/weblogger/business/MediaFileManager.java b/app/src/main/java/org/apache/roller/weblogger/business/MediaFileManager.java
index f8a3611328..326b46feb4 100644
--- a/app/src/main/java/org/apache/roller/weblogger/business/MediaFileManager.java
+++ b/app/src/main/java/org/apache/roller/weblogger/business/MediaFileManager.java
@@ -103,6 +103,13 @@ void removeMediaFile(Weblog weblog, MediaFile mediaFile)
List searchMediaFiles(Weblog weblog,
MediaFileFilter filter) throws WebloggerException;
+ /**
+ * Count the media files searchMediaFiles() would return for the same filter,
+ * ignoring start index, length and order. Used to size pagers.
+ */
+ long getMediaFileCount(Weblog weblog,
+ MediaFileFilter filter) throws WebloggerException;
+
/**
* Create default folder for media files in a weblog.
*/
diff --git a/app/src/main/java/org/apache/roller/weblogger/business/WeblogEntryManager.java b/app/src/main/java/org/apache/roller/weblogger/business/WeblogEntryManager.java
index 3ed56d16b6..f841193e72 100644
--- a/app/src/main/java/org/apache/roller/weblogger/business/WeblogEntryManager.java
+++ b/app/src/main/java/org/apache/roller/weblogger/business/WeblogEntryManager.java
@@ -79,6 +79,16 @@ WeblogEntry getWeblogEntryByAnchor(Weblog website, String anchor)
List getWeblogEntries(WeblogEntrySearchCriteria wesc)
throws WebloggerException;
+ /**
+ * Count the rows getWeblogEntries() would return for the same criteria,
+ * ignoring offset, max results and sort order. Used to size pagers.
+ * @param wesc WeblogEntrySearchCriteria object listing desired search parameters
+ * @return number of matching rows
+ * @throws WebloggerException
+ */
+ long getWeblogEntryCount(WeblogEntrySearchCriteria wesc)
+ throws WebloggerException;
+
/**
* Get Weblog Entries grouped by day.
* @param wesc WeblogEntrySearchCriteria object listing desired search parameters
@@ -227,6 +237,14 @@ List getWeblogCategories(Weblog website)
*/
List getComments(CommentSearchCriteria csc) throws WebloggerException;
+ /**
+ * Count the comments getComments() would return for the same criteria,
+ * ignoring offset, max results and sort order. Used to size pagers.
+ * @param csc CommentSearchCriteria object with fields indicating search criteria
+ * @return number of matching comments
+ */
+ long getCommentCount(CommentSearchCriteria csc) throws WebloggerException;
+
/**
* Deletes comments that match paramters.
* @param website Website or null for all comments on site
diff --git a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAMediaFileManagerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAMediaFileManagerImpl.java
index e3c0ddcaa4..a3eec1b38e 100644
--- a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAMediaFileManagerImpl.java
+++ b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAMediaFileManagerImpl.java
@@ -583,11 +583,65 @@ public List searchMediaFiles(Weblog weblog,
MediaFileFilter filter) throws WebloggerException {
List
-
-
-
- | |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
- |
-
-
-
- <%-- with style if comment is spam or pending --%>
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
-
+
+
+
+
+ | |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+ |
+
+
+
+ <%-- with style if comment is spam or pending --%>
+
+
-
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
-
-
-
-
-
-
- |
+
+
+
+
+
+
+ |
-
-
-
+ |
+
+
+
+
+
+
-
-
-
-
-
- |
+
+ |
-
-
- |
-
-
-
-
- "
- data-post-title="">
-
-
-
- |
+
+
+ |
+
+
+
+
+ "
+ data-post-title="">
+
+
+
+ |
-
-
+
+
- |
+ | |
+
<%-- ============================================================= --%>
<%-- Next / previous links --%>
-
+
diff --git a/app/src/main/webapp/WEB-INF/jsps/editor/EntriesSidebar.jsp b/app/src/main/webapp/WEB-INF/jsps/editor/EntriesSidebar.jsp
index e58527d923..ae3b9bab6f 100644
--- a/app/src/main/webapp/WEB-INF/jsps/editor/EntriesSidebar.jsp
+++ b/app/src/main/webapp/WEB-INF/jsps/editor/EntriesSidebar.jsp
@@ -22,7 +22,7 @@
-
+
@@ -49,7 +49,7 @@
<%-- filter by date --%>
-
-
+
@@ -74,16 +79,19 @@
-
-
-
+
+
-
-
<%-- ========================================================= --%>
<%-- filter by status --%>
@@ -106,12 +114,4 @@
-
diff --git a/app/src/main/webapp/WEB-INF/jsps/editor/EntryEdit.jsp b/app/src/main/webapp/WEB-INF/jsps/editor/EntryEdit.jsp
index 59b6cd8582..e93b76ded7 100644
--- a/app/src/main/webapp/WEB-INF/jsps/editor/EntryEdit.jsp
+++ b/app/src/main/webapp/WEB-INF/jsps/editor/EntryEdit.jsp
@@ -150,7 +150,7 @@
-
+
<%-- Weblog editor --%>
@@ -160,17 +160,16 @@
-
-
-
-
+
+
+
+
@@ -182,18 +181,16 @@
<%-- Advanced settings --%>
-
-
-
-
+
+
+
+
@@ -213,9 +210,10 @@
-
-
-
+
@@ -257,43 +255,45 @@
<%-- ================================================================== --%>
<%-- The button box --%>
- <%-- save draft --%>
-
+
-
+ <%-- save draft --%>
+
- <%-- preview mode --%>
-
-
-
+
- <%-- publish --%>
-
-
-
+ <%-- preview mode --%>
+
+
+
- <%-- submit for review --%>
-
-
+ <%-- publish --%>
+
+
+
-
+ <%-- submit for review --%>
+
+
+
+
- <%-- delete --%>
-
-
+ "
data-post-title="">
-
-
+
+
+
@@ -363,10 +363,6 @@
+
diff --git a/app/src/main/webapp/roller-ui/scripts/date-fields.js b/app/src/main/webapp/roller-ui/scripts/date-fields.js
new file mode 100644
index 0000000000..fd4e01a5e0
--- /dev/null
+++ b/app/src/main/webapp/roller-ui/scripts/date-fields.js
@@ -0,0 +1,35 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+
+/*
+ * Date fields in the admin UI: a readonly input.date-picker inside an .input-group,
+ * optionally followed by a .date-picker-show button (opens the picker) and a
+ * .date-picker-clear button (empties the field, so a filter date can be removed).
+ */
+$(function () {
+ $(".date-picker").datepicker();
+
+ $(document).on("click", ".date-picker-show", function () {
+ $(this).closest(".input-group").find(".date-picker").datepicker("show");
+ });
+
+ $(document).on("click", ".date-picker-clear", function () {
+ $(this).closest(".input-group").find(".date-picker").val("").trigger("change");
+ });
+});
diff --git a/app/src/main/webapp/roller-ui/styles/roller.css b/app/src/main/webapp/roller-ui/styles/roller.css
index b8dea7b99a..f3dd485828 100644
--- a/app/src/main/webapp/roller-ui/styles/roller.css
+++ b/app/src/main/webapp/roller-ui/styles/roller.css
@@ -82,20 +82,34 @@ table.mm_table_actions td {
/* edit entry page */
-/* Only the panel collapse toggles, not links in other toolbars that use
- .panel-heading, such as the rich text editor's menus */
-.panel-heading a[data-toggle="collapse"]:after {
- font-family:'Glyphicons Halflings';
- content:"\e114";
- float: right;
- color: grey;
+/* Panel titles at the size of a section label, not a page heading */
+.accordion-button {
+ font-size: 1.1rem;
+ font-weight: 500;
}
-.panel-heading a[data-toggle="collapse"].collapsed:after {
- content:"\e080";
+
+/* entries and comments pages */
+
+/* Sidebar filter forms: the bootstrap theme adds no spacing to form-vertical groups */
+.roller-filter-form > div {
+ margin-bottom: 1rem;
+}
+
+.roller-filter-form input[type="submit"] {
+ margin-top: 0.5rem;
+}
+
+.roller-pager {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 1rem 0;
}
-#panel-plugins { /* Kludge for EntryEditor.jsp */
- margin-top: 6px; /* Without this there no space between Summary and Plugins to Apply panels */
+.roller-pager-page {
+ color: var(--bs-secondary-color);
+ font-size: 0.875rem;
}
/* keep jQuery UI datepicker in front of other elements */
@@ -165,6 +179,8 @@ table.innertable tbody td {
}
table.innertable td.viewbody {
padding: 5px;
+ /* spam often holds long unbroken URLs; let them wrap so the table fits the page */
+ overflow-wrap: anywhere;
}
table.innertable tr .bot {
margin-bottom: 5px;
@@ -357,6 +373,12 @@ tr.rollertable_pending {
opacity: 1;
}
+/* Date fields are readonly only so the picker sets them; they are not locked */
+.form-control.date-picker[readonly] {
+ background-color: var(--bs-body-bg);
+ cursor: pointer;
+}
+
/* Field hint icons sit next to the label rather than in the text flow. */
.s2b_tooltip {
margin-left: .35rem;
diff --git a/app/src/test/java/org/apache/roller/weblogger/business/CommentTest.java b/app/src/test/java/org/apache/roller/weblogger/business/CommentTest.java
index 075c1f141d..df55fd99b6 100644
--- a/app/src/test/java/org/apache/roller/weblogger/business/CommentTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/business/CommentTest.java
@@ -30,6 +30,7 @@
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@@ -85,6 +86,7 @@ public void tearDown() throws Exception {
/**
* Test basic persistence operations ... Create, Update, Delete
*/
+ @Test
public void testCommentCRUD() throws Exception {
WeblogEntryManager mgr = WebloggerFactory.getWeblogger().getWeblogEntryManager();
@@ -132,6 +134,7 @@ public void testCommentCRUD() throws Exception {
/**
* Test lookup mechanisms ...
*/
+ @Test
public void testCommentLookups() throws Exception {
WeblogEntryManager mgr = WebloggerFactory.getWeblogger().getWeblogEntryManager();
@@ -149,6 +152,7 @@ public void testCommentLookups() throws Exception {
comments = mgr.getComments(csc);
assertNotNull(comments);
assertEquals(3, comments.size());
+ assertEquals(3, mgr.getCommentCount(csc));
// get all comments for entry
testEntry = TestUtils.getManagedWeblogEntry(testEntry);
@@ -169,6 +173,7 @@ public void testCommentLookups() throws Exception {
comments = mgr.getComments(csc);
assertNotNull(comments);
assertEquals(1, comments.size());
+ assertEquals(1, mgr.getCommentCount(csc));
// get approved comments
csc.setStatus(ApprovalStatus.APPROVED);
@@ -182,6 +187,8 @@ public void testCommentLookups() throws Exception {
comments = mgr.getComments(csc);
assertNotNull(comments);
assertEquals(2, comments.size());
+ // the count ignores offset: it sizes the pager across all pages
+ assertEquals(3, mgr.getCommentCount(csc));
// remove test comments
TestUtils.teardownComment(comment1.getId());
@@ -197,6 +204,7 @@ public void testCommentLookups() throws Exception {
* should delete all comments on that entry, and deleting a weblog should
* delete all comments, etc.
*/
+ @Test
public void testCommentParentDeletes() throws Exception {
log.info("BEGIN");
diff --git a/app/src/test/java/org/apache/roller/weblogger/business/MediaFileTest.java b/app/src/test/java/org/apache/roller/weblogger/business/MediaFileTest.java
index 7e89295748..32515d5e25 100644
--- a/app/src/test/java/org/apache/roller/weblogger/business/MediaFileTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/business/MediaFileTest.java
@@ -505,6 +505,7 @@ public void testSearchMediaFile() throws Exception {
filter1.setName("mytest.jpg");
searchResults = mfMgr.searchMediaFiles(testWeblog, filter1);
assertTrue(searchResults.isEmpty());
+ assertEquals(0, mfMgr.getMediaFileCount(testWeblog, filter1));
MediaFileFilter filter2 = new MediaFileFilter();
filter2.setName("test_home.jpg");
@@ -542,6 +543,7 @@ public void testSearchMediaFile() throws Exception {
searchResults = mfMgr.searchMediaFiles(testWeblog, filter4);
assertFalse(searchResults.isEmpty());
assertEquals(1, searchResults.size());
+ assertEquals(1, mfMgr.getMediaFileCount(testWeblog, filter4));
assertEquals("test_work.jpg", searchResults.get(0).getName());
// search by size
@@ -576,6 +578,7 @@ public void testSearchMediaFile() throws Exception {
searchResults = mfMgr.searchMediaFiles(testWeblog, filter9);
assertFalse(searchResults.isEmpty());
assertEquals(2, searchResults.size());
+ assertEquals(2, mfMgr.getMediaFileCount(testWeblog, filter9));
MediaFileFilter filter10 = new MediaFileFilter();
filter10.setSize(3000);
@@ -598,6 +601,7 @@ public void testSearchMediaFile() throws Exception {
searchResults = mfMgr.searchMediaFiles(testWeblog, filter12);
assertFalse(searchResults.isEmpty());
assertEquals(2, searchResults.size());
+ assertEquals(2, mfMgr.getMediaFileCount(testWeblog, filter12));
} finally {
TestUtils.endSession(true);
@@ -668,6 +672,8 @@ public void testSearchMediaFilePaging() throws Exception {
assertFalse(searchResults2.isEmpty());
assertEquals(3, searchResults2.size());
assertEquals("test_file13.jpg", searchResults2.get(0).getName());
+ // the count ignores start index and length: it sizes the pager across all pages
+ assertEquals(15, mfMgr.getMediaFileCount(testWeblog, filter2));
MediaFileFilter filter3 = new MediaFileFilter();
filter3.setSize(1000);
diff --git a/app/src/test/java/org/apache/roller/weblogger/business/WeblogEntryTest.java b/app/src/test/java/org/apache/roller/weblogger/business/WeblogEntryTest.java
index b9398d9262..04837304aa 100644
--- a/app/src/test/java/org/apache/roller/weblogger/business/WeblogEntryTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/business/WeblogEntryTest.java
@@ -209,6 +209,7 @@ public void testWeblogEntryLookups() throws Exception {
assertNotNull(entries);
assertEquals(5, entries.size());
assertEquals(entry3, entries.get(0));
+ assertEquals(5, mgr.getWeblogEntryCount(wesc));
// get all (non-future) PUBLISHED entries in category
WeblogEntrySearchCriteria wesc9 = new WeblogEntrySearchCriteria();
@@ -226,6 +227,7 @@ public void testWeblogEntryLookups() throws Exception {
entries = mgr.getWeblogEntries(wesc2);
assertNotNull(entries);
assertEquals(3, entries.size());
+ assertEquals(3, mgr.getWeblogEntryCount(wesc2));
// get all entries in date range
WeblogEntrySearchCriteria wesc3 = new WeblogEntrySearchCriteria();
@@ -244,6 +246,8 @@ public void testWeblogEntryLookups() throws Exception {
entries = mgr.getWeblogEntries(wesc4);
assertNotNull(entries);
assertEquals(2, entries.size());
+ // the count ignores maxResults: it sizes the pager across all pages
+ assertEquals(5, mgr.getWeblogEntryCount(wesc4));
// get all entries in category
WeblogEntrySearchCriteria wesc5 = new WeblogEntrySearchCriteria();
@@ -262,6 +266,7 @@ public void testWeblogEntryLookups() throws Exception {
assertNotNull(entries);
assertEquals(1, entries.size());
assertEquals(entry2, entries.get(0));
+ assertEquals(5, mgr.getWeblogEntryCount(wesc6));
// get all entries, limited by locale
WeblogEntrySearchCriteria wesc7 = new WeblogEntrySearchCriteria();
@@ -271,6 +276,7 @@ public void testWeblogEntryLookups() throws Exception {
assertNotNull(entries);
assertEquals(4, entries.size());
assertEquals(entry3, entries.get(0));
+ assertEquals(4, mgr.getWeblogEntryCount(wesc7));
// get pinned entries only
entries = mgr.getWeblogEntriesPinnedToMain(5);
@@ -575,6 +581,39 @@ public void testGetEntriesByTag() throws Exception {
}
+ /**
+ * The entry count must match the rows getWeblogEntries() returns, even when a
+ * multi-tag search joins an entry once per matching tag, or the pager's last page is wrong.
+ */
+ @Test
+ public void testWeblogEntryCountWithMultipleTags() throws Exception {
+
+ WeblogEntryManager mgr = WebloggerFactory.getWeblogger().getWeblogEntryManager();
+
+ testWeblog = TestUtils.getManagedWebsite(testWeblog);
+ testUser = TestUtils.getManagedUser(testUser);
+ WeblogEntry entry1 = TestUtils.setupWeblogEntry("entry1", testWeblog, testUser);
+ entry1.addTag("counttagone");
+ entry1.addTag("counttagtwo");
+ mgr.saveWeblogEntry(entry1);
+ WeblogEntry entry2 = TestUtils.setupWeblogEntry("entry2", testWeblog, testUser);
+ entry2.addTag("counttagone");
+ mgr.saveWeblogEntry(entry2);
+ TestUtils.endSession(true);
+
+ testWeblog = TestUtils.getManagedWebsite(testWeblog);
+ WeblogEntrySearchCriteria wesc = new WeblogEntrySearchCriteria();
+ wesc.setWeblog(testWeblog);
+ wesc.setTags(Arrays.asList("counttagone", "counttagtwo"));
+ List
results = mgr.getWeblogEntries(wesc);
+ assertEquals(results.size(), mgr.getWeblogEntryCount(wesc));
+
+ TestUtils.teardownWeblogEntry(entry1.getId());
+ TestUtils.teardownWeblogEntry(entry2.getId());
+ TestUtils.endSession(true);
+ }
+
+
@Test
public void testRemoveEntryTagCascading() throws Exception {
diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/EntryEditorAssetsTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/EntryEditorAssetsTest.java
index 02d9fed204..5528aa9030 100644
--- a/app/src/test/java/org/apache/roller/weblogger/ui/EntryEditorAssetsTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/ui/EntryEditorAssetsTest.java
@@ -63,15 +63,24 @@ public void summernoteClosesItsIconElements() throws IOException {
@Test
public void panelChevronIsOnlyOnCollapseToggles() throws IOException {
+ // The entry editor's panels are Bootstrap 5 accordions, which draw their own
+ // chevron on .accordion-button. A roller.css chevron on ".panel-heading a"
+ // also lands on the rich text editor's menu items, so none may come back.
String css = read(WEBAPP.resolve("roller-ui/styles/roller.css"));
- Matcher rule = Pattern.compile("([^{}]*\\.panel-heading\\s+a[^{}]*:after)\\s*\\{").matcher(css);
- boolean found = false;
- while (rule.find()) {
- found = true;
- assertTrue(rule.group(1).contains("[data-toggle=\"collapse\"]"),
- "rule must be limited to collapse toggles: " + rule.group(1).trim());
+ assertFalse(Pattern.compile("\\.panel-heading[^{}]*:after").matcher(css).find(),
+ "roller.css must not add a chevron to .panel-heading links");
+
+ for (String jsp : new String[] {"EntryEdit.jsp", "EntryEditor.jsp"}) {
+ String page = read(WEBAPP.resolve("WEB-INF/jsps/editor/" + jsp));
+ Matcher toggle = Pattern.compile("<[^>]*data-bs-toggle=\"collapse\"[^>]*>").matcher(page);
+ boolean found = false;
+ while (toggle.find()) {
+ found = true;
+ assertTrue(toggle.group().contains("accordion-button"),
+ jsp + " collapse toggle must be an accordion button: " + toggle.group());
+ }
+ assertTrue(found, jsp + " has collapsible panels");
}
- assertTrue(found, "roller.css still styles the panel collapse toggles");
}
private static String read(Path file) throws IOException {
diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/struts2/pagers/LinkPagerTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/pagers/LinkPagerTest.java
new file mode 100644
index 0000000000..01a0d2c49b
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/pagers/LinkPagerTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. The ASF licenses this file to You
+ * under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. For additional information regarding
+ * copyright in this work, please see the NOTICE file in the top level
+ * directory of this distribution.
+ */
+
+package org.apache.roller.weblogger.ui.struts2.pagers;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+public class LinkPagerTest {
+
+ private static final String URL = "/roller-ui/authoring/entries.rol?weblog=roller";
+
+ private static LinkPager pager(int page, long total) {
+ return new LinkPager<>(URL, page, Collections.emptyList(), total, 30);
+ }
+
+ @Test
+ public void testEmptyResultIsOnePage() {
+ LinkPager p = pager(0, 0);
+ assertEquals(1, p.getPageNumber());
+ assertEquals(1, p.getPageCount());
+ assertFalse(p.isMoreItems());
+ assertNull(p.getFirstLink());
+ assertNull(p.getPrevLink());
+ assertNull(p.getNextLink());
+ assertNull(p.getLastLink());
+ }
+
+ @Test
+ public void testFirstPage() {
+ LinkPager p = pager(0, 65);
+ assertEquals(1, p.getPageNumber());
+ assertEquals(3, p.getPageCount());
+ assertTrue(p.isMoreItems());
+ assertNull(p.getFirstLink());
+ assertNull(p.getPrevLink());
+ assertEquals(URL + "&bean.page=1", p.getNextLink());
+ assertEquals(URL + "&bean.page=2", p.getLastLink());
+ }
+
+ @Test
+ public void testMiddlePage() {
+ LinkPager p = pager(1, 65);
+ assertEquals(2, p.getPageNumber());
+ assertEquals(URL + "&bean.page=0", p.getFirstLink());
+ assertEquals(URL + "&bean.page=0", p.getPrevLink());
+ assertEquals(URL + "&bean.page=2", p.getNextLink());
+ assertEquals(URL + "&bean.page=2", p.getLastLink());
+ }
+
+ @Test
+ public void testLastPage() {
+ LinkPager p = pager(2, 65);
+ assertEquals(3, p.getPageNumber());
+ assertFalse(p.isMoreItems());
+ assertEquals(URL + "&bean.page=0", p.getFirstLink());
+ assertEquals(URL + "&bean.page=1", p.getPrevLink());
+ assertNull(p.getNextLink());
+ assertNull(p.getLastLink());
+ }
+
+ @Test
+ public void testExactMultipleOfPageSize() {
+ // 60 rows is exactly two pages, not three
+ assertEquals(2, pager(0, 60).getPageCount());
+ assertFalse(pager(1, 60).isMoreItems());
+ assertEquals(URL + "&bean.page=1", pager(0, 60).getLastLink());
+ }
+
+ @Test
+ public void testBaseUrlWithoutQueryString() {
+ LinkPager p = new LinkPager<>("/entries.rol", 0, Collections.emptyList(), 31, 30);
+ assertEquals("/entries.rol?bean.page=1", p.getNextLink());
+ }
+
+}
From 844b3913971a883a5e77bfb269ba4cc905212969 Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sun, 25 Oct 2026 14:09:55 -0400
Subject: [PATCH 2/4] Admin UI: space between stacked cards; smaller type scale
Bootstrap 5 sizes everything for a 16px root, which reads large in the
admin UI; a 15px root scales text, controls and spacing down together.
Page titles and sidebar headings get smaller sizes, and cards stacked in
a column (sidebar cards, setup steps) no longer touch.
---
.../main/webapp/roller-ui/styles/roller.css | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/app/src/main/webapp/roller-ui/styles/roller.css b/app/src/main/webapp/roller-ui/styles/roller.css
index f3dd485828..8e4bde61db 100644
--- a/app/src/main/webapp/roller-ui/styles/roller.css
+++ b/app/src/main/webapp/roller-ui/styles/roller.css
@@ -30,9 +30,31 @@ p {
margin: 1em;
}
+/* Bootstrap 5 sizes everything for a 16px root, which reads large in this
+ dense admin UI; 15px scales text, controls and spacing down together. */
+html {
+ font-size: 15px;
+}
+
.roller-page-title {
margin-top: 0px;
margin-bottom: 5px;
+ font-size: 1.75rem;
+}
+
+/* Section headings in sidebar cards */
+.roller-column-left h3 {
+ font-size: 1.25rem;
+}
+
+.roller-column-left h4 {
+ font-size: 1.1rem;
+}
+
+/* Cards stacked in a column (sidebar cards, setup steps, upload results)
+ would otherwise touch */
+.card + .card {
+ margin-top: 1rem;
}
.roller-column-right {
From 61821744fc5997ae2fd75e5e4d83d0c1ffe8877c Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sun, 25 Oct 2026 15:07:40 -0400
Subject: [PATCH 3/4] UI tests: open the entry editor's advanced settings by
its accordion button
The accordion headers are Bootstrap 5 buttons now, not links.
---
.../src/test/java/org/apache/roller/playwright/BaseIT.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java b/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
index 158f70f208..2c616cb75a 100644
--- a/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
+++ b/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
@@ -349,7 +349,7 @@ protected void insertMediaFileThroughDialog(String weblogHandle) {
* stacked form rows.
*/
protected void assertPublishingTimeOnOneLine() {
- page.locator("a[href='#collapseAdvanced']").click();
+ page.locator("[data-bs-target='#collapseAdvanced']").click();
assertThat(page.locator("#collapseAdvanced")).isVisible();
double hours = page.locator("select[name='bean.hours']").boundingBox().y;
double minutes = page.locator("select[name='bean.minutes']").boundingBox().y;
From 1a05350b80569ece6317745e9da48035a1664667 Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sun, 25 Oct 2026 20:41:26 -0400
Subject: [PATCH 4/4] UI tests: measure the publishing time selects after the
accordion opens
The check read each select's position separately while the collapse was
still animating, so the y values drifted apart on slower runners.
---
.../java/org/apache/roller/playwright/BaseIT.java | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java b/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
index 2c616cb75a..05859f2757 100644
--- a/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
+++ b/it-playwright/src/test/java/org/apache/roller/playwright/BaseIT.java
@@ -350,10 +350,15 @@ protected void insertMediaFileThroughDialog(String weblogHandle) {
*/
protected void assertPublishingTimeOnOneLine() {
page.locator("[data-bs-target='#collapseAdvanced']").click();
- assertThat(page.locator("#collapseAdvanced")).isVisible();
- double hours = page.locator("select[name='bean.hours']").boundingBox().y;
- double minutes = page.locator("select[name='bean.minutes']").boundingBox().y;
- double seconds = page.locator("select[name='bean.seconds']").boundingBox().y;
+ // Bootstrap adds "show" only when the collapse transition ends; read
+ // all three positions at once so the layout cannot move between reads
+ assertThat(page.locator("#collapseAdvanced.show:not(.collapsing)")).isVisible();
+ @SuppressWarnings("unchecked")
+ var ys = (java.util.List) page.evaluate("() => ['hours', 'minutes', 'seconds'].map(f =>"
+ + " document.querySelector(`select[name='bean.${f}']`).getBoundingClientRect().y)");
+ double hours = ys.get(0).doubleValue();
+ double minutes = ys.get(1).doubleValue();
+ double seconds = ys.get(2).doubleValue();
org.junit.jupiter.api.Assertions.assertTrue(
Math.abs(hours - minutes) < 2 && Math.abs(minutes - seconds) < 2,
"publishing time selects should share a line, but sit at y=" + hours + ", " + minutes + ", " + seconds);