mraible commented on code in PR #245:
URL: https://github.com/apache/roller/pull/245#discussion_r4545053859


##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,134 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 import org.apache.roller.weblogger.WebloggerException;
 import org.apache.roller.weblogger.business.WebloggerFactory;
 import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
 import org.apache.roller.weblogger.pojos.WeblogCategory;
 import org.apache.roller.weblogger.pojos.WeblogPermission;
 import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
 
 /**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
  */
-// TODO: make this work @AllowedMethods({"execute","move"})
 public class Categories extends UIAction {
 
-       private static Log log = LogFactory.getLog(Categories.class);
+    private static final Log log = LogFactory.getLog(Categories.class);
+
+    // all categories from the action weblog, in display order
+    private List<WeblogCategory> allCategories = Collections.emptyList();
 
-       // all categories from the action weblog
-       private List<WeblogCategory> allCategories;
+    // number of entries in each category, by category id
+    private Map<String, Long> entryCounts = Collections.emptyMap();
 
-       public Categories() {
-               this.actionName = "categories";
-               this.desiredMenu = "editor";
-               this.pageTitle = "categoriesForm.rootTitle";
-       }
+    // category ids in their new order, posted by reorder
+    private List<String> categoryIds = new ArrayList<>();
 
-       @Override
-       public List<String> requiredWeblogPermissionActions() {
-               return Collections.singletonList(WeblogPermission.POST);
-       }
+    public Categories() {
+        this.actionName = "categories";
+        this.desiredMenu = "editor";
+        this.pageTitle = "categoriesForm.rootTitle";
+    }
 
-       @Override
-       public String execute() {
-               try {
+    @Override
+    public List<String> requiredWeblogPermissionActions() {
+        return Collections.singletonList(WeblogPermission.POST);
+    }
+
+    @Override
+    public String execute() {
+        try {
             WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
-                       allCategories = 
wmgr.getWeblogCategories(getActionWeblog());
-               } catch (WebloggerException ex) {
-                       log.error("Error building categories list", ex);
-                       addError("Error building categories list");
-               }
-
-               return LIST;
-       }
-
-       public String move() {
-               return execute();
-       }
-
-       public List<WeblogCategory> getAllCategories() {
-               return allCategories;
-       }
-
-       public void setAllCategories(List<WeblogCategory> allCategories) {
-               this.allCategories = allCategories;
-       }
+            allCategories = wmgr.getWeblogCategories(getActionWeblog());
+            entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+        } catch (WebloggerException ex) {
+            log.error("Error building categories list", ex);
+            addError("generic.error.check.logs");
+        }
+        return LIST;
+    }
+
+    /**
+     * Save a new display order. The posted ids must name every category of
+     * the weblog exactly once.
+     */
+    public String reorder() {
+        try {
+            WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+            List<WeblogCategory> current = 
wmgr.getWeblogCategories(getActionWeblog());
+
+            Set<String> currentIds = new HashSet<>();
+            for (WeblogCategory category : current) {
+                currentIds.add(category.getId());
+            }
+            if (categoryIds.size() != current.size()
+                    || !currentIds.equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = 
wmgr.getWeblogCategory(getActionWeblog(), categoryIds.get(position));
+                if (category.getPosition() != position) {
+                    category.setPosition(position);

Review Comment:
   This updates each category's `position`, but `Weblog.weblogCategories` keeps 
its old order. It's only sorted by position when loaded from the database, and 
`Weblog` isn't excluded from EclipseLink's shared cache. Themes read the list 
through `WeblogWrapper.getWeblogCategories()`, so the public site may keep 
showing the old order until the weblog drops out of the cache. This page reads 
through an ordered named query, which would hide it. 
`WeblogCategory.calculatePosition()` also uses the last item of that list, so a 
category added afterwards could get a duplicate position. I haven't run this to 
confirm, but re-sorting `getActionWeblog().getWeblogCategories()` by position 
after saving should cover it.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,134 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 import org.apache.roller.weblogger.WebloggerException;
 import org.apache.roller.weblogger.business.WebloggerFactory;
 import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
 import org.apache.roller.weblogger.pojos.WeblogCategory;
 import org.apache.roller.weblogger.pojos.WeblogPermission;
 import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
 
 /**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
  */
-// TODO: make this work @AllowedMethods({"execute","move"})
 public class Categories extends UIAction {
 
-       private static Log log = LogFactory.getLog(Categories.class);
+    private static final Log log = LogFactory.getLog(Categories.class);
+
+    // all categories from the action weblog, in display order
+    private List<WeblogCategory> allCategories = Collections.emptyList();
 
-       // all categories from the action weblog
-       private List<WeblogCategory> allCategories;
+    // number of entries in each category, by category id
+    private Map<String, Long> entryCounts = Collections.emptyMap();
 
-       public Categories() {
-               this.actionName = "categories";
-               this.desiredMenu = "editor";
-               this.pageTitle = "categoriesForm.rootTitle";
-       }
+    // category ids in their new order, posted by reorder
+    private List<String> categoryIds = new ArrayList<>();
 
-       @Override
-       public List<String> requiredWeblogPermissionActions() {
-               return Collections.singletonList(WeblogPermission.POST);
-       }
+    public Categories() {
+        this.actionName = "categories";
+        this.desiredMenu = "editor";
+        this.pageTitle = "categoriesForm.rootTitle";
+    }
 
-       @Override
-       public String execute() {
-               try {
+    @Override
+    public List<String> requiredWeblogPermissionActions() {
+        return Collections.singletonList(WeblogPermission.POST);
+    }
+
+    @Override
+    public String execute() {
+        try {
             WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
-                       allCategories = 
wmgr.getWeblogCategories(getActionWeblog());
-               } catch (WebloggerException ex) {
-                       log.error("Error building categories list", ex);
-                       addError("Error building categories list");
-               }
-
-               return LIST;
-       }
-
-       public String move() {
-               return execute();
-       }
-
-       public List<WeblogCategory> getAllCategories() {
-               return allCategories;
-       }
-
-       public void setAllCategories(List<WeblogCategory> allCategories) {
-               this.allCategories = allCategories;
-       }
+            allCategories = wmgr.getWeblogCategories(getActionWeblog());
+            entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+        } catch (WebloggerException ex) {
+            log.error("Error building categories list", ex);
+            addError("generic.error.check.logs");
+        }
+        return LIST;
+    }
+
+    /**
+     * Save a new display order. The posted ids must name every category of
+     * the weblog exactly once.
+     */
+    public String reorder() {
+        try {
+            WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+            List<WeblogCategory> current = 
wmgr.getWeblogCategories(getActionWeblog());
+
+            Set<String> currentIds = new HashSet<>();
+            for (WeblogCategory category : current) {
+                currentIds.add(category.getId());
+            }
+            if (categoryIds.size() != current.size()
+                    || !currentIds.equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = 
wmgr.getWeblogCategory(getActionWeblog(), categoryIds.get(position));

Review Comment:
   Minor: this runs one `getWeblogCategory` query per posted id, although 
`current` already holds every category. Saves can fire on each drag, so a 
`Map<String, WeblogCategory>` built from `current` (which the id check needs 
anyway) would avoid N+1 queries.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        return message.replace("{0}", value);
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);
+        if (image && imagePreview.getAttribute("src") !== image) {
+            imagePreview.setAttribute("src", image);
+        }
+        saveButton.disabled = !nameOk || image === null;
+        return !saveButton.disabled;
+    }
+
+    function openEditor(row) {
+        var adding = !row;
+        document.getElementById("category-edit-title").textContent = adding ? 
text("titleAdd") : text("titleEdit");
+        document.getElementById("categoryEditForm_bean_id").value = adding ? 
"" : row.dataset.categoryId;
+        nameField.value = adding ? "" : row.dataset.categoryName;
+        descField.value = adding ? "" : row.dataset.categoryDesc;
+        imageField.value = adding ? "" : row.dataset.categoryImage;
+        showErrors(document.getElementById("categoryEditErrors"), []);
+        setInvalid(nameField, "categoryNameFeedback", "");
+        setInvalid(imageField, "categoryImageFeedback", "");
+        imagePreview.classList.add("d-none");
+        imagePreview.removeAttribute("src");
+        saveButton.disabled = adding;
+        showChooser(false);
+        if (!adding) {
+            validate();
+        }
+        editModal.show();
+    }
+
+    function saveCategory() {
+        if (!validate()) {
+            return;
+        }
+        var adding = document.getElementById("categoryEditForm_bean_id").value 
=== "";
+        var name = nameField.value.trim();
+        saveButton.disabled = true;
+        post(editForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryEditErrors"), 
errors);
+                saveButton.disabled = false;
+            } else {
+                reloadWith(format(adding ? text("msgAdded") : 
text("msgSaved"), name), name);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryEditErrors"), 
[text("msgError")]);
+            saveButton.disabled = false;
+        });
+    }
+
+    nameField.addEventListener("input", validate);
+    imageField.addEventListener("input", validate);
+    imagePreview.addEventListener("error", function () {
+        imagePreview.classList.add("d-none");
+    });
+    saveButton.addEventListener("click", saveCategory);
+    // Enter in a field saves; the form has no submit button and is never 
posted directly
+    editForm.addEventListener("submit", function (event) {
+        event.preventDefault();
+    });
+    editForm.addEventListener("keydown", function (event) {
+        if (event.key === "Enter" && event.target.matches("input")) {
+            event.preventDefault();
+            saveCategory();
+        }
+    });
+    document.getElementById("addCategoryButton").addEventListener("click", 
function () {
+        openEditor(null);
+    });
+    
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
 function () {
+        nameField.focus();
+    });
+
+    // --- choosing an image from the media library ---------------------------
+    // Only when uploads are on; otherwise the page has no chooser and these 
are null.
+
+    var chooser = document.getElementById("categoryImageChooser");
+    var chooserFrame = document.getElementById("categoryImageChooserFrame");
+    var editTitle = document.getElementById("category-edit-title");
+    var formTitle = "";
+
+    // the dialog shows either the form or the chooser, which needs a wider 
dialog
+    function showChooser(on) {
+        if (!chooser) {
+            return;
+        }
+        var showing = !chooser.classList.contains("d-none");
+        if (on === showing) {
+            return;
+        }
+        if (on) {
+            formTitle = editTitle.textContent;
+            editTitle.textContent = chooser.dataset.title;
+            chooserFrame.setAttribute("src", chooser.dataset.src);
+        } else {
+            editTitle.textContent = formTitle || editTitle.textContent;
+            chooserFrame.setAttribute("src", "about:blank");
+        }
+        chooser.classList.toggle("d-none", !on);
+        editForm.classList.toggle("d-none", on);
+        document.querySelector("#category-edit-modal 
.modal-dialog").classList.toggle("modal-lg", on);
+        
document.getElementById("categoryChooserBackButton").classList.toggle("d-none", 
!on);
+        
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+        saveButton.classList.toggle("d-none", on);
+    }
+
+    if (chooser) {
+        
document.getElementById("categoryImageChooseButton").addEventListener("click", 
function () {
+            showChooser(true);
+        });
+        
document.getElementById("categoryChooserBackButton").addEventListener("click", 
function () {
+            showChooser(false);
+            document.getElementById("categoryImageChooseButton").focus();
+        });
+        
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
 function () {
+            showChooser(false);
+        });
+
+        // called by the chooser in the frame (MediaFileImageChooser.jsp); it 
lists only images
+        window.onSelectMediaFile = function (name, url, isImage) {
+            if (isImage !== "true") {
+                return;
+            }
+            imageField.value = url;
+            showChooser(false);
+            validate();
+            imageField.focus();
+        };
+    }
+
+    // --- delete 
----------------------------------------------------------------
+
+    function openDelete(row) {
+        var id = row.dataset.categoryId;
+        var inUse = row.dataset.inUse === "true";
+        document.getElementById("category-delete-title").textContent = 
format(text("titleDelete"), row.dataset.categoryName);
+        document.getElementById("categoryRemove_removeId").value = id;
+        document.getElementById("categoryDeleteText").textContent = 
row.dataset.deleteText;
+        showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+        // the other categories, in display order, after the "choose" option
+        while (targetSelect.options.length > 1) {
+            targetSelect.remove(1);
+        }
+        list.querySelectorAll(".category-row").forEach(function (other) {
+            if (other.dataset.categoryId !== id) {
+                targetSelect.add(new Option(other.dataset.categoryName, 
other.dataset.categoryId));
+            }
+        });
+        targetSelect.value = "";
+
+        // a category nothing uses needs no target; a disabled select is not 
posted
+        
document.getElementById("categoryMoveTarget").classList.toggle("d-none", 
!inUse);
+        targetSelect.disabled = !inUse;
+        deleteButton.disabled = inUse;
+        deleteButton.dataset.categoryName = row.dataset.categoryName;
+        deleteModal.show();
+    }
+
+    targetSelect.addEventListener("change", function () {
+        deleteButton.disabled = targetSelect.value === "";
+    });
+
+    deleteButton.addEventListener("click", function () {
+        var name = deleteButton.dataset.categoryName;
+        deleteButton.disabled = true;
+        post(removeForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryDeleteErrors"), 
errors);
+                deleteButton.disabled = false;
+            } else {
+                reloadWith(format(text("msgRemoved"), name), null);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryDeleteErrors"), 
[text("msgError")]);
+            deleteButton.disabled = false;
+        });
+    });
+
+    // --- row actions 
-----------------------------------------------------------
+
+    list.addEventListener("click", function (event) {
+        var edit = event.target.closest(".category-edit-link");
+        var remove = event.target.closest(".category-delete-link");
+        if (edit) {
+            event.preventDefault();
+            openEditor(rowOf(edit));
+        } else if (remove) {
+            openDelete(rowOf(remove));
+        }
+    });
+
+    // --- reordering 
------------------------------------------------------------
+
+    var saveTimer = null;
+
+    function saveOrder() {

Review Comment:
   Salts are single-use (`SaltValidator.consumeSubmittedSalt` removes them), 
but `saveOrder` can fire again before the previous post returns: sortable's 
`update` runs on every drop, and keyboard moves are only debounced by 600ms. A 
second drag, or a drag followed by an arrow key, sends a spent salt and gets a 
"Security Violation" error, so the list shows an order that wasn't saved. The 
`.fail()` handler doesn't call `refreshSalt`, so every salt field on the page 
stays stale, and Add, Edit, Delete and further reorders all fail until a 
reload. Could this send one save at a time (queue the latest order, or disable 
reordering while one is in flight), and refresh the salt on failure too?



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        return message.replace("{0}", value);

Review Comment:
   `String.replace` expands `$` patterns in the replacement string, so a 
category named `Tips $& Tricks` produces the toast `Category "Tips {0} Tricks" 
added`. `$$` drops a dollar sign, and `` $` `` or `$'` splice in parts of the 
message. The delete dialog title goes through the same path. 
`message.replace("{0}", function () { return value; })` avoids it.



##########
app/src/main/webapp/WEB-INF/jsps/editor/Categories.jsp:
##########
@@ -17,319 +17,232 @@
 --%>
 <%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %>
 
-<p class="subtitle">
-    <s:text name="categoriesForm.subtitle">
-        <s:param value="weblog"/>
-    </s:text>
-</p>
-<p class="pagetip">
-    <s:text name="categoriesForm.rootPrompt"/>
-</p>
+<%-- Categories: one list in display order. Rows are dragged (or moved with 
the arrow keys on
+     their handle) to reorder; Add, Edit and Delete open dialogs that post 
with AJAX and reload
+     the page, which then shows a short confirmation. Text the script needs is 
in data-* attributes. --%>
+
+<div id="categoriesPage" class="categories-page"
+     data-msg-added="<s:text escapeHtml="true" name='categoryForm.created'/>"
+     data-msg-saved="<s:text escapeHtml="true" 
name='categoryForm.changesSaved'/>"
+     data-msg-removed="<s:text escapeHtml="true" name='categoryForm.removed'/>"
+     data-msg-order-saved="<s:text escapeHtml="true" 
name='categoriesForm.orderSaved'/>"
+     data-msg-error="<s:text escapeHtml="true" 
name='generic.error.check.logs'/>"
+     data-msg-name-required="<s:text escapeHtml="true" 
name='categoryForm.requiredFields'/>"
+     data-msg-bad-url="<s:text escapeHtml="true" name='categoryForm.badURL'/>"
+     data-title-add="<s:text escapeHtml="true" name='categoryForm.add.title'/>"
+     data-title-edit="<s:text escapeHtml="true" 
name='categoryForm.edit.title'/>"
+     data-title-delete="<s:text escapeHtml="true" 
name='categoryDeleteOK.heading'/>">
+
+<div class="categories-header">
+    <p class="subtitle mb-0">
+        <s:text name="categoriesForm.subtitle">
+            <s:param value="weblog"/>
+        </s:text>
+    </p>
+    <button type="button" id="addCategoryButton" class="btn btn-primary">
+        <span class="bi bi-plus-lg" aria-hidden="true"></span>
+        <s:text escapeHtml="true" name="categoriesForm.addCategory"/>
+    </button>
+</div>
+
+<p class="pagetip" id="categoriesTip"><s:text escapeHtml="true" 
name="categoriesForm.rootPrompt"/></p>
 
-<%-- Form is a table of categories each with checkbox --%>
-<s:form action="categories!move">
+<%-- carries the salt for the reorder post; the category ids are added by the 
script --%>
+<s:form id="categoriesReorderForm" action="categories!reorder" theme="simple">
     <s:hidden name="salt"/>
     <s:hidden name="weblog"/>
-    <s:hidden name="categoryId"/>
-
-    <table class="rollertable table table-striped" width="100%">
-
-        <tr class="rollertable">
-            <th width="30%"><s:text name="generic.name"/></th>
-            <th width="50%"><s:text name="generic.description"/></th>
-            <th width="10%"><s:text name="generic.edit"/></th>
-            <th width="10%"><s:text name="categoriesForm.remove"/></th>
-        </tr>
-
-        <s:if test="AllCategories != null && !AllCategories.isEmpty">
-
-            <s:iterator var="category" value="AllCategories" 
status="rowstatus">
-                <tr>
-                    <td><s:property value="#category.name"/></td>
-
-                    <td><s:property value="#category.description"/></td>
-
-                    <td align="center">
-
-                        <s:set var="categoryId"    value="#category.id" />
-                        <s:set var="categoryName"  value="#category.name" />
-                        <s:set var="categoryDesc"  
value="#category.description" />
-                        <s:set var="categoryImage" value="#category.image" />
-                        <a href="#" class="category-edit-link"
-                           data-category-id="<s:property value="categoryId" />"
-                           data-category-name="<s:property 
value="categoryName"/>"
-                           data-category-desc="<s:property 
value="categoryDesc"/>"
-                           data-category-image="<s:property 
value="categoryImage"/>">
-                            <span class="bi bi-pencil-square"></span>
-                        </a>
-
-                    </td>
-
-                    <td class="rollertable" align="center">
-                        <s:if test="AllCategories.size() > 1">
-
-                            <s:set var="categoryId"    value="#category.id" />
-                            <s:set var="categoryName"  value="#category.name" 
/>
-                            <s:set var="categoryInUse" 
value="#category.inUse.toString()" />
-                            <a href="#" class="category-delete-link"
-                               data-category-id="<s:property 
value="categoryId" />"
-                               data-category-name="<s:property 
value="categoryName" />"
-                               data-category-in-use="<s:property 
value="categoryInUse"/>">
-                                <span class="bi bi-trash"></span>
-                            </a>
-
-                        </s:if>
-                    </td>
-
-                </tr>
-            </s:iterator>
-
-        </s:if>
-
-        <s:else>
-            <tr>
-                <td style="vertical-align:middle" colspan="6"><s:text 
name="categoriesForm.noresults"/></td>
-            </tr>
-        </s:else>
-
-    </table>
-
 </s:form>
 
+<s:if test="allCategories.isEmpty()">
+    <p class="categories-empty"><s:text escapeHtml="true" 
name="categoriesForm.noresults"/></p>
+</s:if>
+
+<ul id="categoryList" class="category-list" aria-describedby="categoriesTip">
+    <s:iterator var="category" value="allCategories">
+        <s:url var="entriesURL" escapeAmp="false" action="entries">
+            <s:param name="weblog" value="%{actionWeblog.handle}"/>
+            <s:param name="bean.categoryName" value="%{#category.name}"/>
+        </s:url>
+        <li class="category-row"
+            data-category-id="<s:property value='#category.id'/>"
+            data-category-name="<s:property value='#category.name'/>"
+            data-category-desc="<s:property value='#category.description'/>"
+            data-category-image="<s:property value='#category.image'/>"
+            data-in-use="<s:property value='isInUse(#category.id)'/>"
+            data-delete-text="<s:if test='getEntryCount(#category.id) > 
0'><s:text escapeHtml="true" name='categoryDeleteOK.entriesInCategory'><s:param 
value='getEntryCount(#category.id)'/></s:text></s:if><s:elseif 
test='isInUse(#category.id)'><s:text escapeHtml="true" 
name='categoryDeleteOK.warningCatInUse'/></s:elseif><s:else><s:text 
escapeHtml="true" name='categoryDeleteOK.noEntriesInCat'/></s:else>">
+
+            <button type="button" class="category-handle"
+                    aria-label="<s:text escapeHtml="true" 
name='categoriesForm.moveHandle'><s:param value='#category.name'/></s:text>">
+                <span class="bi bi-grip-vertical" aria-hidden="true"></span>
+            </button>
+
+            <s:if test="#category.image != null && #category.image != ''">
+                <img class="category-image" src="<s:property 
value='#category.image'/>" alt="" loading="lazy">
+            </s:if>
+
+            <div class="category-main">
+                <a href="#" class="category-name 
category-edit-link"><s:property value="#category.name"/></a>
+                <s:if test="#category.description != null && 
#category.description != ''">
+                    <span class="category-desc"><s:property 
value="#category.description"/></span>
+                </s:if>
+            </div>
 
-<%-- ============================================================= --%>
-<%-- add/edit category modal --%>
-
-<div id="category-edit-modal" class="modal fade category-edit-modal" 
tabindex="-1" role="dialog">
+            <s:if test="getEntryCount(#category.id) > 0">
+                <a class="category-count" href="<s:property 
value='%{entriesURL}'/>"
+                   title="<s:text escapeHtml="true" 
name='categoriesForm.viewEntries'><s:param value='#category.name'/></s:text>">
+                    <s:text escapeHtml="true" 
name="categoriesForm.entryCount"><s:param 
value="getEntryCount(#category.id)"/></s:text>
+                </a>
+            </s:if>
+            <s:else>
+                <span class="category-count"><s:text escapeHtml="true" 
name="categoriesForm.entryCount"><s:param value="0"/></s:text></span>
+            </s:else>
+
+            <div class="category-actions">
+                <button type="button" class="btn btn-sm btn-outline-secondary 
category-edit-link">
+                    <span class="bi bi-pencil" aria-hidden="true"></span>
+                    <s:text escapeHtml="true" name="generic.edit"/>
+                </button>
+                <s:if test="allCategories.size() > 1">
+                    <button type="button" class="btn btn-sm btn-outline-danger 
category-delete-link">
+                        <span class="bi bi-trash" aria-hidden="true"></span>
+                        <s:text escapeHtml="true" name="generic.delete"/>
+                    </button>
+                </s:if>
+                <s:else>
+                    <%-- a disabled button gets no hover events, so the 
wrapper carries the tooltip --%>
+                    <span class="d-inline-block" tabindex="0" title="<s:text 
escapeHtml="true" name='categoriesForm.lastCategory'/>">
+                        <button type="button" class="btn btn-sm 
btn-outline-danger" disabled>
+                            <span class="bi bi-trash" 
aria-hidden="true"></span>
+                            <s:text escapeHtml="true" name="generic.delete"/>
+                        </button>
+                    </span>
+                </s:else>
+            </div>
+        </li>
+    </s:iterator>
+</ul>
+
+<div id="categoryStatus" class="visually-hidden" role="status" 
aria-live="polite"></div>
+
+<div class="toast-container position-fixed bottom-0 end-0 p-3">
+    <div id="categoryToast" class="toast align-items-center text-bg-success 
border-0" role="status" aria-atomic="true">
+        <div class="d-flex">
+            <div class="toast-body"></div>
+            <button type="button" class="btn-close btn-close-white me-2 
m-auto" data-bs-dismiss="toast"
+                    aria-label="<s:text escapeHtml="true" 
name='generic.close'/>"></button>
+        </div>
+    </div>
+</div>
 
-    <div class="modal-dialog modal-lg">
+<%-- add/edit dialog --%>
 
+<div id="category-edit-modal" class="modal fade" tabindex="-1" 
aria-labelledby="category-edit-title" aria-hidden="true">
+    <div class="modal-dialog">
         <div class="modal-content">
-
             <div class="modal-header">
-                <h3 id="category-edit-title"></h3>
+                <h3 class="modal-title fs-5" id="category-edit-title"></h3>
+                <button type="button" class="btn-close" 
data-bs-dismiss="modal" aria-label="<s:text escapeHtml="true" 
name='generic.close'/>"></button>
             </div>
-
             <div class="modal-body">
-                <s:form action="categoryEdit" id="categoryEditForm" 
theme="bootstrap" cssClass="form-horizontal">
+                <div id="categoryEditErrors" class="alert alert-danger d-none" 
role="alert"></div>
+                <s:form action="categoryEdit!save" id="categoryEditForm" 
theme="simple" novalidate="novalidate">
                     <s:hidden name="salt"/>
                     <s:hidden name="weblog"/>
-                    <s:hidden name="bean.id"/>
-
-                    <%-- action needed here because we are using AJAX to post 
this form --%>
-                    <s:hidden name="action:categoryEdit!save" value="save"/>
-
-                    <s:textfield name="bean.name" 
label="%{getText('generic.name')}" maxlength="255"
-                                 onchange="validateCategory()" 
onkeyup="validateCategory()" />
+                    <s:hidden name="bean.id" id="categoryEditForm_bean_id"/>
+
+                    <div class="mb-3">
+                        <label class="form-label" 
for="categoryEditForm_bean_name"><s:text escapeHtml="true" 
name="generic.name"/></label>
+                        <s:textfield name="bean.name" 
id="categoryEditForm_bean_name" value="" maxlength="255"
+                                     cssClass="form-control" 
required="required"
+                                     aria-describedby="categoryNameFeedback"/>
+                        <div id="categoryNameFeedback" 
class="invalid-feedback"></div>
+                    </div>
 
-                    <s:textfield name="bean.description" 
label="%{getText('generic.description')}"/>
+                    <div class="mb-3">
+                        <label class="form-label" 
for="categoryEditForm_bean_description"><s:text escapeHtml="true" 
name="generic.description"/></label>
+                        <s:textfield name="bean.description" 
id="categoryEditForm_bean_description" value=""
+                                     maxlength="255" cssClass="form-control"/>
+                    </div>
 
-                    <s:textfield name="bean.image" 
label="%{getText('categoryForm.image')}"
-                                 onchange="validateCategory()" 
onkeyup="validateCategory()" />
+                    <div class="mb-1">
+                        <label class="form-label" 
for="categoryEditForm_bean_image"><s:text escapeHtml="true" 
name="categoryForm.image"/></label>
+                        <div class="input-group has-validation">
+                            <s:textfield name="bean.image" 
id="categoryEditForm_bean_image" value="" maxlength="255"
+                                         cssClass="form-control" type="url"
+                                         aria-describedby="categoryImageHelp 
categoryImageFeedback"/>
+                            <s:if test="mediaLibraryEnabled">
+                                <button type="button" 
id="categoryImageChooseButton" class="btn btn-outline-secondary">
+                                    <span class="bi bi-images" 
aria-hidden="true"></span>
+                                    <s:text escapeHtml="true" 
name="categoryForm.chooseImage"/>
+                                </button>
+                            </s:if>
+                            <div id="categoryImageFeedback" 
class="invalid-feedback"></div>
+                        </div>
+                        <div id="categoryImageHelp" class="form-text"><s:text 
escapeHtml="true" name="%{mediaLibraryEnabled ? 'categoryForm.imageHelpMedia' : 
'categoryForm.imageHelp'}"/></div>
+                        <img id="categoryImagePreview" 
class="category-image-preview d-none" alt="">
+                    </div>
                 </s:form>
