adds breadcrumb menu

Fri, 22 May 2020 16:21:31 +0200

author
Mike Becker <universe@uap-core.de>
date
Fri, 22 May 2020 16:21:31 +0200
changeset 71
dca186d3911f
parent 70
821c4950b619
child 72
0646c14e36fb

adds breadcrumb menu

src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/Constants.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/MenuEntry.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/site.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/menu-entry.jsp file | annotate | diff | comparison | revisions
src/main/webapp/error.css file | annotate | diff | comparison | revisions
--- a/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java	Fri May 22 16:21:31 2020 +0200
@@ -230,12 +230,24 @@
      * @param req          the servlet request object
      * @param fragmentName the name of the fragment
      * @see Constants#DYN_FRAGMENT_PATH_PREFIX
+     * @see Constants#REQ_ATTR_FRAGMENT
      */
     protected void setDynamicFragment(HttpServletRequest req, String fragmentName) {
         req.setAttribute(Constants.REQ_ATTR_FRAGMENT, Functions.dynFragmentPath(fragmentName));
     }
 
     /**
+     * Sets the breadcrumbs menu.
+     *
+     * @param req         the servlet request object
+     * @param breadcrumbs the menu entries for the breadcrumbs menu
+     * @see Constants#REQ_ATTR_BREADCRUMBS
+     */
+    protected void setBreadcrumbs(HttpServletRequest req, List<MenuEntry> breadcrumbs) {
+        req.setAttribute(Constants.REQ_ATTR_BREADCRUMBS, breadcrumbs);
+    }
+
+    /**
      * @param req      the servlet request object
      * @param location the location where to redirect
      * @see Constants#REQ_ATTR_REDIRECT_LOCATION
@@ -268,16 +280,16 @@
      * The specified type must have a single-argument constructor accepting a string to perform conversion.
      * The constructor of the specified type may throw an exception on conversion failures.
      *
-     * @param req the servlet request object
+     * @param req   the servlet request object
      * @param clazz the class object of the expected type
-     * @param name the name of the parameter
-     * @param <T> the expected type
+     * @param name  the name of the parameter
+     * @param <T>   the expected type
      * @return the parameter value or an empty optional, if no parameter with the specified name was found
      */
-    protected<T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
+    protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
         final String paramValue = req.getParameter(name);
         if (paramValue == null) return Optional.empty();
