completes feature: project components

Sat, 17 Oct 2020 19:56:50 +0200

author
Mike Becker <universe@uap-core.de>
date
Sat, 17 Oct 2020 19:56:50 +0200
changeset 134
f47e82cd6077
parent 133
ef075cd7ce55
child 135
bafc315294fd

completes feature: project components

src/main/java/de/uapcore/lightpit/dao/IssueDao.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/entities/Component.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/entities/Issue.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/entities/Project.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/entities/Version.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentInfo.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentsView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetails.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/VersionEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/VersionsView.java file | annotate | diff | comparison | revisions
src/main/resources/localization/projects.properties file | annotate | diff | comparison | revisions
src/main/resources/localization/projects_de.properties file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/component-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/components.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issues.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-details.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-navmenu.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/projects.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/version-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/versions.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/project-header.jspf file | annotate | diff | comparison | revisions
--- a/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Sat Oct 17 19:56:50 2020 +0200
@@ -28,10 +28,7 @@
  */
 package de.uapcore.lightpit.dao;
 
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.IssueComment;
-import de.uapcore.lightpit.entities.Project;
-import de.uapcore.lightpit.entities.Version;
+import de.uapcore.lightpit.entities.*;
 
 import java.sql.SQLException;
 import java.util.List;
@@ -39,14 +36,39 @@
 public interface IssueDao extends ChildEntityDao<Issue, Project> {
 
     /**
-     * Lists all issues that are somehow related to the specified version.
-     * If version is null, search for issues that are not related to any version.
+     * Lists all issues that are related to the specified component and version.
+     * If component or version is null, search for issues that are not assigned to any
+     * component or version, respectively.
      *
+     * @param project the project
+     * @param component the component or null
      * @param version the version or null
      * @return a list of issues
      * @throws SQLException on any kind of SQL error
      */
-    List<Issue> list(Version version) throws SQLException;
+    List<Issue> list(Project project, Component component, Version version) throws SQLException;
+
+    /**
+     * Lists all issues that are related to the specified version.
+     * If the version is null, lists issues that are not assigned to any version.
+     *
+     * @param project the project (mandatory)
+     * @param version the version or null
+     * @return a list of issues
+     * @throws SQLException on any kind of SQL error
+     */
+    List<Issue> list(Project project, Version version) throws SQLException;
+
+    /**
+     * Lists all issues that are related to the specified component.
+     * If the component is null, lists issues that are not assigned to a component.
+     *
+     * @param project the project (mandatory)
+     * @param component the component or null
+     * @return a list of issues
+     * @throws SQLException on any kind of SQL error
+     */
+    List<Issue> list(Project project, Component component) throws SQLException;
 
     /**
      * Lists all comments for a specific issue in chronological order.
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Sat Oct 17 19:56:50 2020 +0200
@@ -31,13 +31,11 @@
 import de.uapcore.lightpit.dao.IssueDao;
 import de.uapcore.lightpit.entities.*;
 
-import java.sql.Connection;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.SQLException;
+import java.sql.*;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
 import static de.uapcore.lightpit.dao.Functions.*;
 
@@ -51,43 +49,50 @@
 
     public PGIssueDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
-                "select issueid, project, p.name as projectname, status, category, subject, i.description, " +
+                "select issueid, i.project, p.name as projectname, component, c.name as componentname, " +
+                        "status, category, subject, i.description, " +
                         "userid, username, givenname, lastname, mail, " +
                         "created, updated, eta " +
                         "from lpit_issue i " +
-                        "join lpit_project p on project = projectid " +
+                        "join lpit_project p on i.project = projectid " +
+                        "left join lpit_component c on component = c.id " +
                         "left join lpit_user on userid = assignee " +
-                        "where project = ? ");
+                        "where i.project = ? and coalesce(component, -1) = coalesce(?, component, -1)");
 
         listForVersion = connection.prepareStatement(
                 "with issue_version as ( "+
                         "select issueid, versionid from lpit_issue_affected_version union "+
                         "select issueid, versionid from lpit_issue_resolved_version) "+
-                        "select issueid, project, p.name as projectname, status, category, subject, i.description, " +
+                        "select issueid, i.project, p.name as projectname, component, c.name as componentname, " +
+                        "status, category, subject, i.description, " +
                         "userid, username, givenname, lastname, mail, " +
                         "created, updated, eta " +
                         "from lpit_issue i " +
-                        "join lpit_project p on project = projectid " +
+                        "join lpit_project p on i.project = projectid " +
+                        "left join lpit_component c on component = c.id " +
                         "left join issue_version using (issueid) "+
                         "left join lpit_user on userid = assignee " +
-                        "where coalesce(versionid,-1) = ? "
+                        "where coalesce(versionid,-1) = ? and coalesce(component, -1) = coalesce(?, component, -1)"
         );
 
         find = connection.prepareStatement(
-                "select issueid, project, p.name as projectname, status, category, subject, i.description, " +
+                "select issueid, i.project, p.name as projectname, component, c.name as componentname, " +
+                        "status, category, subject, i.description, " +
                         "userid, username, givenname, lastname, mail, " +
                         "created, updated, eta " +
                         "from lpit_issue i " +
-                        "left join lpit_project p on project = projectid " +
+                        "join lpit_project p on i.project = projectid " +
+                        "left join lpit_component c on component = c.id " +
                         "left join lpit_user on userid = assignee " +
                         "where issueid = ? ");
 
         insert = connection.prepareStatement(
-                "insert into lpit_issue (project, status, category, subject, description, assignee, eta) " +
+                "insert into lpit_issue (project, component, status, category, subject, description, assignee, eta) " +
                         "values (?, ?::issue_status, ?::issue_category, ?, ?, ?, ?) returning issueid"
         );
         update = connection.prepareStatement(
-                "update lpit_issue set updated = now(), status = ?::issue_status, category = ?::issue_category, " +
+                "update lpit_issue set " +
+                        "updated = now(), component = ?, status = ?::issue_status, category = ?::issue_category, " +
                         "subject = ?, description = ?, assignee = ?, eta = ? where issueid = ?"
         );
 
@@ -123,8 +128,15 @@
     private Issue mapColumns(ResultSet result) throws SQLException {
         final var project = new Project(result.getInt("project"));
         project.setName(result.getString("projectname"));
+        var component = new Component(result.getInt("component"));
+        if (result.wasNull()) {
+            component = null;
+        } else {
+            component.setName(result.getString("componentname"));
+        }
         final var issue = new Issue(result.getInt("issueid"));
         issue.setProject(project);
+        issue.setComponent(component);
         issue.setStatus(IssueStatus.valueOf(result.getString("status")));
         issue.setCategory(IssueCategory.valueOf(result.getString("category")));
         issue.setSubject(result.getString("subject"));
@@ -161,17 +173,24 @@
         }
     }
 
+    private int setData(PreparedStatement stmt, int column, Issue instance) throws SQLException {
+        setForeignKeyOrNull(stmt, ++column, instance.getComponent(), Component::getId);
+        stmt.setString(++column, instance.getStatus().name());
+        stmt.setString(++column, instance.getCategory().name());
+        stmt.setString(++column, instance.getSubject());
+        setStringOrNull(stmt, ++column, instance.getDescription());
+        setForeignKeyOrNull(stmt, ++column, instance.getAssignee(), User::getId);
+        setDateOrNull(stmt, ++column, instance.getEta());
+        return column;
+    }
+
     @Override
     public void save(Issue instance, Project project) throws SQLException {
         Objects.requireNonNull(instance.getSubject());
         instance.setProject(project);
-        insert.setInt(1, instance.getProject().getId());
-        insert.setString(2, instance.getStatus().name());
-        insert.setString(3, instance.getCategory().name());
-        insert.setString(4, instance.getSubject());
-        setStringOrNull(insert, 5, instance.getDescription());
-        setForeignKeyOrNull(insert, 6, instance.getAssignee(), User::getId);
-        setDateOrNull(insert, 7, instance.getEta());
+        int column = 0;
+        insert.setInt(++column, instance.getProject().getId());
+        setData(insert, column, instance);
         // insert and retrieve the ID
         final var rs = insert.executeQuery();
         rs.next();
@@ -183,13 +202,8 @@
     public boolean update(Issue instance) throws SQLException {
         if (instance.getId() < 0) return false;
         Objects.requireNonNull(instance.getSubject());
-        update.setString(1, instance.getStatus().name());
-        update.setString(2, instance.getCategory().name());
-        update.setString(3, instance.getSubject());
-        setStringOrNull(update, 4, instance.getDescription());
-        setForeignKeyOrNull(update, 5, instance.getAssignee(), User::getId);
-        setDateOrNull(update, 6, instance.getEta());
-        update.setInt(7, instance.getId());
+        int column = setData(update, 0, instance);
+        update.setInt(++column, instance.getId());
         boolean success = update.executeUpdate() > 0;
         if (success) {
             updateVersionLists(instance);
@@ -199,8 +213,7 @@
         }
     }
 
-    private List<Issue> list(PreparedStatement query, int arg) throws SQLException {
-        query.setInt(1, arg);
+    private List<Issue> executeQuery(PreparedStatement query) throws SQLException {
         List<Issue> issues = new ArrayList<>();
         try (var result = query.executeQuery()) {
             while (result.next()) {
@@ -212,12 +225,30 @@
 
     @Override
     public List<Issue> list(Project project) throws SQLException {
-        return list(list, project.getId());
+        list.setInt(1, project.getId());
+        list.setNull(2, Types.INTEGER);
+        return executeQuery(list);
     }
 
     @Override
-    public List<Issue> list(Version version) throws SQLException {
-        return list(listForVersion, version == null ? -1 : version.getId());
+    public List<Issue> list(Project project, Component component, Version version) throws SQLException {
+        listForVersion.setInt(1, Optional.ofNullable(version).map(Version::getId).orElse(-1));
+        listForVersion.setInt(2, Optional.ofNullable(component).map(Component::getId).orElse(-1));
+        return executeQuery(listForVersion);
+    }
+
+    @Override
+    public List<Issue> list(Project project, Version version) throws SQLException {
+        listForVersion.setInt(1, Optional.ofNullable(version).map(Version::getId).orElse(-1));
+        listForVersion.setNull(2, Types.INTEGER);
+        return executeQuery(listForVersion);
+    }
+
+    @Override
+    public List<Issue> list(Project project, Component component) throws SQLException {
+        list.setInt(1, project.getId());
+        list.setInt(2, Optional.ofNullable(component).map(Component::getId).orElse(-1));
+        return executeQuery(list);
     }
 
     @Override
--- a/src/main/java/de/uapcore/lightpit/entities/Component.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Component.java	Sat Oct 17 19:56:50 2020 +0200
@@ -38,7 +38,9 @@
 
     private String name;
 
-    private WebColor color;
+    private String node;
+
+    private WebColor color = new WebColor("000000");
 
     private int ordinal = 0;
 
@@ -66,6 +68,14 @@
         this.name = name;
     }
 
+    public String getNode() {
+        return node == null ? String.valueOf(id) : node;
+    }
+
+    public void setNode(String node) {
+        this.node = node;
+    }
+
     public WebColor getColor() {
         return color;
     }
--- a/src/main/java/de/uapcore/lightpit/entities/Issue.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Issue.java	Sat Oct 17 19:56:50 2020 +0200
@@ -39,6 +39,7 @@
 
     private int id;
     private Project project;
+    private Component component;
 
     private IssueStatus status;
     private IssueCategory category;
@@ -78,6 +79,14 @@
         return project;
     }
 
+    public Component getComponent() {
+        return component;
+    }
+
+    public void setComponent(Component component) {
+        this.component = component;
+    }
+
     public IssueStatus getStatus() {
         return status;
     }
--- a/src/main/java/de/uapcore/lightpit/entities/Project.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Project.java	Sat Oct 17 19:56:50 2020 +0200
@@ -34,6 +34,7 @@
 
     private final int id;
     private String name;
+    private String node;
     private String description;
     private String repoUrl;
     private User owner;
@@ -54,6 +55,14 @@
         this.name = name;
     }
 
+    public String getNode() {
+        return node == null ? String.valueOf(id) : node;
+    }
+
+    public void setNode(String node) {
+        this.node = node;
+    }
+
     public String getDescription() {
         return description;
     }
--- a/src/main/java/de/uapcore/lightpit/entities/Version.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Version.java	Sat Oct 17 19:56:50 2020 +0200
@@ -34,6 +34,7 @@
 
     private final int id;
     private String name;
+    private String node;
     /**
      * If we do not want versions to be ordered lexicographically we may specify an order.
      */
@@ -56,6 +57,14 @@
         this.name = name;
     }
 