+                <s:if test="mediaLibraryEnabled">
+                    <%-- the media chooser, images only; shown in place of the 
form --%>
+                    <s:url var="imageChooserURL" 
action="mediaFileImageChooser" namespace="overlay" escapeAmp="false">
+                        <s:param name="weblog" value="%{actionWeblog.handle}"/>
+                        <s:param name="bean.type" 
value="'mediaFileView.image'"/>

Review Comment:
   Minor: `MediaFileImageChooser.IMAGES_ONLY` is documented as the value this 
page uses, but the JSP hardcodes `'mediaFileView.image'`. If either one 
changes, the chooser silently stops filtering to images and shows "Insert", and 
`onSelectMediaFile` ignores non-image clicks without telling the user. Exposing 
the constant (for example, a getter on `Categories`) would keep a single source.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        return message.replace("{0}", value);
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);
+        if (image && imagePreview.getAttribute("src") !== image) {
+            imagePreview.setAttribute("src", image);
+        }
+        saveButton.disabled = !nameOk || image === null;
+        return !saveButton.disabled;
+    }
+
+    function openEditor(row) {
+        var adding = !row;
+        document.getElementById("category-edit-title").textContent = adding ? 
text("titleAdd") : text("titleEdit");
+        document.getElementById("categoryEditForm_bean_id").value = adding ? 
"" : row.dataset.categoryId;
+        nameField.value = adding ? "" : row.dataset.categoryName;
+        descField.value = adding ? "" : row.dataset.categoryDesc;
+        imageField.value = adding ? "" : row.dataset.categoryImage;
+        showErrors(document.getElementById("categoryEditErrors"), []);
+        setInvalid(nameField, "categoryNameFeedback", "");
+        setInvalid(imageField, "categoryImageFeedback", "");
+        imagePreview.classList.add("d-none");
+        imagePreview.removeAttribute("src");
+        saveButton.disabled = adding;
+        showChooser(false);
+        if (!adding) {
+            validate();
+        }
+        editModal.show();
+    }
+
+    function saveCategory() {
+        if (!validate()) {
+            return;
+        }
+        var adding = document.getElementById("categoryEditForm_bean_id").value 
=== "";
+        var name = nameField.value.trim();
+        saveButton.disabled = true;
+        post(editForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryEditErrors"), 
errors);
+                saveButton.disabled = false;
+            } else {
+                reloadWith(format(adding ? text("msgAdded") : 
text("msgSaved"), name), name);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryEditErrors"), 
[text("msgError")]);
+            saveButton.disabled = false;
+        });
+    }
+
+    nameField.addEventListener("input", validate);
+    imageField.addEventListener("input", validate);
+    imagePreview.addEventListener("error", function () {
+        imagePreview.classList.add("d-none");
+    });
+    saveButton.addEventListener("click", saveCategory);
+    // Enter in a field saves; the form has no submit button and is never 
posted directly
+    editForm.addEventListener("submit", function (event) {
+        event.preventDefault();
+    });
+    editForm.addEventListener("keydown", function (event) {
+        if (event.key === "Enter" && event.target.matches("input")) {
+            event.preventDefault();
+            saveCategory();
+        }
+    });
+    document.getElementById("addCategoryButton").addEventListener("click", 
function () {
+        openEditor(null);
+    });
+    
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
 function () {
+        nameField.focus();
+    });
+
+    // --- choosing an image from the media library ---------------------------
+    // Only when uploads are on; otherwise the page has no chooser and these 
are null.
+
+    var chooser = document.getElementById("categoryImageChooser");
+    var chooserFrame = document.getElementById("categoryImageChooserFrame");
+    var editTitle = document.getElementById("category-edit-title");
+    var formTitle = "";
+
+    // the dialog shows either the form or the chooser, which needs a wider 
dialog
+    function showChooser(on) {
+        if (!chooser) {
+            return;
+        }
+        var showing = !chooser.classList.contains("d-none");
+        if (on === showing) {
+            return;
+        }
+        if (on) {
+            formTitle = editTitle.textContent;
+            editTitle.textContent = chooser.dataset.title;
+            chooserFrame.setAttribute("src", chooser.dataset.src);
+        } else {
+            editTitle.textContent = formTitle || editTitle.textContent;
+            chooserFrame.setAttribute("src", "about:blank");
+        }
+        chooser.classList.toggle("d-none", !on);
+        editForm.classList.toggle("d-none", on);
+        document.querySelector("#category-edit-modal 
.modal-dialog").classList.toggle("modal-lg", on);
+        
document.getElementById("categoryChooserBackButton").classList.toggle("d-none", 
!on);
+        
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+        saveButton.classList.toggle("d-none", on);
+    }
+
+    if (chooser) {
+        
document.getElementById("categoryImageChooseButton").addEventListener("click", 
function () {
+            showChooser(true);
+        });
+        
document.getElementById("categoryChooserBackButton").addEventListener("click", 
function () {
+            showChooser(false);
+            document.getElementById("categoryImageChooseButton").focus();
+        });
+        
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
 function () {
+            showChooser(false);
+        });
+
+        // called by the chooser in the frame (MediaFileImageChooser.jsp); it 
lists only images
+        window.onSelectMediaFile = function (name, url, isImage) {
+            if (isImage !== "true") {
+                return;
+            }
+            imageField.value = url;
+            showChooser(false);
+            validate();
+            imageField.focus();
+        };
+    }
+
+    // --- delete 
----------------------------------------------------------------
+
+    function openDelete(row) {
+        var id = row.dataset.categoryId;
+        var inUse = row.dataset.inUse === "true";
+        document.getElementById("category-delete-title").textContent = 
format(text("titleDelete"), row.dataset.categoryName);
+        document.getElementById("categoryRemove_removeId").value = id;
+        document.getElementById("categoryDeleteText").textContent = 
row.dataset.deleteText;
+        showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+        // the other categories, in display order, after the "choose" option
+        while (targetSelect.options.length > 1) {
+            targetSelect.remove(1);
+        }
+        list.querySelectorAll(".category-row").forEach(function (other) {
+            if (other.dataset.categoryId !== id) {
+                targetSelect.add(new Option(other.dataset.categoryName, 
other.dataset.categoryId));
+            }
+        });
+        targetSelect.value = "";
+
+        // a category nothing uses needs no target; a disabled select is not 
posted
+        
document.getElementById("categoryMoveTarget").classList.toggle("d-none", 
!inUse);
+        targetSelect.disabled = !inUse;
+        deleteButton.disabled = inUse;
+        deleteButton.dataset.categoryName = row.dataset.categoryName;
+        deleteModal.show();
+    }
+
+    targetSelect.addEventListener("change", function () {
+        deleteButton.disabled = targetSelect.value === "";
+    });
+
+    deleteButton.addEventListener("click", function () {
+        var name = deleteButton.dataset.categoryName;
+        deleteButton.disabled = true;
+        post(removeForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryDeleteErrors"), 
errors);
+                deleteButton.disabled = false;
+            } else {
+                reloadWith(format(text("msgRemoved"), name), null);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryDeleteErrors"), 
[text("msgError")]);
+            deleteButton.disabled = false;
+        });
+    });
+
+    // --- row actions 
-----------------------------------------------------------
+
+    list.addEventListener("click", function (event) {
+        var edit = event.target.closest(".category-edit-link");
+        var remove = event.target.closest(".category-delete-link");
+        if (edit) {
+            event.preventDefault();
+            openEditor(rowOf(edit));
+        } else if (remove) {
+            openDelete(rowOf(remove));
+        }
+    });
+
+    // --- reordering 
------------------------------------------------------------
+
+    var saveTimer = null;
+
+    function saveOrder() {
+        var ids = 
Array.prototype.map.call(list.querySelectorAll(".category-row"), function (row) 
{
+            return "&categoryIds=" + 
encodeURIComponent(row.dataset.categoryId);
+        }).join("");
+        post(document.getElementById("categoriesReorderForm"), 
ids).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showToast(errors.join(" "), true);
+            } else {
+                document.getElementById("categoryStatus").textContent = 
text("msgOrderSaved");
+                showToast(text("msgOrderSaved"));
+            }
+        }).fail(function () {
+            showToast(text("msgError"), true);
+        });
+    }
+
+    // keyboard moves can come quickly, one after another; save once they stop
+    function saveOrderSoon() {
+        clearTimeout(saveTimer);
+        saveTimer = setTimeout(saveOrder, 600);
+    }
+
+    if ($.fn.sortable && list.children.length > 1) {
+        $(list).sortable({

Review Comment:
   jQuery UI sortable only handles mouse events, and 1.14 has no touch support 
without a touch-punch shim. On a phone, dragging the handle does nothing, and 
`touch-action: none` also stops the handle from scrolling. Phones have no arrow 
keys either, so there's no way to reorder on mobile, which contradicts the PR 
description and CHANGES.md. Pointer Events, a touch-capable sortable, or 
up/down buttons would fix it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to