-        if (clazz.equals(String.class)) return Optional.of((T)paramValue);
+        if (clazz.equals(String.class)) return Optional.of((T) paramValue);
         try {
             final Constructor<T> ctor = clazz.getConstructor(String.class);
             return Optional.of(ctor.newInstance(paramValue));
@@ -290,16 +302,16 @@
     /**
      * Tries to look up an entity with a key obtained from a request parameter.
      *
-     * @param req the servlet request object
+     * @param req   the servlet request object
      * @param clazz the class representing the type of the request parameter
-     * @param name the name of the request parameter
-     * @param find the find function (typically a DAO function)
-     * @param <T> the type of the request parameter
-     * @param <R> the type of the looked up entity
+     * @param name  the name of the request parameter
+     * @param find  the find function (typically a DAO function)
+     * @param <T>   the type of the request parameter
+     * @param <R>   the type of the looked up entity
      * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
      * @throws SQLException if the find function throws an exception
      */
-    protected<T,R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, SQLFindFunction<? super T, ? extends R> find) throws SQLException {
+    protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, SQLFindFunction<? super T, ? extends R> find) throws SQLException {
         final var param = getParameter(req, clazz, name);
         if (param.isPresent()) {
             return Optional.ofNullable(find.apply(param.get()));
@@ -311,7 +323,13 @@
     private void forwardToFullView(HttpServletRequest req, HttpServletResponse resp)
             throws IOException, ServletException {
 
-        req.setAttribute(Constants.REQ_ATTR_MENU, getModuleManager().getMainMenu());
+        final var mainMenu = new ArrayList<MenuEntry>(getModuleManager().getMainMenu());
+        for (var entry : mainMenu) {
+            if (Functions.fullPath(req).startsWith("/" + entry.getPathName())) {
+                entry.setActive(true);
+            }
+        }
+        req.setAttribute(Constants.REQ_ATTR_MENU, mainMenu);
         req.getRequestDispatcher(SITE_JSP).forward(req, resp);
     }
 
--- a/src/main/java/de/uapcore/lightpit/Constants.java	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/Constants.java	Fri May 22 16:21:31 2020 +0200
@@ -68,6 +68,11 @@
     public static final String REQ_ATTR_MENU = fqn(AbstractLightPITServlet.class, "mainMenu");
 
     /**
+     * Key for the request attribute containing the breadcrumb menu.
+     */
+    public static final String REQ_ATTR_BREADCRUMBS = fqn(AbstractLightPITServlet.class, "breadcrumbs");
+
+    /**
      * Key for the request attribute containing the base href.
      */
     public static final String REQ_ATTR_BASE_HREF = fqn(AbstractLightPITServlet.class, "base_href");
--- a/src/main/java/de/uapcore/lightpit/MenuEntry.java	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/MenuEntry.java	Fri May 22 16:21:31 2020 +0200
@@ -45,6 +45,11 @@
     private final ResourceKey resourceKey;
 
     /**
+     * Custom menu text.
+     */
+    private final String text;
+
+    /**
      * Path name of the module, linked by this menu entry.
      */
     private final String pathName;
@@ -54,20 +59,33 @@
      */
     private final int sequence;
 
+    /**
+     * True if this menu entry is active.
+     */
+    private boolean active = false;
+
     public MenuEntry(ResourceKey resourceKey, String pathName, int sequence) {
+        this.text = null;
         this.resourceKey = resourceKey;
         this.pathName = pathName;
         this.sequence = sequence;
     }
 
-    public MenuEntry(ResourceKey resourceKey, String pathName) {
-        this(resourceKey, pathName, 0);
+    public MenuEntry(String text, String pathName, int sequence) {
+        this.text = text;
+        this.resourceKey = null;
+        this.pathName = pathName;
+        this.sequence = sequence;
     }
 
     public ResourceKey getResourceKey() {
         return resourceKey;
     }
 
+    public String getText() {
+        return text;
+    }
+
     public String getPathName() {
         return pathName;
     }
@@ -76,6 +94,14 @@
         return sequence;
     }
 
+    public boolean isActive() {
+        return this.active;
+    }
+
+    public void setActive(boolean active) {
+        this.active = true;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Fri May 22 16:21:31 2020 +0200
@@ -40,7 +40,10 @@
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.NoSuchElementException;
+import java.util.Optional;
 
 import static de.uapcore.lightpit.Functions.fqn;
 
@@ -63,14 +66,44 @@
         final var projectDao = dao.getProjectDao();
         final var session = req.getSession();
         final var projectSelection = getParameter(req, Integer.class, "pid");
+        final Project selectedProject;
         if (projectSelection.isPresent()) {
-            final var selectedId = projectSelection.get();
-            final var selectedProject = projectDao.find(selectedId);
-            session.setAttribute(SESSION_ATTR_SELECTED_PROJECT, selectedProject);
-            return selectedProject;
+            selectedProject = projectDao.find(projectSelection.get());
         } else {
-            return (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
+            final var sessionProject = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
+            selectedProject = sessionProject == null ? null : projectDao.find(sessionProject.getId());
         }
+        session.setAttribute(SESSION_ATTR_SELECTED_PROJECT, selectedProject);
+        return selectedProject;
+    }
+
+
+    /**
+     * Creates the breadcrumb menu.
+     *
+     * @param level           the current active level
+     * @param selectedProject the selected project, if any, or null
+     * @return a dynamic breadcrumb menu trying to display as many levels as possible
+     */
+    private List<MenuEntry> getBreadcrumbs(int level,
+                                           Project selectedProject) {
+        MenuEntry entry;
+
+        final var breadcrumbs = new ArrayList<MenuEntry>();
+        entry = new MenuEntry(new ResourceKey("localization.projects", "menuLabel"),
+                "projects/", 0);
+        breadcrumbs.add(entry);
+        if (level == 0) entry.setActive(true);
+
+        if (selectedProject == null)
+            return breadcrumbs;
+
+        entry = new MenuEntry(selectedProject.getName(),
+                "projects/view?pid=" + selectedProject.getId(), 1);
+        if (level == 1) entry.setActive(true);
+
+        breadcrumbs.add(entry);
+        return breadcrumbs;
     }
 
     @RequestMapping(method = HttpMethod.GET)
@@ -81,20 +114,33 @@
         setDynamicFragment(req, "projects");
         setStylesheet(req, "projects");
 
-        if (getSelectedProject(req, dao) == null) {
-            projectList.stream().findFirst().ifPresent(proj -> req.getSession().setAttribute(SESSION_ATTR_SELECTED_PROJECT, proj));
-        }
+        final var selectedProject = getSelectedProject(req, dao);
+        setBreadcrumbs(req, getBreadcrumbs(0, selectedProject));
 
         return ResponseType.HTML;
     }
 
+    private void configureEditForm(HttpServletRequest req, DataAccessObjects dao, Optional<Project> project) throws SQLException {
+        if (project.isPresent()) {
+            req.setAttribute("project", project.get());
+            setBreadcrumbs(req, getBreadcrumbs(1, project.get()));
+        } else {
+            req.setAttribute("project", new Project(-1));
+            setBreadcrumbs(req, getBreadcrumbs(0, null));
+        }
+
+        req.setAttribute("users", dao.getUserDao().list());
+        setDynamicFragment(req, "project-form");
+    }
+
     @RequestMapping(requestPath = "edit", method = HttpMethod.GET)
     public ResponseType edit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
-        req.setAttribute("project", findByParameter(req, Integer.class, "id",
-                dao.getProjectDao()::find).orElse(new Project(-1)));
-        req.setAttribute("users", dao.getUserDao().list());
 
-        setDynamicFragment(req, "project-form");
+        Optional<Project> project = findByParameter(req, Integer.class, "id", dao.getProjectDao()::find);
+        configureEditForm(req, dao, project);
+        if (project.isPresent()) {
+            req.getSession().setAttribute(SESSION_ATTR_SELECTED_PROJECT, project.get());
+        }
 
         return ResponseType.HTML;
     }
@@ -102,7 +148,7 @@
     @RequestMapping(requestPath = "commit", method = HttpMethod.POST)
     public ResponseType commit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
 
-        Project project = new Project(-1);
+        Project project = null;
         try {
             project = new Project(getParameter(req, Integer.class, "id").orElseThrow());
             project.setName(getParameter(req, String.class, "name").orElseThrow());
@@ -119,11 +165,9 @@
             LOG.debug("Successfully updated project {}", project.getName());
         } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
             // TODO: set request attribute with error text
-            req.setAttribute("project", project);
-            req.setAttribute("users", dao.getUserDao().list());
-            setDynamicFragment(req, "project-form");
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
+            configureEditForm(req, dao, Optional.ofNullable(project));
         }
 
         return ResponseType.HTML;