+    public String getNode() {
+        return node == null ? String.valueOf(id) : node;
+    }
+
+    public void setNode(String node) {
+        this.node = node;
+    }
+
     public int getOrdinal() {
         return ordinal;
     }
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Sat Oct 17 19:56:50 2020 +0200
@@ -32,6 +32,7 @@
 import de.uapcore.lightpit.*;
 import de.uapcore.lightpit.dao.DataAccessObjects;
 import de.uapcore.lightpit.entities.*;
+import de.uapcore.lightpit.types.WebColor;
 import de.uapcore.lightpit.viewmodel.*;
 import de.uapcore.lightpit.viewmodel.util.IssueSorter;
 import org.slf4j.Logger;
@@ -43,6 +44,7 @@
 import java.io.IOException;
 import java.sql.Date;
 import java.sql.SQLException;
+import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -84,19 +86,30 @@
         }
 
         // Select Version
-        final int vid = Functions.parseIntOrZero(pathParameters.get("version"));
-        if (vid > 0) {
-            viewModel.setVersionFilter(versionDao.find(vid));
+        final var pathParamVersion = pathParameters.get("version");
+        if ("no-version".equals(pathParamVersion)) {
+            viewModel.setVersionFilter(ProjectView.NO_VERSION);
+        } else if ("all-versions".equals(pathParamVersion)) {
+            viewModel.setVersionFilter(ProjectView.ALL_VERSIONS);
+        } else {
+            final int vid = Functions.parseIntOrZero(pathParamVersion);
+            if (vid > 0) {
+                viewModel.setVersionFilter(versionDao.find(vid));
+            }
         }
-        // TODO: don't treat unknown == unassigned - send 404 for unknown and introduce special word for unassigned
 
         // Select Component
-        final int cid = Functions.parseIntOrZero(pathParameters.get("component"));
-        if (cid > 0) {
-            viewModel.setComponentFilter(componentDao.find(cid));
+        final var pathParamComponent = pathParameters.get("component");
+        if ("no-component".equals(pathParamComponent)) {
+            viewModel.setComponentFilter(ProjectView.NO_COMPONENT);
+        } else if ("all-components".equals(pathParamComponent)) {
+            viewModel.setComponentFilter(ProjectView.ALL_COMPONENTS);
+        } else {
+            final int cid = Functions.parseIntOrZero(pathParamComponent);
+            if (cid > 0) {
+                viewModel.setComponentFilter(componentDao.find(cid));
+            }
         }
-
-        // TODO: distinguish all/unassigned for components
     }
 
     private ResponseType forwardView(HttpServletRequest req, ProjectView viewModel, String name) {
@@ -133,7 +146,7 @@
         final var viewModel = new ProjectEditView();
         populate(viewModel, pathParams, dao);
 
-        if (viewModel.getProjectInfo() == null) {
+        if (!viewModel.isProjectInfoPresent()) {
             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
             return ResponseType.NONE;
         }
@@ -176,28 +189,60 @@
         }
     }
 
-    @RequestMapping(requestPath = "$project/versions/$version", method = HttpMethod.GET)
-    public ResponseType view(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParams, DataAccessObjects dao) throws SQLException, IOException {
+    @RequestMapping(requestPath = "$project/$component/$version/issues/", method = HttpMethod.GET)
+    public ResponseType issues(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParams, DataAccessObjects dao) throws SQLException, IOException {
         final var viewModel = new ProjectDetailsView();
         populate(viewModel, pathParams, dao);
-        final var version = viewModel.getVersionFilter();
 
-        if (viewModel.getProjectInfo() == null || version == null) {
+        if (!viewModel.isEveryFilterValid()) {
             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
             return ResponseType.NONE;
         }
 
+        final var project = viewModel.getProjectInfo().getProject();
+        final var version = viewModel.getVersionFilter();
+        final var component = viewModel.getComponentFilter();
+
         final var issueDao = dao.getIssueDao();
 
-        final var detailView = viewModel.getProjectDetails();
-        final var issues = issueDao.list(version);
+        final List<Issue> issues;
+        if (version.equals(ProjectView.NO_VERSION)) {
+            if (component.equals(ProjectView.ALL_COMPONENTS)) {
+                issues = issueDao.list(project, (Version) null);
+            } else if (component.equals(ProjectView.NO_COMPONENT)) {
+                issues = issueDao.list(project, null, null);
+            } else {
+                issues = issueDao.list(project, component, null);
+            }
+        } else if (version.equals(ProjectView.ALL_VERSIONS)) {
+            if (component.equals(ProjectView.ALL_COMPONENTS)) {
+                issues = issueDao.list(project);
+            } else if (component.equals(ProjectView.NO_COMPONENT)) {
+                issues = issueDao.list(project, (Component)null);
+            } else {
+                issues = issueDao.list(project, component);
+            }
+        } else {
+            if (component.equals(ProjectView.ALL_COMPONENTS)) {
+                issues = issueDao.list(project, version);
+            } else if (component.equals(ProjectView.NO_COMPONENT)) {
+                issues = issueDao.list(project, null, version);
+            } else {
+                issues = issueDao.list(project, component, version);
+            }
+        }
+
         for (var issue : issues) issueDao.joinVersionInformation(issue);
         issues.sort(new IssueSorter(
                 new IssueSorter.Criteria(IssueSorter.Field.PHASE, true),
                 new IssueSorter.Criteria(IssueSorter.Field.ETA, true),
                 new IssueSorter.Criteria(IssueSorter.Field.UPDATED, false)
         ));
-        detailView.updateDetails(issues, version);
+
+
+        viewModel.getProjectDetails().updateDetails(issues);
+        if (version.getId() > 0)
+            viewModel.getProjectDetails().updateVersionInfo(version);
 
         return forwardView(req, viewModel, "project-details");
     }
@@ -262,7 +307,6 @@
             version.setStatus(VersionStatus.valueOf(getParameter(req, String.class, "status").orElseThrow()));
             dao.getVersionDao().saveOrUpdate(version, project);
 
-            // TODO: improve building the redirect location
             setRedirectLocation(req, "./projects/" + project.getId() + "/versions/");
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
         } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
@@ -274,11 +318,90 @@
         return ResponseType.HTML;
     }
 
+    @RequestMapping(requestPath = "$project/components/", method = HttpMethod.GET)
+    public ResponseType components(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObjects dao) throws IOException, SQLException {
+        final var viewModel = new ComponentsView();
+        populate(viewModel, pathParameters, dao);
+
+        final var projectInfo = viewModel.getProjectInfo();
+        if (projectInfo == null) {
+            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            return ResponseType.NONE;
+        }
+
+        final var issueDao = dao.getIssueDao();
+        final var issues = issueDao.list(projectInfo.getProject());
+        viewModel.update(projectInfo.getComponents(), issues);
+
+        return forwardView(req, viewModel, "components");
+    }
+
+    @RequestMapping(requestPath = "$project/components/$component/edit", method = HttpMethod.GET)
+    public ResponseType editComponent(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObjects dao) throws IOException, SQLException {
+        final var viewModel = new ComponentEditView();
+        populate(viewModel, pathParameters, dao);
+
+        if (viewModel.getProjectInfo() == null || viewModel.getComponentFilter() == null) {
+            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            return ResponseType.NONE;
+        }
+
+        viewModel.setComponent(viewModel.getComponentFilter());
+        viewModel.setUsers(dao.getUserDao().list());
+
+        return forwardView(req, viewModel, "component-form");
+    }
+
+    @RequestMapping(requestPath = "$project/create-component", method = HttpMethod.GET)
+    public ResponseType createComponent(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObjects dao) throws IOException, SQLException {
+        final var viewModel = new ComponentEditView();
+        populate(viewModel, pathParameters, dao);
+
+        if (viewModel.getProjectInfo() == null) {
+            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            return ResponseType.NONE;
+        }
+
+        viewModel.setComponent(new Component(-1));
+        viewModel.setUsers(dao.getUserDao().list());
+
+        return forwardView(req, viewModel, "component-form");
+    }
+
+    @RequestMapping(requestPath = "commit-component", method = HttpMethod.POST)
+    public ResponseType commitComponent(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException {
+
+        try {
+            final var project = new Project(getParameter(req, Integer.class, "pid").orElseThrow());
+            final var component = new Component(getParameter(req, Integer.class, "id").orElseThrow());
+            component.setName(getParameter(req, String.class, "name").orElseThrow());
+            component.setColor(getParameter(req, WebColor.class, "color").orElseThrow());
+            getParameter(req, Integer.class, "ordinal").ifPresent(component::setOrdinal);
+            getParameter(req, Integer.class, "lead").map(
+                    userid -> userid >= 0 ? new User(userid) : null
+            ).ifPresent(component::setLead);
+            getParameter(req, String.class, "description").ifPresent(component::setDescription);
+
+            dao.getComponentDao().saveOrUpdate(component, project);
+
+            setRedirectLocation(req, "./projects/" + project.getId() + "/components/");
+            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
+            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
+            // TODO: implement - fix issue #21
+            return ResponseType.NONE;
+        }
+
+        return ResponseType.HTML;
+    }
+
     private void configureIssueEditor(IssueEditView viewModel, Issue issue, DataAccessObjects dao) throws SQLException {
-        issue.setProject(viewModel.getProjectInfo().getProject());
+        final var project = viewModel.getProjectInfo().getProject();
+        issue.setProject(project);
         viewModel.setIssue(issue);
         viewModel.configureVersionSelectors(viewModel.getProjectInfo().getVersions());
         viewModel.setUsers(dao.getUserDao().list());
+        viewModel.setComponents(dao.getComponentDao().list(project));
         if (issue.getId() >= 0) {
             viewModel.setComments(dao.getIssueDao().listComments(issue));
         }
@@ -337,6 +460,9 @@
             getParameter(req, Integer.class, "assignee").map(
                     userid -> userid >= 0 ? new User(userid) : null
             ).ifPresent(issue::setAssignee);
+            getParameter(req, Integer.class, "component").map(
+                    cid -> cid >= 0 ? new Component(cid) : null
+            ).ifPresent(issue::setComponent);
             getParameter(req, String.class, "description").ifPresent(issue::setDescription);
             getParameter(req, Date.class, "eta").ifPresent(issue::setEta);
 
@@ -354,7 +480,7 @@
             dao.getIssueDao().saveOrUpdate(issue, issue.getProject());
 
             // TODO: fix issue #14
-            setRedirectLocation(req, "./projects/" + issue.getProject().getId() + "/versions/");
+            setRedirectLocation(req, "./projects/" + issue.getProject().getId() + "/all-components/all-versions/issues/");
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
 
             return ResponseType.HTML;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/ComponentEditView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -0,0 +1,40 @@
+package de.uapcore.lightpit.viewmodel;
+
+import de.uapcore.lightpit.entities.Component;
+import de.uapcore.lightpit.entities.User;
+
+import java.util.List;
+
+public class ComponentEditView extends ProjectView {
+    private Component component;
+    private List<User> users;
+    private String errorText;
+
+    public ComponentEditView() {
+        setSelectedPage(SELECTED_PAGE_COMPONENTS);
+    }
+
+    public void setComponent(Component component) {
+        this.component = component;
+    }
+
+    public Component getComponent() {
+        return component;
+    }
+
+    public List<User> getUsers() {
+        return users;
+    }
+
+    public void setUsers(List<User> users) {
+        this.users = users;
+    }
+
+    public String getErrorText() {
+        return errorText;
+    }
+
+    public void setErrorText(String errorText) {
+        this.errorText = errorText;
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/ComponentInfo.java	Sat Oct 17 19:56:50 2020 +0200
@@ -0,0 +1,42 @@
+package de.uapcore.lightpit.viewmodel;
+
+import de.uapcore.lightpit.entities.Component;
+import de.uapcore.lightpit.entities.Issue;
+import de.uapcore.lightpit.entities.IssueSummary;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ComponentInfo {
+
+    private final Component component;
+
+    private final IssueSummary issueSummary = new IssueSummary();
+
+    private final List<Issue> issues = new ArrayList<>();
+
+    public ComponentInfo(Component component) {
+        this.component = component;
+    }
+
+    public Component getComponent() {
+        return component;
+    }
+
+    public IssueSummary getIssueSummary() {
+        return issueSummary;
+    }
+
+    public List<Issue> getIssues() {
+        return issues;
+    }
+
+    public void collectIssues(List<Issue> issues) {
+        for (Issue issue : issues) {
+            if (component.equals(issue.getComponent())) {
+                this.issues.add(issue);
+                this.issueSummary.add(issue);
+            }
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/ComponentsView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -0,0 +1,29 @@
+package de.uapcore.lightpit.viewmodel;
+
+import de.uapcore.lightpit.entities.Component;
+import de.uapcore.lightpit.entities.Issue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ComponentsView extends ProjectView {
+
+    private List<ComponentInfo> componentInfos = new ArrayList<>();
+
+    public ComponentsView() {
+        setSelectedPage(SELECTED_PAGE_COMPONENTS);
+    }
+
+    public void update(List<Component> components, List<Issue> issues) {
+        componentInfos.clear();
+        for (var component : components) {
+            final var info = new ComponentInfo(component);
+            info.collectIssues(issues);
+            componentInfos.add(info);
+        }
+    }
+
+    public List<ComponentInfo> getComponentInfos() {
+        return componentInfos;
+    }
+}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -11,6 +11,7 @@
     private Set<Version> versionsUpcoming = new HashSet<>();
     private Set<Version> versionsRecent = new HashSet<>();
     private List<User> users;
+    private List<Component> components;
     private List<IssueComment> comments;
 
     public void setIssue(Issue issue) {
@@ -45,7 +46,8 @@
         versionsUpcoming.addAll(issue.getResolvedVersions());
         for (var v : versions) {
             if (v.getStatus().isReleased()) {
-                versionsRecent.add(v);
+                if (!v.getStatus().equals(VersionStatus.Deprecated))
+                    versionsRecent.add(v);
             } else {
                 versionsUpcoming.add(v);
             }
@@ -60,6 +62,14 @@
         this.users = users;
     }
 
+    public List<Component> getComponents() {
+        return components;
+    }
+
+    public void setComponents(List<Component> components) {
+        this.components = components;
+    }
+
     public IssueStatus[] getIssueStatus() {
         return IssueStatus.values();
     }
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetails.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetails.java	Sat Oct 17 19:56:50 2020 +0200
@@ -13,14 +13,15 @@
     private List<Issue> issues;
     private IssueSummary issueSummary;
 
-    public void updateDetails(List<Issue> issues, Version version) {
+    public void updateDetails(List<Issue> issues) {
         this.issues = issues;
         issueSummary = new IssueSummary();
         issues.forEach(issueSummary::add);
-        if (version != null) {
-            versionInfo = new VersionInfo(version);
-            versionInfo.collectIssues(issues);
-        }
+    }
+
+    public void updateVersionInfo(Version version) {
+        versionInfo = new VersionInfo(version);
+        versionInfo.collectIssues(issues);
     }
 
     public List<Issue> getIssues() {
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectView.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/ProjectView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -8,11 +8,29 @@
 
 public class ProjectView {
 
+    public static final int SELECTED_PAGE_ISSUES = 0;
+    public static final int SELECTED_PAGE_VERSIONS = 1;
+    public static final int SELECTED_PAGE_COMPONENTS = 2;
+
+    public static final Version ALL_VERSIONS = new Version(0);
+    public static final Version NO_VERSION = new Version(-1);
+    public static final Component ALL_COMPONENTS = new Component(0);
+    public static final Component NO_COMPONENT = new Component(-1);
+
+    static {
+        ALL_VERSIONS.setNode("all-versions");
+        NO_VERSION.setNode("no-version");
+        ALL_COMPONENTS.setNode("all-components");
+        NO_COMPONENT.setNode("no-component");
+    }
+
     private final List<ProjectInfo> projectList = new ArrayList<>();
     private ProjectInfo projectInfo;
     private Version versionFilter;
     private Component componentFilter;
 
+    private int selectedPage = SELECTED_PAGE_ISSUES;
+
     public List<ProjectInfo> getProjectList() {
         return projectList;
     }
@@ -25,6 +43,14 @@
         this.projectInfo = projectInfo;
     }
 
+    public int getSelectedPage() {
+        return selectedPage;
+    }
+
+    public void setSelectedPage(int selectedPage) {
+        this.selectedPage = selectedPage;
+    }
+
     public Version getVersionFilter() {
         return versionFilter;
     }
@@ -40,4 +66,20 @@
     public void setComponentFilter(Component componentFilter) {
         this.componentFilter = componentFilter;
     }
+
+    public boolean isProjectInfoPresent() {
+        return projectInfo != null;
+    }
+
+    public boolean isVersionFilterValid() {
+        return projectInfo != null && versionFilter != null;
+    }
+
+    public boolean isComponentFilterValid() {
+        return projectInfo != null && componentFilter != null;
+    }
+
+    public boolean isEveryFilterValid() {
+        return projectInfo != null && versionFilter != null && componentFilter != null;
+    }
 }
--- a/src/main/java/de/uapcore/lightpit/viewmodel/VersionEditView.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/VersionEditView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -7,6 +7,10 @@
     private Version version;
     private String errorText;
 
+    public VersionEditView() {
+        setSelectedPage(SELECTED_PAGE_VERSIONS);
+    }
+
     public void setVersion(Version version) {
         this.version = version;
     }
--- a/src/main/java/de/uapcore/lightpit/viewmodel/VersionsView.java	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/VersionsView.java	Sat Oct 17 19:56:50 2020 +0200
@@ -8,18 +8,22 @@
 
 public class VersionsView extends ProjectView {
 
-    private List<VersionInfo> versionInfo = new ArrayList<>();
+    private List<VersionInfo> versionInfos = new ArrayList<>();
+
+    public VersionsView() {
+        setSelectedPage(SELECTED_PAGE_VERSIONS);
+    }
 
     public void update(List<Version> versions, List<Issue> issues) {
-        versionInfo.clear();
+        versionInfos.clear();
         for (var version : versions) {
             final var info = new VersionInfo(version);
             info.collectIssues(issues);
-            versionInfo.add(info);
+            versionInfos.add(info);
         }
     }
 
-    public List<VersionInfo> getVersionInfo() {
-        return versionInfo;
+    public List<VersionInfo> getVersionInfos() {
+        return versionInfos;
     }
 }
--- a/src/main/resources/localization/projects.properties	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/resources/localization/projects.properties	Sat Oct 17 19:56:50 2020 +0200
@@ -24,6 +24,8 @@
 pageTitle=Project Tracking
 
 button.create=New Project
+button.component.create=New Component
+button.component.edit=Edit Component
 button.version.create=New Version
 button.version.edit=Edit Version
 button.issue.create=New Issue
@@ -43,6 +45,8 @@
 owner=Project Lead
 version.latest=Latest Version
 version.next=Next Version
+issues=Issues
+component=Component
 
 progress=Overall Progress
 
@@ -56,10 +60,20 @@
 version.name=Version
 version.status=Status
 version.ordinal=Ordering
+
+component.project=Project
+component.name=Component
+component.color=Color
+component.lead=Lead
+component.ordinal=Ordering
+component.description=Description
+
 tooltip.ordinal=Use to override lexicographic ordering.
 
 placeholder.null-owner=Unassigned
+placeholder.null-lead=Unassigned
 placeholder.null-assignee=Unassigned
+placeholder.null-component=Unassigned
 
 version.status.Future=Future
 version.status.Unreleased=Unreleased
@@ -67,10 +81,10 @@
 version.status.LTS=LTS
 version.status.Deprecated=Deprecated
 
-
 issue.without-version=No Assigned Version
 issue.id=Issue ID
 issue.project=Project
+issue.component=Component
 issue.subject=Subject
 issue.description=Description
 issue.assignee=Assignee
--- a/src/main/resources/localization/projects_de.properties	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/resources/localization/projects_de.properties	Sat Oct 17 19:56:50 2020 +0200
@@ -24,6 +24,8 @@
 pageTitle=Projektverwaltung
 
 button.create=Neues Projekt
+button.component.create=Neue Komponente
+button.component.edit=Komponente Bearbeiten
 button.version.create=Neue Version
 button.version.edit=Version Bearbeiten
 button.issue.create=Neuer Vorgang
@@ -43,6 +45,8 @@
 owner=Projektleitung
 version.latest=Neuste Version
 version.next=N\u00e4chste Version
+component=Komponente
+issues=Vorg\u00e4nge
 
 progress=Gesamtfortschritt
 
@@ -56,10 +60,20 @@
 version.name=Version
 version.status=Status
 version.ordinal=Sequenznummer
+
+component.project=Projekt
+component.name=Komponente
+component.color=Farbe
+component.lead=Leitung
+component.ordinal=Sequenznummer
+component.description=Beschreibung
+
 tooltip.ordinal=\u00dcbersteuert die lexikographische Sortierung.
 
 placeholder.null-owner=Nicht Zugewiesen
+placeholder.null-lead=Niemand
 placeholder.null-assignee=Niemandem
+placeholder.null-component=Keine
 
 version.status.Future=Geplant
 version.status.Unreleased=Unver\u00f6ffentlicht
@@ -70,6 +84,7 @@
 issue.without-version=Keine Version zugeordnet
 issue.id=Vorgangs-ID
 issue.project=Projekt
+issue.component=Komponente
 issue.subject=Thema
 issue.description=Beschreibung
 issue.assignee=Zugewiesen
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/component-form.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -0,0 +1,95 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2020 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@page pageEncoding="UTF-8" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ComponentEditView" scope="request" />
+<c:set var="component" scope="page" value="${viewmodel.component}"/>
+<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
+
+<form action="./projects/commit-component" method="post">
+    <table class="formtable" style="width: 70ch">
+        <colgroup>
+            <col>
+            <col style="width: 100%">
+        </colgroup>
+        <tbody>
+        <tr>
+            <th><fmt:message key="component.project"/></th>
+            <td>
+                <c:out value="${project.name}" />
+                <input type="hidden" name="pid" value="${project.id}" />
+            </td>
+        </tr>
+        <tr>
+            <th><fmt:message key="component.name"/></th>
+            <td><input name="name" type="text" maxlength="20" required value="<c:out value="${component.name}"/>" /></td>
+        </tr>
+        <tr>
+            <th><fmt:message key="component.color"/></th>
+            <td><input name="color" type="color" required value="${component.color}" /></td>
+        </tr>
+        <tr>
+            <th><fmt:message key="component.lead"/></th>
+            <td>
+                <select name="lead">
+                    <option value="-1"><fmt:message key="placeholder.null-lead"/></option>
+                    <c:forEach var="user" items="${viewmodel.users}">
+                        <option
+                                <c:if test="${not empty component.lead and user eq component.lead}">selected</c:if>
+                                value="${user.id}"><c:out value="${user.displayname}"/></option>
+                    </c:forEach>
+                </select>
+            </td>
+        </tr>
+        <tr title="<fmt:message key="tooltip.ordinal" />">
+            <th><fmt:message key="component.ordinal"/></th>
+            <td>
+                <input name="ordinal" type="number" min="0" value="${component.ordinal}"/>
+            </td>
+        </tr>
+        <tr>
+            <th class="vtop"><fmt:message key="component.description"/></th>
+            <td>
+                <textarea name="description" rows="5"><c:out value="${component.description}"/></textarea>
+            </td>
+        </tr>
+        </tbody>
+        <tfoot>
+        <tr>
+            <td colspan="2">
+                <input type="hidden" name="id" value="${component.id}"/>
+                <a href="./projects/${project.node}/components/" class="button">
+                    <fmt:message bundle="${lightpit_bundle}" key="button.cancel"/>
+                </a>
+                <button type="submit"><fmt:message bundle="${lightpit_bundle}" key="button.okay"/></button>
+            </td>
+        </tr>
+        </tfoot>
+    </table>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/components.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -0,0 +1,95 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2020 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@page pageEncoding="UTF-8" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ComponentsView" scope="request" />
+
+<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
+
+<%@include file="../jspf/project-header.jspf"%>
+
+<div id="tool-area">
+    <a href="./projects/${project.id}/create-component" class="button"><fmt:message key="button.component.create"/></a>
+    <a href="./projects/${project.id}/create-issue" class="button"><fmt:message key="button.issue.create"/></a>
+</div>
+
+<h2><fmt:message key="progress" /></h2>
+
+<c:set var="summary" value="${viewmodel.projectInfo.issueSummary}" />
+<%@include file="../jspf/issue-summary.jspf"%>
+
+<table id="version-list" class="datatable medskip fullwidth">
+    <colgroup>
+        <col>
+        <col width="20%">
+        <col width="44%">
+        <col width="12%">
+        <col width="12%">
+        <col width="12%">
+    </colgroup>
+    <thead>
+    <tr>
+        <th colspan="3"></th>
+        <th colspan="3" class="hcenter">
+            <fmt:message key="issues"/>
+        </th>
+    </tr>
+    <tr>
+        <th></th>
+        <th><fmt:message key="component.name"/></th>
+        <th><fmt:message key="component.description"/></th>
+        <th class="hcenter"><fmt:message key="issues.open" /></th>
+        <th class="hcenter"><fmt:message key="issues.active" /></th>
+        <th class="hcenter"><fmt:message key="issues.done" /></th>
+    </tr>
+    </thead>
+    <tbody>
+        <c:forEach var="componentInfo" items="${viewmodel.componentInfos}" >
+        <tr>
+            <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/components/${componentInfo.component.node}/edit">&#x270e;</a></td>
+            <td rowspan="2">
+                <div class="navmenu-icon" style="background-color: ${componentInfo.component.color}"></div>
+                <a href="./projects/${project.node}/${componentInfo.component.node}/all-versions/issues/">
+                    <c:out value="${componentInfo.component.name}"/>
+                </a>
+            </td>
+            <td rowspan="2"><c:out value="${componentInfo.component.description}"/> </td>
+            <td class="hright">${componentInfo.issueSummary.open}</td>
+            <td class="hright">${componentInfo.issueSummary.active}</td>
+            <td class="hright">${componentInfo.issueSummary.done}</td>
+        </tr>
+        <tr>
+            <td colspan="3">
+                <c:set var="summary" value="${componentInfo.issueSummary}"/>
+                <%@include file="../jspf/issue-progress.jspf" %>
+            </td>
+        </tr>
+        </c:forEach>
+    </tbody>
+</table>
\ No newline at end of file
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -74,6 +74,19 @@
             <td><fmt:formatDate value="${issue.updated}" /></td>
         </tr>
         <tr>
+            <th><fmt:message key="issue.component"/></th>
+            <td>
+                <select name="component">
+                    <option value="-1"><fmt:message key="placeholder.null-component"/></option>
+                    <c:forEach var="component" items="${viewmodel.components}">
+                        <option
+                                <c:if test="${not empty issue.component and component eq issue.component}">selected</c:if>
+                                value="${component.id}"><c:out value="${component.name}"/></option>
+                    </c:forEach>
+                </select>
+            </td>
+        </tr>
+        <tr>
             <th><fmt:message key="issue.category"/></th>
             <td>
                 <select name="category">
@@ -154,7 +167,7 @@
             <td colspan="2">
                 <input type="hidden" name="id" value="${issue.id}"/>
                 <%-- TODO: fix #14 --%>
-                <a href="./projects/${issue.project.id}/versions/" class="button">
+                <a href="./projects/${issue.project.node}/versions/" class="button">
                     <fmt:message bundle="${lightpit_bundle}" key="button.cancel"/>
                 </a>
                 <button type="submit"><fmt:message bundle="${lightpit_bundle}" key="button.okay"/></button>
--- a/src/main/webapp/WEB-INF/jsp/issues.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,57 +0,0 @@
-<%--
-DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
-
-Copyright 2018 Mike Becker. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-1. Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
-notice, this list of conditions and the following disclaimer in the
-documentation and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---%>
-<%@page pageEncoding="UTF-8" %>
-<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
-<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
-
-<h1>TODO: REWRITE THIS PAGE</h1>
-<%--
-TODO: rewrite
-<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssuesView" scope="request"/>
-<c:set var="project" scope="page" value="${viewmodel.project}"/>
-<c:set var="version" scope="page" value="${viewmodel.version}"/>
-<%@include file="../jspf/project-header.jspf"%>
-
-<c:if test="${not empty version}">
-    <h2>
-        <fmt:message key="version.name" /> <c:out value="${version.name}" /> - <fmt:message key="version.status.${version.status}"/>
-        <a href="./projects/versions/edit?vid=${version.id}">&#x270e;</a>
-    </h2>
-</c:if>
-
-<div id="tool-area">
-    <div>
-        <a href="./projects/issues/edit" class="button"><fmt:message key="button.issue.create"/></a>
-        <c:if test="${not empty version}">
-            <a href="./projects/issues/?pid=${project.id}&vid=-1" class="button"><fmt:message key="button.issue.all"/></a>
-        </c:if>
-    </div>
-</div>
-
-<c:set var="issues" value="${viewmodel.issues}"/>
-<%@include file="../jspf/issue-list.jspf"%>
---%>
\ No newline at end of file
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -24,21 +24,26 @@
 OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 --%>
-<%@page pageEncoding="UTF-8" %>
+<%@page pageEncoding="UTF-8" import="de.uapcore.lightpit.viewmodel.ProjectView" %>
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectDetailsView" scope="request" />
 
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
+<c:set var="component" scope="page" value="${viewmodel.componentFilter}"/>
 <%@include file="../jspf/project-header.jspf"%>
 
 <div id="tool-area">
-    <c:if test="${not empty viewmodel.versionFilter}">
-        <a href="./projects/${project.id}/versions/${viewmodel.versionFilter.id}/edit" class="button"><fmt:message key="button.version.edit"/></a>
+    <a href="./projects/${project.node}/create-issue" class="button"><fmt:message key="button.issue.create"/></a>
+    <c:if test="${viewmodel.versionFilter.id gt 0}">
+        <a href="./projects/${project.node}/versions/${viewmodel.versionFilter.node}/edit" class="button"><fmt:message key="button.version.edit"/></a>
     </c:if>
-    <a href="./projects/${project.id}/create-version" class="button"><fmt:message key="button.version.create"/></a>
-    <a href="./projects/${project.id}/create-issue" class="button"><fmt:message key="button.issue.create"/></a>
+    <a href="./projects/${project.node}/create-version" class="button"><fmt:message key="button.version.create"/></a>
+    <c:if test="${viewmodel.componentFilter.id gt 0}">
+        <a href="./projects/${project.node}/components/${viewmodel.componentFilter.node}/edit" class="button"><fmt:message key="button.component.edit"/></a>
+    </c:if>
+    <a href="./projects/${project.node}/create-component" class="button"><fmt:message key="button.component.create"/></a>
 </div>
 
 <h2><fmt:message key="progress" /></h2>
@@ -46,36 +51,43 @@
 <c:set var="summary" value="${viewmodel.projectInfo.issueSummary}" />
 <%@include file="../jspf/issue-summary.jspf"%>
 
-<c:if test="${not empty viewmodel.versionFilter}">
-    <c:set var="versionInfo" value="${viewmodel.projectDetails.versionInfo}"/>
-    <h2>
-        <fmt:message key="version.name" /> <c:out value="${versionInfo.version.name}" /> - <fmt:message key="version.status.${versionInfo.version.status}"/>
-    </h2>
-
-    <h3><fmt:message key="issues.resolved"/> </h3>
-    <c:set var="summary" value="${versionInfo.resolvedTotal}"/>
-    <%@include file="../jspf/issue-summary.jspf"%>
-    <c:set var="issues" value="${versionInfo.resolved}"/>
-    <c:if test="${not empty issues}">
-        <%@include file="../jspf/issue-list.jspf"%>
-    </c:if>
+<c:choose>
+    <c:when test="${viewmodel.versionFilter eq ProjectView.NO_VERSION or viewmodel.versionFilter eq ProjectView.ALL_VERSIONS}">
+        <h2>
+            <c:if test="${viewmodel.versionFilter eq ProjectView.NO_VERSION}">
+                <fmt:message key="issue.without-version" />
+            </c:if>
+            <c:if test="${viewmodel.versionFilter ne ProjectView.NO_VERSION}">
+                <fmt:message key="issues" />
+            </c:if>
+        </h2>
+        <c:set var="summary" value="${viewmodel.projectDetails.issueSummary}"/>
+        <c:set var="issues" value="${viewmodel.projectDetails.issues}"/>
+        <%@include file="../jspf/issue-summary.jspf"%>
+        <c:if test="${not empty issues}">
+            <%@include file="../jspf/issue-list.jspf"%>
+        </c:if>
+    </c:when>
+    <c:otherwise>
+        <c:set var="versionInfo" value="${viewmodel.projectDetails.versionInfo}"/>
+        <h2>
+            <fmt:message key="version.name" /> <c:out value="${versionInfo.version.name}" /> - <fmt:message key="version.status.${versionInfo.version.status}"/>
+        </h2>
 
-    <c:set var="issues" value="${versionInfo.reported}"/>
-    <c:if test="${not empty issues}">
-        <h3><fmt:message key="issues.reported"/> </h3>
-        <c:set var="summary" value="${versionInfo.reportedTotal}"/>
+        <h3><fmt:message key="issues.resolved"/> </h3>
+        <c:set var="summary" value="${versionInfo.resolvedTotal}"/>
         <%@include file="../jspf/issue-summary.jspf"%>
-        <%@include file="../jspf/issue-list.jspf"%>
-    </c:if>
-</c:if>
-<c:if test="${empty viewmodel.versionFilter}">
-    <h2>
-        <fmt:message key="issue.without-version" />
-    </h2>
-    <c:set var="summary" value="${viewmodel.projectDetails.issueSummary}"/>
-    <c:set var="issues" value="${viewmodel.projectDetails.issues}"/>
-    <%@include file="../jspf/issue-summary.jspf"%>
-    <c:if test="${not empty issues}">
-        <%@include file="../jspf/issue-list.jspf"%>
-    </c:if>
-</c:if>
+        <c:set var="issues" value="${versionInfo.resolved}"/>
+        <c:if test="${not empty issues}">
+            <%@include file="../jspf/issue-list.jspf"%>
+        </c:if>
+
+        <c:set var="issues" value="${versionInfo.reported}"/>
+        <c:if test="${not empty issues}">
+            <h3><fmt:message key="issues.reported"/> </h3>
+            <c:set var="summary" value="${versionInfo.reportedTotal}"/>
+            <%@include file="../jspf/issue-summary.jspf"%>
+            <%@include file="../jspf/issue-list.jspf"%>
+        </c:if>
+    </c:otherwise>
+</c:choose>
\ No newline at end of file
--- a/src/main/webapp/WEB-INF/jsp/project-navmenu.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/project-navmenu.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -24,7 +24,7 @@
 OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 --%>
-<%@page pageEncoding="UTF-8" %>
+<%@page pageEncoding="UTF-8" import="de.uapcore.lightpit.viewmodel.ProjectView" %>
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
@@ -33,20 +33,27 @@
 <c:forEach var="projectInfo" items="${viewmodel.projectList}">
     <c:set var="isActive" value="${viewmodel.projectInfo.project eq projectInfo.project}" />
     <div class="menuEntry level-0" <c:if test="${isActive}">data-active</c:if> >
-        <a href="projects/${projectInfo.project.id}/versions/">
+        <a href="projects/${projectInfo.project.node}/versions/">
             <c:out value="${projectInfo.project.name}"/>
         </a>
     </div>
     <c:if test="${isActive}">
         <!-- VERSIONS -->
-        <div class="menuEntry level-1">
-            <a href="projects/${projectInfo.project.id}/versions/">
+        <c:set var="componentNode" value="${not empty viewmodel.componentFilter ? viewmodel.componentFilter.node : 'all-components'}"/>
+        <div class="menuEntry level-1" <c:if test="${viewmodel.selectedPage eq ProjectView.SELECTED_PAGE_VERSIONS}">data-active</c:if> >
+            <a href="projects/${projectInfo.project.node}/versions/">
                 <fmt:message key="navmenu.versions"/>
             </a>
         </div>
-        <div class="menuEntry level-2">
+        <div class="menuEntry level-2" <c:if test="${viewmodel.versionFilter eq ProjectView.ALL_VERSIONS}">data-active</c:if>>
             <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.id}/versions/unassigned">
+            <a href="projects/${projectInfo.project.node}/${componentNode}/all-versions/issues/">
+                <fmt:message key="navmenu.all" />
+            </a>
+        </div>
+        <div class="menuEntry level-2" <c:if test="${viewmodel.versionFilter eq ProjectView.NO_VERSION}">data-active</c:if>>
+            <div class="navmenu-icon" style="background: black"></div>
+            <a href="projects/${projectInfo.project.node}/${componentNode}/no-version/issues/">
                 <fmt:message key="navmenu.unassigned" />
             </a>
         </div>
@@ -55,39 +62,38 @@
             <div class="menuEntry level-2" <c:if test="${isVersionActive}">data-active</c:if>
                     title="<fmt:message key="version.status.${version.status}" />">
                 <div class="navmenu-icon version-${version.status}"></div>
-                <a href="projects/${projectInfo.project.id}/versions/${version.id}">
+                <a href="projects/${projectInfo.project.node}/${componentNode}/${version.node}/issues/">
                     <c:out value="${version.name}"/>
                 </a>
             </div>
         </c:forEach>
-        <%-- COMPONENTS
-        TODO: find a way to combine version and component into one URL
-        <div class="menuEntry level-1">
-            <a href="projects/${projectInfo.project.id}/components/">
+        <!-- COMPONENTS -->
+        <c:set var="versionNode" value="${not empty viewmodel.versionFilter ? viewmodel.versionFilter.node : 'all-versions'}"/>
+        <div class="menuEntry level-1" <c:if test="${viewmodel.selectedPage eq ProjectView.SELECTED_PAGE_COMPONENTS}">data-active</c:if>>
+            <a href="projects/${projectInfo.project.node}/components/">
                 <fmt:message key="navmenu.components"/>
             </a>
         </div>
-        <div class="menuEntry level-2">
+        <div class="menuEntry level-2" <c:if test="${viewmodel.componentFilter eq ProjectView.ALL_COMPONENTS}">data-active</c:if>>
             <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.id}/components/">
+            <a href="projects/${projectInfo.project.node}/all-components/${versionNode}/issues/">
                 <fmt:message key="navmenu.all" />
             </a>
         </div>
-        <div class="menuEntry level-2">
+        <div class="menuEntry level-2"  <c:if test="${viewmodel.componentFilter eq ProjectView.NO_COMPONENT}">data-active</c:if>>
             <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.id}/components/unassigned">
+            <a href="projects/${projectInfo.project.node}/no-component/${versionNode}/issues/">
                 <fmt:message key="navmenu.unassigned" />
             </a>
         </div>
         <c:forEach var="component" items="${viewmodel.projectInfo.components}">
             <c:set var="isComponentActive" value="${viewmodel.componentFilter eq component}" />
-            <div class="menuEntry level-2" <c:if test="${isVersionActive}">data-active</c:if> >
+            <div class="menuEntry level-2" <c:if test="${isComponentActive}">data-active</c:if> >
                 <div class="navmenu-icon" style="background-color: ${component.color}"></div>
-                <a href="projects/view?pid=${projectInfo.project.id}&cid=${component.id}">
+                <a href="projects/${projectInfo.project.node}/${component.node}/${versionNode}/issues/">
                     <c:out value="${component.name}"/>
                 </a>
             </div>
         </c:forEach>
-        --%>
     </c:if>
 </c:forEach>
--- a/src/main/webapp/WEB-INF/jsp/projects.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/projects.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -68,8 +68,8 @@
         <c:forEach var="projectInfo" items="${viewmodel.projectList}">
             <c:set var="project" scope="page" value="${projectInfo.project}"/>
             <tr class="nowrap">
-                <td style="width: 2em;"><a href="./projects/${project.id}/edit">&#x270e;</a></td>
-                <td><a href="./projects/${project.id}/versions/"><c:out value="${project.name}"/></a>
+                <td style="width: 2em;"><a href="./projects/${project.node}/edit">&#x270e;</a></td>
+                <td><a href="./projects/${project.node}/versions/"><c:out value="${project.name}"/></a>
                 </td>
                 <td>
                     <c:if test="${not empty project.repoUrl}">
@@ -79,12 +79,12 @@
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.latestVersion}">
-                        <a href="./projects/${project.id}/versions/${projectInfo.latestVersion.id}"><c:out value="${projectInfo.latestVersion.name}"/></a>
+                        <a href="./projects/${project.node}/all-components/${projectInfo.latestVersion.node}/issues/"><c:out value="${projectInfo.latestVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.nextVersion}">
-                        <a href="./projects/${project.id}/versions/${projectInfo.nextVersion.id}"><c:out value="${projectInfo.nextVersion.name}"/></a>
+                        <a href="./projects/${project.node}/all-components/${projectInfo.nextVersion.node}/issues/"><c:out value="${projectInfo.nextVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">${projectInfo.issueSummary.open}</td>
--- a/src/main/webapp/WEB-INF/jsp/version-form.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/version-form.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -73,7 +73,7 @@
         <tr>
             <td colspan="2">
                 <input type="hidden" name="id" value="${version.id}"/>
-                <a href="./projects/${project.id}/versions/" class="button">
+                <a href="./projects/${project.node}/versions/" class="button">
                     <fmt:message bundle="${lightpit_bundle}" key="button.cancel"/>
                 </a>
                 <button type="submit"><fmt:message bundle="${lightpit_bundle}" key="button.okay"/></button>
--- a/src/main/webapp/WEB-INF/jsp/versions.jsp	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/versions.jsp	Sat Oct 17 19:56:50 2020 +0200
@@ -76,11 +76,11 @@
     </tr>
     </thead>
     <tbody>
-        <c:forEach var="versionInfo" items="${viewmodel.versionInfo}" >
+        <c:forEach var="versionInfo" items="${viewmodel.versionInfos}" >
         <tr>
-            <td rowspan="2" style="width: 2em;"><a href="./projects/${project.id}/versions/${versionInfo.version.id}/edit">&#x270e;</a></td>
+            <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/versions/${versionInfo.version.node}/edit">&#x270e;</a></td>
             <td rowspan="2">
-                <a href="./projects/${project.id}/versions/${versionInfo.version.id}">
+                <a href="./projects/${project.node}/all-components/${versionInfo.version.node}/issues/">
                     <c:out value="${versionInfo.version.name}"/>
                 </a>
                 <div class="version-tag version-${versionInfo.version.status}">
--- a/src/main/webapp/WEB-INF/jspf/project-header.jspf	Sat Oct 17 15:21:56 2020 +0200
+++ b/src/main/webapp/WEB-INF/jspf/project-header.jspf	Sat Oct 17 19:56:50 2020 +0200
@@ -1,5 +1,6 @@
 <%--
 project: Project
+component: Component (optional)
 --%>
 <div class="table project-attributes">
     <div class="row">
@@ -21,4 +22,19 @@
             </c:if>
         </div>
     </div>
+    <c:if test="${not empty component and component.id gt 0}">
+        <div class="row">
+            <div class="caption"><fmt:message key="component"/>:</div>
+            <div><c:out value="${component.name}"/></div>
+            <div class="caption"><fmt:message key="component.lead"/>:</div>
+            <div>
+                <c:if test="${not empty component.lead}">
+                    <c:out value="${component.lead.displayname}"/>
+                </c:if>
+                <c:if test="${empty component.lead}">
+                    <fmt:message key="placeholder.null-lead"/>
+                </c:if>
+            </div>
+        </div>
+    </c:if>
 </div>

mercurial