@@ -140,11 +184,21 @@
         req.setAttribute("versions", dao.getVersionDao().list(selectedProject));
         req.setAttribute("issues", dao.getIssueDao().list(selectedProject));
 
+        // TODO: add more levels depending on last visited location
+        setBreadcrumbs(req, getBreadcrumbs(1, selectedProject));
+
         setDynamicFragment(req, "project-details");
 
         return ResponseType.HTML;
     }
 
+    private void configureEditVersionForm(HttpServletRequest req, Optional<Version> version, Project selectedProject) {
+        req.setAttribute("version", version.orElse(new Version(-1, selectedProject)));
+        req.setAttribute("versionStatusEnum", VersionStatus.values());
+
+        setDynamicFragment(req, "version-form");
+    }
+
     @RequestMapping(requestPath = "versions/edit", method = HttpMethod.GET)
     public ResponseType editVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException {
         final var selectedProject = getSelectedProject(req, dao);
@@ -153,11 +207,9 @@
             return ResponseType.NONE;
         }
 
-        req.setAttribute("version", findByParameter(req, Integer.class, "id",
-                dao.getVersionDao()::find).orElse(new Version(-1, selectedProject)));
-        req.setAttribute("versionStatusEnum", VersionStatus.values());
-
-        setDynamicFragment(req, "version-form");
+        configureEditVersionForm(req,
+                findByParameter(req, Integer.class, "id", dao.getVersionDao()::find),
+                selectedProject);
 
         return ResponseType.HTML;
     }
@@ -170,7 +222,7 @@
             return ResponseType.NONE;
         }
 
-        Version version = new Version(-1, selectedProject);
+        Version version = null;
         try {
             version = new Version(getParameter(req, Integer.class, "id").orElseThrow(), selectedProject);
             version.setName(getParameter(req, String.class, "name").orElseThrow());
@@ -183,16 +235,24 @@
             LOG.debug("Successfully updated version {} for project {}", version.getName(), selectedProject.getName());
         } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
             // TODO: set request attribute with error text
-            req.setAttribute("version", version);
-            req.setAttribute("versionStatusEnum", VersionStatus.values());
-            setDynamicFragment(req, "version-form");
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
+            configureEditVersionForm(req, Optional.ofNullable(version), selectedProject);
         }
 
         return ResponseType.HTML;
     }
 
+    private void configureEditIssueForm(HttpServletRequest req, DataAccessObjects dao, Optional<Issue> issue, Project selectedProject) throws SQLException {
+
+        req.setAttribute("issue", issue.orElse(new Issue(-1, selectedProject)));
+        req.setAttribute("issueStatusEnum", IssueStatus.values());
+        req.setAttribute("issueCategoryEnum", IssueCategory.values());
+        req.setAttribute("versions", dao.getVersionDao().list(selectedProject));
+
+        setDynamicFragment(req, "issue-form");
+    }
+
     @RequestMapping(requestPath = "issues/edit", method = HttpMethod.GET)
     public ResponseType editIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException {
         final var selectedProject = getSelectedProject(req, dao);
@@ -201,12 +261,9 @@
             return ResponseType.NONE;
         }
 
-        req.setAttribute("issue", findByParameter(req, Integer.class, "id",
-                dao.getIssueDao()::find).orElse(new Issue(-1, selectedProject)));
-        req.setAttribute("issueStatusEnum", IssueStatus.values());
-        req.setAttribute("issueCategoryEnum", IssueCategory.values());
-
-        setDynamicFragment(req, "issue-form");
+        configureEditIssueForm(req, dao,
+                findByParameter(req, Integer.class, "id", dao.getIssueDao()::find),
+                selectedProject);
 
         return ResponseType.HTML;
     }
@@ -219,7 +276,7 @@
             return ResponseType.NONE;
         }
 
-        Issue issue = new Issue(-1, selectedProject);
+        Issue issue = null;
         try {
             issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow(), selectedProject);
 
@@ -232,12 +289,9 @@
             LOG.debug("Successfully updated issue {} for project {}", issue.getId(), selectedProject.getName());
         } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
             // TODO: set request attribute with error text
-            req.setAttribute("issue", issue);
-            req.setAttribute("issueStatusEnum", IssueStatus.values());
-            req.setAttribute("issueCategoryEnum", IssueCategory.values());
-            setDynamicFragment(req, "issue-form");
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
+            configureEditIssueForm(req, dao, Optional.ofNullable(issue), selectedProject);
         }
 
         return ResponseType.HTML;
--- a/src/main/webapp/WEB-INF/jsp/site.jsp	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/site.jsp	Fri May 22 16:21:31 2020 +0200
@@ -32,7 +32,7 @@
 <%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
 
 <%-- Make the base href easily available at request scope --%>
-<c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}" />
+<c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/>
 
 <%-- Define an alias for the request path --%>
 <c:set scope="page" var="requestPath" value="${requestScope[Constants.REQ_ATTR_PATH]}"/>
@@ -40,6 +40,9 @@
 <%-- Define an alias for the main menu --%>
 <c:set scope="page" var="mainMenu" value="${requestScope[Constants.REQ_ATTR_MENU]}"/>
 
+<%-- Define an alias for the main menu --%>
+<c:set scope="page" var="breadcrumbs" value="${requestScope[Constants.REQ_ATTR_BREADCRUMBS]}"/>
+
 <%-- Define an alias for the fragment name --%>
 <c:set scope="page" var="fragment" value="${requestScope[Constants.REQ_ATTR_FRAGMENT]}"/>
 
@@ -54,7 +57,7 @@
 
 <%-- Apply the session locale (should always be present, but check nevertheless) --%>
 <c:if test="${not empty sessionScope[Constants.SESSION_ATTR_LANGUAGE]}">
-<fmt:setLocale scope="request" value="${sessionScope[Constants.SESSION_ATTR_LANGUAGE]}"/>
+    <fmt:setLocale scope="request" value="${sessionScope[Constants.SESSION_ATTR_LANGUAGE]}"/>
 </c:if>
 
 <%-- Selected project, if any --%>
@@ -62,48 +65,41 @@
 
 <!DOCTYPE html>
 <html>
-    <head>
-        <base href="${baseHref}">
-        <title>LightPIT -
-            <fmt:bundle basename="${moduleInfo.bundleBaseName}">
-                <fmt:message key="${moduleInfo.titleKey}" />
-            </fmt:bundle>
-        </title>
-        <meta charset="UTF-8">
-        <c:if test="${not empty redirectLocation}">
+<head>
+    <base href="${baseHref}">
+    <title>LightPIT -
+        <fmt:bundle basename="${moduleInfo.bundleBaseName}">
+            <fmt:message key="${moduleInfo.titleKey}"/>
+        </fmt:bundle>
+    </title>
+    <meta charset="UTF-8">
+    <c:if test="${not empty redirectLocation}">
         <meta http-equiv="refresh" content="0; URL=${redirectLocation}">
-        </c:if>
-        <link rel="stylesheet" href="lightpit.css" type="text/css">
-        <c:if test="${not empty extraCss}">
+    </c:if>
+    <link rel="stylesheet" href="lightpit.css" type="text/css">
+    <c:if test="${not empty extraCss}">
         <link rel="stylesheet" href="${extraCss}" type="text/css">
-        </c:if>
-    </head>
-    <body>
-        <div id="mainMenu">
-            <c:forEach var="menu" items="${mainMenu}">
-                <div class="menuEntry"
-                        <c:set var="menuPath" value="/${menu.pathName}"/>
-                        <c:if test="${fn:startsWith(requestPath, menuPath)}">
-                            data-active
-                        </c:if>
-                >
-                    <a href="${menu.pathName}">
-                        <fmt:bundle basename="${menu.resourceKey.bundle}">
-                            <fmt:message key="${menu.resourceKey.key}"/>
-                        </fmt:bundle>
-                    </a>
-                </div>
-            </c:forEach>
-        </div>
-        <div id="breadcrumbs">
-            <%-- TODO: find a strategy to define the breadcrumbs  --%>
-        </div>
-        <div id="content-area">
-            <c:if test="${not empty fragment}">
-                <fmt:setBundle scope="request" basename="${moduleInfo.bundleBaseName}"/>
-                <fmt:setBundle scope="request" var="lightpit_bundle" basename="localization.lightpit"/>
-                <c:import url="${fragment}" />
-            </c:if>
-        </div>
-    </body>
+    </c:if>
+</head>
+<body>
+<div id="mainMenu">
+    <c:forEach var="menu" items="${mainMenu}">
+        <%@include file="../jspf/menu-entry.jsp" %>
+    </c:forEach>
+</div>
+<c:if test="${not empty breadcrumbs}">
+    <div id="breadcrumbs">
+        <c:forEach var="menu" items="${breadcrumbs}">
+            <%@include file="../jspf/menu-entry.jsp" %>
+        </c:forEach>
+    </div>
+</c:if>
+<div id="content-area">
+    <c:if test="${not empty fragment}">
+        <fmt:setBundle scope="request" basename="${moduleInfo.bundleBaseName}"/>
+        <fmt:setBundle scope="request" var="lightpit_bundle" basename="localization.lightpit"/>
+        <c:import url="${fragment}"/>
+    </c:if>
+</div>
+</body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jspf/menu-entry.jsp	Fri May 22 16:21:31 2020 +0200
@@ -0,0 +1,13 @@
+<div class="menuEntry"
+     <c:if test="${menu.active}">data-active</c:if> >
+    <a href="${menu.pathName}">
+        <c:if test="${empty menu.resourceKey}">
+            <c:out value="${menu.text}"/>
+        </c:if>
+        <c:if test="${not empty menu.resourceKey}">
+            <fmt:bundle basename="${menu.resourceKey.bundle}">
+                <fmt:message key="${menu.resourceKey.key}"/>
+            </fmt:bundle>
+        </c:if>
+    </a>
+</div>
\ No newline at end of file
--- a/src/main/webapp/error.css	Tue May 19 19:34:57 2020 +0200
+++ b/src/main/webapp/error.css	Fri May 22 16:21:31 2020 +0200
@@ -33,15 +33,15 @@
 
 #error-page table {
     width: 100%;
-    
+
     border-top-style: solid;
     border-top-width: 1pt;
     border-top-color: #606060;
-    
+
     border-bottom-style: solid;
     border-bottom-width: 1pt;
     border-bottom-color: #505050;
-    
+
     border-collapse: separate;
     border-spacing: .5em;
 }

mercurial