adds the ability to create and edit issues

Fri, 22 May 2020 21:23:57 +0200

author
Mike Becker <universe@uap-core.de>
date
Fri, 22 May 2020 21:23:57 +0200
changeset 75
33b6843fdf8a
parent 74
91d1fc2a3a14
child 76
82f71fb1758a

adds the ability to create and edit issues

setup/postgres/psql_create_tables.sql file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/dao/GenericDao.java file | annotate | diff | comparison | revisions
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/dao/postgres/PGProjectDao.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/dao/postgres/PGUserDao.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/dao/postgres/PGVersionDao.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/User.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/modules/UsersModule.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/issue-form.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-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/user-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/version-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/lightpit.css file | annotate | diff | comparison | revisions
src/main/webapp/projects.css file | annotate | diff | comparison | revisions
--- a/setup/postgres/psql_create_tables.sql	Fri May 22 17:26:27 2020 +0200
+++ b/setup/postgres/psql_create_tables.sql	Fri May 22 21:23:57 2020 +0200
@@ -10,7 +10,7 @@
 );
 
 create table lpit_project (
-    id              serial          primary key,
+    projectid       serial          primary key,
     name            varchar(20)     not null unique,
     description     varchar(200),
     repoUrl         varchar(50),
@@ -26,8 +26,8 @@
 );
 
 create table lpit_version (
-    id              serial          primary key,
-    project         integer         not null references lpit_project(id),
+    versionid       serial          primary key,
+    project         integer         not null references lpit_project(projectid),
     name            varchar(20)     not null,
     ordinal         integer         not null default 0,
     status          version_status  not null default 'Future'
@@ -53,15 +53,34 @@
 );
 
 create table lpit_issue (
-    id              serial          primary key,
-    project         integer         not null references lpit_project(id),
+    issueid         serial          primary key,
+    project         integer         not null references lpit_project(projectid),
     status          issue_status    not null default 'InSpecification',
     category        issue_category  not null default 'Feature',
     subject         varchar(20)     not null,
     description     text,
-    version_plan    integer         references lpit_version(id),
-    version_done    integer         references lpit_version(id),
+    assignee        integer         references lpit_user(userid),
     created         timestamp       with time zone not null default now(),
     updated         timestamp       with time zone not null default now(),
     eta             date
 );
+
+create table lpit_issue_affected_version (
+    issueid         integer         references lpit_issue(issueid),
+    versionid       integer         references lpit_version(versionid),
+    primary key (issueid, versionid)
+);
+
+create table lpit_issue_scheduled_version (
+    issueid         integer         references lpit_issue(issueid),
+    versionid       integer         references lpit_version(versionid),
+    primary key (issueid, versionid)
+);
+
+create table lpit_issue_resolved_version (
+    issueid         integer         references lpit_issue(issueid),
+    versionid       integer         references lpit_version(versionid),
+    primary key (issueid, versionid)
+);
+
+
--- a/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java	Fri May 22 21:23:57 2020 +0200
@@ -295,6 +295,13 @@
         final String paramValue = req.getParameter(name);
         if (paramValue == null) return Optional.empty();
         if (clazz.equals(String.class)) return Optional.of((T) paramValue);
+        if (java.sql.Date.class.isAssignableFrom(clazz)) {
+            try {
+                return Optional.of((T)java.sql.Date.valueOf(paramValue));
+            } catch (IllegalArgumentException ex) {
+                return Optional.empty();
+            }
+        }
         try {
             final Constructor<T> ctor = clazz.getConstructor(String.class);
             return Optional.of(ctor.newInstance(paramValue));
--- a/src/main/java/de/uapcore/lightpit/dao/GenericDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/GenericDao.java	Fri May 22 21:23:57 2020 +0200
@@ -34,6 +34,7 @@
 
     /**
      * Finds an entity by its integer ID.
+     * It is not guaranteed that referenced entities are automatically joined.
      *
      * @param id the id
      * @return the enity or null if there is no such entity
@@ -43,6 +44,7 @@
 
     /**
      * Inserts an instance into database.
+     * It is not guaranteed that generated fields will be updated in the instance.
      *
      * @param instance the instance to insert
      * @throws SQLException on any kind of SQL errors
--- a/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Fri May 22 21:23:57 2020 +0200
@@ -38,10 +38,31 @@
 
     /**
      * Lists all issues for the specified project.
+     * This is not guaranteed to contain version information.
+     * Use {@link #joinVersionInformation(Issue)} to obtain this information for a specific issue.
      *
      * @param project the project
      * @return a list of issues
      * @throws SQLException on any kind of SQL error
      */
     List<Issue> list(Project project) throws SQLException;
+
+    /**
+     * Saves an instances to the database.
+     * Implementations of this DAO must guarantee that the generated ID is stored in the instance.
+     *
+     * @param instance the instance to insert
+     * @throws SQLException on any kind of SQL error
+     * @see Issue#setId(int)
+     */
+    @Override
+    void save(Issue instance) throws SQLException;
+
+    /**
+     * Retrieves the affected, scheduled and resolved versions for the specified issue.
+     *
+     * @param issue the issue to join the information for
+     * @throws SQLException on any kind of SQL error
+     */
+    void joinVersionInformation(Issue issue) throws SQLException;
 }
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Fri May 22 21:23:57 2020 +0200
@@ -43,63 +43,92 @@
 
 public final class PGIssueDao implements IssueDao {
 
-    private final PreparedStatement insert, update, list, find;
+    private final PreparedStatement insert, update, list, find, affectedVersions, scheduledVersions, resolvedVersions;
 
     public PGIssueDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
-                "select issue.id, issue.project, issue.status, issue.category, issue.subject, issue.description, " +
-                        "vplan.id, vplan.name, vdone.id, vdone.name, " +
-                        "issue.created, issue.updated, issue.eta " +
-                        "from lpit_issue issue " +
-                        "left join lpit_version vplan on vplan.id = version_plan " +
-                        "left join lpit_version vdone on vdone.id = version_done " +
-                        "where issue.project = ? ");
+                "select issueid, project, status, category, subject, description, " +
+                        "userid, username, givenname, lastname, mail, " +
+                        "created, updated, eta " +
+                        "from lpit_issue " +
+                        "left join lpit_user on userid = assignee " +
+                        "where project = ? ");
 
         find = connection.prepareStatement(
-                "select issue.id, issue.project, issue.status, issue.category, issue.subject, issue.description, " +
-                        "vplan.id, vplan.name, vdone.id, vdone.name, " +
-                        "issue.created, issue.updated, issue.eta " +
-                        "from lpit_issue issue " +
-                        "left join lpit_version vplan on vplan.id = version_plan " +
-                        "left join lpit_version vdone on vdone.id = version_done " +
-                        "where issue.id = ? ");
+                "select issueid, project, status, category, subject, description, " +
+                        "userid, username, givenname, lastname, mail, " +
+                        "created, updated, eta " +
+                        "from lpit_issue " +
+                        "left join lpit_user on userid = assignee " +
+                        "where issueid = ? ");
 
         insert = connection.prepareStatement(
-                "insert into lpit_issue (project, status, category, subject, description, version_plan, version_done, eta) " +
-                        "values (?, ?::issue_status, ?::issue_category, ?, ?, ?, ?, ?)"
+                "insert into lpit_issue (project, 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, " +
-                        "subject = ?, description = ?, version_plan = ?, version_done = ?, eta = ? where id = ?"
+                        "subject = ?, description = ?, assignee = ?, eta = ? where issueid = ?"
+        );
+
+        affectedVersions = connection.prepareStatement(
+                "select v.versionid, v.name, v.status, v.ordinal " +
+                        "from lpit_version v join lpit_issue_affected_version using (versionid) " +
+                        "where issueid = ? " +
+                        "order by v.ordinal, v.name"
+        );
+
+        scheduledVersions = connection.prepareStatement(
+                "select v.versionid, v.name, v.status, v.ordinal " +
+                        "from lpit_version v join lpit_issue_scheduled_version using (versionid) " +
+                        "where issueid = ? " +
+                        "order by v.ordinal, v.name"
+        );
+
+        resolvedVersions = connection.prepareStatement(
+                "select v.versionid, v.name, v.status, v.ordinal " +
+                        "from lpit_version v join lpit_issue_resolved_version using (versionid) " +
+                        "where issueid = ? " +
+                        "order by v.ordinal, v.name"
         );
     }
 
-    private Version obtainVersion(ResultSet result, Project project, String prefix) throws SQLException {
-        final int vplan = result.getInt(prefix + "id");
-        if (vplan > 0) {
-            final var ver = new Version(vplan, project);
-            ver.setName(result.getString(prefix + "name"));
-            return ver;
+    private User obtainAssignee(ResultSet result) throws SQLException {
+        final int id = result.getInt("userid");
+        if (id != 0) {
+            final var user = new User(id);
+            user.setUsername(result.getString("username"));
+            user.setGivenname(result.getString("givenname"));
+            user.setLastname(result.getString("lastname"));
+            user.setMail(result.getString("mail"));
+            return user;
         } else {
             return null;
         }
     }
 
-    public Issue mapColumns(ResultSet result) throws SQLException {
-        final var project = new Project(result.getInt("issue.project"));
-        final var issue = new Issue(result.getInt("issue.id"), project);
-        issue.setStatus(IssueStatus.valueOf(result.getString("issue.status")));
-        issue.setCategory(IssueCategory.valueOf(result.getString("issue.category")));
-        issue.setSubject(result.getString("issue.subject"));
-        issue.setDescription(result.getString("issue.description"));
-        issue.setScheduledVersion(obtainVersion(result, project, "vplan."));
-        issue.setResolvedVersion(obtainVersion(result, project, "vdone."));
-        issue.setCreated(result.getTimestamp("issue.created"));
-        issue.setUpdated(result.getTimestamp("issue.updated"));
-        issue.setEta(result.getDate("issue.eta"));
+    private Issue mapColumns(ResultSet result) throws SQLException {
+        final var project = new Project(result.getInt("project"));
+        final var issue = new Issue(result.getInt("issueid"), project);
+        issue.setStatus(IssueStatus.valueOf(result.getString("status")));
+        issue.setCategory(IssueCategory.valueOf(result.getString("category")));
+        issue.setSubject(result.getString("subject"));
+        issue.setDescription(result.getString("description"));
+        issue.setAssignee(obtainAssignee(result));
+        issue.setCreated(result.getTimestamp("created"));
+        issue.setUpdated(result.getTimestamp("updated"));
+        issue.setEta(result.getDate("eta"));
         return issue;
     }
 
+    private Version mapVersion(ResultSet result, Project project) throws SQLException {
+        final var version = new Version(result.getInt("v.versionid"), project);
+        version.setName(result.getString("v.name"));
+        version.setOrdinal(result.getInt("v.ordinal"));
+        version.setStatus(VersionStatus.valueOf(result.getString("v.status")));
+        return version;
+    }
+
     @Override
     public void save(Issue instance) throws SQLException {
         Objects.requireNonNull(instance.getSubject());
@@ -109,36 +138,38 @@
         insert.setString(3, instance.getCategory().name());
         insert.setString(4, instance.getSubject());
         setStringOrNull(insert, 5, instance.getDescription());
-        setForeignKeyOrNull(insert, 6, instance.getScheduledVersion(), Version::getId);
-        setForeignKeyOrNull(insert, 7, instance.getResolvedVersion(), Version::getId);
-        setDateOrNull(insert, 8, instance.getEta());
-        insert.executeUpdate();
+        setForeignKeyOrNull(insert, 6, instance.getAssignee(), User::getId);
+        setDateOrNull(insert, 7, instance.getEta());
+        // insert and retrieve the ID
+        final var rs = insert.executeQuery();
+        rs.next();
+        instance.setId(rs.getInt(1));
     }
 
     @Override
     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.getScheduledVersion(), Version::getId);
-        setForeignKeyOrNull(update, 6, instance.getResolvedVersion(), Version::getId);
-        setDateOrNull(update, 7, instance.getEta());
-        update.setInt(8, instance.getId());
+        setForeignKeyOrNull(update, 5, instance.getAssignee(), User::getId);
+        setDateOrNull(update, 6, instance.getEta());
+        update.setInt(7, instance.getId());
         return update.executeUpdate() > 0;
     }
 
     @Override
     public List<Issue> list(Project project) throws SQLException {
         list.setInt(1, project.getId());
-        List<Issue> versions = new ArrayList<>();
+        List<Issue> issues = new ArrayList<>();
         try (var result = list.executeQuery()) {
             while (result.next()) {
-                versions.add(mapColumns(result));
+                issues.add(mapColumns(result));
             }
         }
-        return versions;
+        return issues;
     }
 
     @Override
@@ -152,4 +183,23 @@
             }
         }
     }
+
+    private List<Version> listVersions(PreparedStatement stmt, Issue issue) throws SQLException {
+        stmt.setInt(1, issue.getId());
+        List<Version> versions = new ArrayList<>();
+        try (var result = stmt.executeQuery()) {
+            while (result.next()) {
+                versions.add(mapVersion(result, issue.getProject()));
+            }
+        }
+        return versions;
+    }
+
+    @Override
+    public void joinVersionInformation(Issue issue) throws SQLException {
+        Objects.requireNonNull(issue.getProject());
+        issue.setAffectedVersions(listVersions(affectedVersions, issue));
+        issue.setScheduledVersions(listVersions(scheduledVersions, issue));
+        issue.setResolvedVersions(listVersions(resolvedVersions, issue));
+    }
 }
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGProjectDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGProjectDao.java	Fri May 22 21:23:57 2020 +0200
@@ -49,29 +49,29 @@
 
     public PGProjectDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
-                "select id, name, description, repourl, " +
+                "select projectid, name, description, repourl, " +
                         "userid, username, lastname, givenname, mail " +
                         "from lpit_project " +
                         "left join lpit_user owner on lpit_project.owner = owner.userid " +
                         "order by name");
 
         find = connection.prepareStatement(
-                "select id, name, description, repourl, " +
+                "select projectid, name, description, repourl, " +
                         "userid, username, lastname, givenname, mail " +
                         "from lpit_project " +
                         "left join lpit_user owner on lpit_project.owner = owner.userid " +
-                        "where id = ?");
+                        "where projectid = ?");
 
         insert = connection.prepareStatement(
                 "insert into lpit_project (name, description, repourl, owner) values (?, ?, ?, ?)"
         );
         update = connection.prepareStatement(
-                "update lpit_project set name = ?, description = ?, repourl = ?, owner = ? where id = ?"
+                "update lpit_project set name = ?, description = ?, repourl = ?, owner = ? where projectid = ?"
         );
     }
 
     public Project mapColumns(ResultSet result) throws SQLException {
-        final var proj = new Project(result.getInt("id"));
+        final var proj = new Project(result.getInt("projectid"));
         proj.setName(result.getString("name"));
         proj.setDescription(result.getString("description"));
         proj.setRepoUrl(result.getString("repourl"));
@@ -101,6 +101,7 @@
 
     @Override
     public boolean update(Project instance) throws SQLException {
+        if (instance.getId() < 0) return false;
         Objects.requireNonNull(instance.getName());
         update.setString(1, instance.getName());
         setStringOrNull(update, 2, instance.getDescription());
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGUserDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGUserDao.java	Fri May 22 21:23:57 2020 +0200
@@ -58,7 +58,7 @@
         update = connection.prepareStatement("update lpit_user set lastname = ?, givenname = ?, mail = ? where userid = ?");
     }
 
-    public User mapColumns(ResultSet result) throws SQLException {
+    private User mapColumns(ResultSet result) throws SQLException {
         final int id = result.getInt("userid");
         if (id == 0) return null;
         final var user = new User(id);
@@ -81,6 +81,7 @@
 
     @Override
     public boolean update(User instance) throws SQLException {
+        if (instance.getId() < 0) return false;
         setStringOrNull(update, 1, instance.getLastname());
         setStringOrNull(update, 2, instance.getGivenname());
         setStringOrNull(update, 3, instance.getMail());
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGVersionDao.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGVersionDao.java	Fri May 22 21:23:57 2020 +0200
@@ -47,26 +47,27 @@
 
     public PGVersionDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
-                "select id, project, name, ordinal, status " +
+                "select versionid, project, name, ordinal, status " +
                         "from lpit_version " +
                         "where project = ? " +
                         "order by ordinal, lower(name)");
 
         find = connection.prepareStatement(
-                "select id, project, name, ordinal, status " +
+                "select versionid, project, name, ordinal, status " +
                         "from lpit_version " +
-                        "where id = ?");
+                        "where versionid = ?");
 
         insert = connection.prepareStatement(
                 "insert into lpit_version (project, name, ordinal, status) values (?, ?, ?, ?::version_status)"
         );
         update = connection.prepareStatement(
-                "update lpit_version set name = ?, ordinal = ?, status = ?::version_status where id = ?"
+                "update lpit_version set name = ?, ordinal = ?, status = ?::version_status where versionid = ?"
         );
     }
 
-    public Version mapColumns(ResultSet result) throws SQLException {
-        final var version = new Version(result.getInt("id"), new Project(result.getInt("project")));
+    private Version mapColumns(ResultSet result) throws SQLException {
+        final var project = new Project(result.getInt("project"));
+        final var version = new Version(result.getInt("versionid"), project);
         version.setName(result.getString("name"));
         version.setOrdinal(result.getInt("ordinal"));
         version.setStatus(VersionStatus.valueOf(result.getString("status")));
@@ -86,6 +87,7 @@
 
     @Override
     public boolean update(Version instance) throws SQLException {
+        if (instance.getId() < 0) return false;
         Objects.requireNonNull(instance.getName());
         update.setString(1, instance.getName());
         update.setInt(2, instance.getOrdinal());
@@ -100,7 +102,9 @@
         List<Version> versions = new ArrayList<>();
         try (var result = list.executeQuery()) {
             while (result.next()) {
-                versions.add(mapColumns(result));
+                final var v = mapColumns(result);
+                v.setProject(project);
+                versions.add(v);
             }
         }
         return versions;
--- a/src/main/java/de/uapcore/lightpit/entities/Issue.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Issue.java	Fri May 22 21:23:57 2020 +0200
@@ -30,12 +30,14 @@
 
 import java.sql.Date;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
 public final class Issue {
 
-    private final int id;
+    private int id;
     private final Project project;
 
     private IssueStatus status;
@@ -43,13 +45,14 @@
 
     private String subject;
     private String description;
+    private User assignee;
 
-    private List<Version> affectedVersions;
-    private Version scheduledVersion;
-    private Version resolvedVersion;
+    private List<Version> affectedVersions = Collections.emptyList();
+    private List<Version> scheduledVersions = Collections.emptyList();
+    private List<Version> resolvedVersions = Collections.emptyList();
 
-    private Timestamp created;
-    private Timestamp updated;
+    private Timestamp created = Timestamp.from(Instant.now());
+    private Timestamp updated = Timestamp.from(Instant.now());
     private Date eta;
 
     public Issue(int id, Project project) {
@@ -61,6 +64,14 @@
         return id;
     }
 
+    /**
+     * Should only be used by a DAO to store the generated ID.
+     * @param id the freshly generated ID returned from the database after insert
+     */
+    public void setId(int id) {
+        this.id = id;
+    }
+
     public Project getProject() {
         return project;
     }
@@ -97,6 +108,14 @@
         this.description = description;
     }
 
+    public User getAssignee() {
+        return assignee;
+    }
+
+    public void setAssignee(User assignee) {
+        this.assignee = assignee;
+    }
+
     public List<Version> getAffectedVersions() {
         return affectedVersions;
     }
@@ -105,20 +124,20 @@
         this.affectedVersions = affectedVersions;
     }
 
-    public Version getScheduledVersion() {
-        return scheduledVersion;
+    public List<Version> getScheduledVersions() {
+        return scheduledVersions;
     }
 
-    public void setScheduledVersion(Version scheduledVersion) {
-        this.scheduledVersion = scheduledVersion;
+    public void setScheduledVersions(List<Version> scheduledVersions) {
+        this.scheduledVersions = scheduledVersions;
     }
 
-    public Version getResolvedVersion() {
-        return resolvedVersion;
+    public List<Version> getResolvedVersions() {
+        return resolvedVersions;
     }
 
-    public void setResolvedVersion(Version resolvedVersion) {
-        this.resolvedVersion = resolvedVersion;
+    public void setResolvedVersions(List<Version> resolvedVersions) {
+        this.resolvedVersions = resolvedVersions;
     }
 
     public Timestamp getCreated() {
--- a/src/main/java/de/uapcore/lightpit/entities/User.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/User.java	Fri May 22 21:23:57 2020 +0200
@@ -80,21 +80,26 @@
         this.lastname = lastname;
     }
 
-    public String getDisplayname() {
+    public String getShortDisplayname() {
         StringBuilder dn = new StringBuilder();
         if (givenname != null)
             dn.append(givenname);
         dn.append(' ');
         if (lastname != null)
             dn.append(lastname);
-        dn.append(' ');
-        if (mail != null && !mail.isBlank()) {
-            dn.append("<" + mail + ">");
-        }
         final var str = dn.toString().trim();
         return str.isBlank() ? username : str;
     }
 
+    public String getDisplayname() {
+        final String sdn = getShortDisplayname();
+        if (mail != null && !mail.isBlank()) {
+            return sdn + " <" + mail + ">";
+        } else {
+            return sdn;
+        }
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
--- a/src/main/java/de/uapcore/lightpit/entities/Version.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/entities/Version.java	Fri May 22 21:23:57 2020 +0200
@@ -33,7 +33,7 @@
 public final class Version implements Comparable<Version> {
 
     private final int id;
-    private final Project project;
+    private Project project;
     private String name;
     /**
      * If we do not want versions to be ordered lexicographically we may specify an order.
@@ -50,6 +50,10 @@
         return id;
     }
 
+    public void setProject(Project project) {
+        this.project = project;
+    }
+
     public Project getProject() {
         return project;
     }
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Fri May 22 21:23:57 2020 +0200
@@ -38,12 +38,14 @@
 import javax.servlet.annotation.WebServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
 import java.io.IOException;
+import java.sql.Date;
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.NoSuchElementException;
-import java.util.Optional;
+import java.util.Objects;
 
 import static de.uapcore.lightpit.Functions.fqn;
 
@@ -61,32 +63,85 @@
     private static final Logger LOG = LoggerFactory.getLogger(ProjectsModule.class);
 
     public static final String SESSION_ATTR_SELECTED_PROJECT = fqn(ProjectsModule.class, "selected-project");
+    public static final String SESSION_ATTR_SELECTED_ISSUE = fqn(ProjectsModule.class, "selected-issue");
+    public static final String SESSION_ATTR_SELECTED_VERSION = fqn(ProjectsModule.class, "selected-version");
 
-    private Project getSelectedProject(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
-        final var projectDao = dao.getProjectDao();
-        final var session = req.getSession();
-        final var projectSelection = getParameter(req, Integer.class, "pid");
-        final Project selectedProject;
-        if (projectSelection.isPresent()) {
-            selectedProject = projectDao.find(projectSelection.get());
-        } else {
-            final var sessionProject = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
-            selectedProject = sessionProject == null ? null : projectDao.find(sessionProject.getId());
+    private class SessionSelection {
+        final HttpSession session;
+        Project project;
+        Version version;
+        Issue issue;
+
+        SessionSelection(HttpServletRequest req, Project project) {
+            this.session = req.getSession();
+            this.project = project;
+            version = null;
+            issue = null;
+            updateAttributes();
         }
-        session.setAttribute(SESSION_ATTR_SELECTED_PROJECT, selectedProject);
-        return selectedProject;
+
+        SessionSelection(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
+            this.session = req.getSession();
+            final var issueDao = dao.getIssueDao();
+            final var projectDao = dao.getProjectDao();
+            final var issueSelection = getParameter(req, Integer.class, "issue");
+            if (issueSelection.isPresent()) {
+                issue = issueDao.find(issueSelection.get());
+            } else {
+                final var issue = (Issue) session.getAttribute(SESSION_ATTR_SELECTED_ISSUE);
+                this.issue = issue == null ? null : issueDao.find(issue.getId());
+            }
+            if (issue != null) {
+                version = null; // show the issue globally
+                project = projectDao.find(issue.getProject().getId());
+            }
+
+            final var projectSelection = getParameter(req, Integer.class, "pid");
+            if (projectSelection.isPresent()) {
+                final var selectedProject = projectDao.find(projectSelection.get());
+                if (!Objects.equals(selectedProject, project)) {
+                    // reset version and issue if project changed
+                    version = null;
+                    issue = null;
+                }
+                project = selectedProject;
+            } else {
+                final var sessionProject = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
+                project = sessionProject == null ? null : projectDao.find(sessionProject.getId());
+            }
+            updateAttributes();
+        }
+
+        void selectVersion(Version version) {
+            if (!version.getProject().equals(project)) throw new AssertionError("Nice, you implemented a bug!");
+            this.version = version;
+            this.issue = null;
+            updateAttributes();
+        }
+
+        void selectIssue(Issue issue) {
+            if (!issue.getProject().equals(project)) throw new AssertionError("Nice, you implemented a bug!");
+            this.issue = issue;
+            this.version = null;
+            updateAttributes();
+        }
+
+        void updateAttributes() {
+            session.setAttribute(SESSION_ATTR_SELECTED_PROJECT, project);
+            session.setAttribute(SESSION_ATTR_SELECTED_VERSION, version);
+            session.setAttribute(SESSION_ATTR_SELECTED_ISSUE, issue);
+        }
     }
 
 
     /**
      * Creates the breadcrumb menu.
      *
-     * @param level           the current active level
-     * @param selectedProject the selected project, if any, or null
+     * @param level           the current active level (0: root, 1: project, 2: version, 3: issue)
+     * @param sessionSelection the currently selected objects
      * @return a dynamic breadcrumb menu trying to display as many levels as possible
      */
-    private List<MenuEntry> getBreadcrumbs(int level,
-                                           Project selectedProject) {
+    private List<MenuEntry> getBreadcrumbs(int level, SessionSelection sessionSelection) {
         MenuEntry entry;
 
         final var breadcrumbs = new ArrayList<MenuEntry>();
@@ -95,52 +150,77 @@
         breadcrumbs.add(entry);
         if (level == 0) entry.setActive(true);
 
-        if (selectedProject == null)
-            return breadcrumbs;
+        if (sessionSelection.project != null) {
+            if (sessionSelection.project.getId() < 0) {
+                entry = new MenuEntry(new ResourceKey("localization.projects", "button.create"),
+                        "projects/edit", 1);
+            } else {
+                entry = new MenuEntry(sessionSelection.project.getName(),
+                        "projects/view?pid=" + sessionSelection.project.getId(), 1);
+            }
+            if (level == 1) entry.setActive(true);
+            breadcrumbs.add(entry);
+        }
 
-        entry = new MenuEntry(selectedProject.getName(),
-                "projects/view?pid=" + selectedProject.getId(), 1);
-        if (level == 1) entry.setActive(true);
+        if (sessionSelection.version != null) {
+            if (sessionSelection.version.getId() < 0) {
+                entry = new MenuEntry(new ResourceKey("localization.projects", "button.version.create"),
+                        "projects/versions/edit", 2);
+            } else {
+                entry = new MenuEntry(sessionSelection.version.getName(),
+                        // TODO: change link to issue overview for that version
+                        "projects/versions/edit?id=" + sessionSelection.version.getId(), 2);
+            }
+            if (level == 2) entry.setActive(true);
+            breadcrumbs.add(entry);
+        }
 
-        breadcrumbs.add(entry);
+        if (sessionSelection.issue != null) {
+            entry = new MenuEntry(new ResourceKey("localization.projects", "menu.issues"),
+                    // TODO: change link to a separate issue view (maybe depending on the selected version)
+                    "projects/view?pid=" + sessionSelection.issue.getProject().getId(), 3);
+            breadcrumbs.add(entry);
+            if (sessionSelection.issue.getId() < 0) {
+                entry = new MenuEntry(new ResourceKey("localization.projects", "button.issue.create"),
+                        "projects/issues/edit", 2);
+            } else {
+                entry = new MenuEntry("#" + sessionSelection.issue.getId(),
+                        // TODO: maybe change link to a view rather than directly opening the editor
+                        "projects/issues/edit?id=" + sessionSelection.issue.getId(), 4);
+            }
+            if (level == 3) entry.setActive(true);
+            breadcrumbs.add(entry);
+        }
+
         return breadcrumbs;
     }
 
     @RequestMapping(method = HttpMethod.GET)
     public ResponseType index(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
-
+        final var sessionSelection = new SessionSelection(req, dao);
         final var projectList = dao.getProjectDao().list();
         req.setAttribute("projects", projectList);
         setContentPage(req, "projects");
         setStylesheet(req, "projects");
 
-        final var selectedProject = getSelectedProject(req, dao);
-        setBreadcrumbs(req, getBreadcrumbs(0, selectedProject));
+        setBreadcrumbs(req, getBreadcrumbs(0, sessionSelection));
 
         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));
-        }
-
+    private void configureEditForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException {
+        req.setAttribute("project", selection.project);
         req.setAttribute("users", dao.getUserDao().list());
         setContentPage(req, "project-form");
+        setBreadcrumbs(req, getBreadcrumbs(1, selection));
     }
 
     @RequestMapping(requestPath = "edit", method = HttpMethod.GET)
     public ResponseType edit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
+        final var selection = new SessionSelection(req, findByParameter(req, Integer.class, "id",
+                dao.getProjectDao()::find).orElse(new Project(-1)));
 
-        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());
-        }
+        configureEditForm(req, dao, selection);
 
         return ResponseType.HTML;
     }
@@ -148,7 +228,7 @@
     @RequestMapping(requestPath = "commit", method = HttpMethod.POST)
     public ResponseType commit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
 
-        Project project = null;
+        Project project = new Project(-1);
         try {
             project = new Project(getParameter(req, Integer.class, "id").orElseThrow());
             project.setName(getParameter(req, String.class, "name").orElseThrow());
@@ -163,11 +243,11 @@
             setRedirectLocation(req, "./projects/");
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
             LOG.debug("Successfully updated project {}", project.getName());
-        } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
             // TODO: set request attribute with error text
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
-            configureEditForm(req, dao, Optional.ofNullable(project));
+            configureEditForm(req, dao, new SessionSelection(req, project));
         }
 
         return ResponseType.HTML;
@@ -175,123 +255,133 @@
 
     @RequestMapping(requestPath = "view", method = HttpMethod.GET)
     public ResponseType view(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException {
-        final var selectedProject = getSelectedProject(req, dao);
-        if (selectedProject == null) {
-            resp.sendError(HttpServletResponse.SC_FORBIDDEN);
-            return ResponseType.NONE;
-        }
+        final var sessionSelection = new SessionSelection(req, dao);
 
-        req.setAttribute("versions", dao.getVersionDao().list(selectedProject));
-        req.setAttribute("issues", dao.getIssueDao().list(selectedProject));
+        req.setAttribute("versions", dao.getVersionDao().list(sessionSelection.project));
+        req.setAttribute("issues", dao.getIssueDao().list(sessionSelection.project));
 
-        // TODO: add more levels depending on last visited location
-        setBreadcrumbs(req, getBreadcrumbs(1, selectedProject));
-
+        setBreadcrumbs(req, getBreadcrumbs(1, sessionSelection));
         setContentPage(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)));
+    private void configureEditVersionForm(HttpServletRequest req, SessionSelection selection) {
+        req.setAttribute("version", selection.version);
         req.setAttribute("versionStatusEnum", VersionStatus.values());
 
         setContentPage(req, "version-form");
+        setBreadcrumbs(req, getBreadcrumbs(2, selection));
     }
 
     @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);
-        if (selectedProject == null) {
+        final var sessionSelection = new SessionSelection(req, dao);
+        if (sessionSelection.project == null) {
+            // TODO: remove this bullshit and only retrieve the object from session if we are creating a fresh version
             resp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return ResponseType.NONE;
         }
 
-        configureEditVersionForm(req,
-                findByParameter(req, Integer.class, "id", dao.getVersionDao()::find),
-                selectedProject);
+        sessionSelection.selectVersion(findByParameter(req, Integer.class, "id", dao.getVersionDao()::find)
+                .orElse(new Version(-1, sessionSelection.project)));
+        configureEditVersionForm(req, sessionSelection);
 
         return ResponseType.HTML;
     }
 
     @RequestMapping(requestPath = "versions/commit", method = HttpMethod.POST)
     public ResponseType commitVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException {
-        final var selectedProject = getSelectedProject(req, dao);
-        if (selectedProject == null) {
+        final var sessionSelection = new SessionSelection(req, dao);
+        if (sessionSelection.project == null) {
+            // TODO: remove this bullshit and retrieve project id from hidden field
             resp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return ResponseType.NONE;
         }
 
-        Version version = null;
+        var version = new Version(-1, sessionSelection.project);
         try {
-            version = new Version(getParameter(req, Integer.class, "id").orElseThrow(), selectedProject);
+            version = new Version(getParameter(req, Integer.class, "id").orElseThrow(), sessionSelection.project);
             version.setName(getParameter(req, String.class, "name").orElseThrow());
             getParameter(req, Integer.class, "ordinal").ifPresent(version::setOrdinal);
             version.setStatus(VersionStatus.valueOf(getParameter(req, String.class, "status").orElseThrow()));
             dao.getVersionDao().saveOrUpdate(version);
 
-            setRedirectLocation(req, "./projects/versions/");
+            // specifying the pid parameter will purposely reset the session selected version!
+            setRedirectLocation(req, "./projects/view?pid="+sessionSelection.project.getId());
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-            LOG.debug("Successfully updated version {} for project {}", version.getName(), selectedProject.getName());
-        } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
+            LOG.debug("Successfully updated version {} for project {}", version.getName(), sessionSelection.project.getName());
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
             // TODO: set request attribute with error text
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
-            configureEditVersionForm(req, Optional.ofNullable(version), selectedProject);
+            sessionSelection.selectVersion(version);
+            configureEditVersionForm(req, sessionSelection);
         }
 
         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)));
+    private void configureEditIssueForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException {
+        req.setAttribute("issue", selection.issue);
         req.setAttribute("issueStatusEnum", IssueStatus.values());
         req.setAttribute("issueCategoryEnum", IssueCategory.values());
-        req.setAttribute("versions", dao.getVersionDao().list(selectedProject));
+        req.setAttribute("versions", dao.getVersionDao().list(selection.project));
+        req.setAttribute("users", dao.getUserDao().list());
 
         setContentPage(req, "issue-form");
+        setBreadcrumbs(req, getBreadcrumbs(3, selection));
     }
 
     @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);
-        if (selectedProject == null) {
+        final var sessionSelection = new SessionSelection(req, dao);
+        if (sessionSelection.project == null) {
+            // TODO: remove this bullshit and only retrieve the object from session if we are creating a fresh issue
             resp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return ResponseType.NONE;
         }
 
-        configureEditIssueForm(req, dao,
-                findByParameter(req, Integer.class, "id", dao.getIssueDao()::find),
-                selectedProject);
+        sessionSelection.selectIssue(findByParameter(req, Integer.class, "id",
+                dao.getIssueDao()::find).orElse(new Issue(-1, sessionSelection.project)));
+        configureEditIssueForm(req, dao, sessionSelection);
 
         return ResponseType.HTML;
     }
 
     @RequestMapping(requestPath = "issues/commit", method = HttpMethod.POST)
     public ResponseType commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException {
-        final var selectedProject = getSelectedProject(req, dao);
-        if (selectedProject == null) {
+        // TODO: remove this bullshit and store the project ID as hidden field
+        final var sessionSelection = new SessionSelection(req, dao);
+        if (sessionSelection.project == null) {
             resp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return ResponseType.NONE;
         }
 
-        Issue issue = null;
+        Issue issue = new Issue(-1, sessionSelection.project);
         try {
-            issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow(), selectedProject);
-
-            // TODO: implement
-
+            issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow(), sessionSelection.project);
+            getParameter(req, String.class, "category").map(IssueCategory::valueOf).ifPresent(issue::setCategory);
+            getParameter(req, String.class, "status").map(IssueStatus::valueOf).ifPresent(issue::setStatus);
+            issue.setSubject(getParameter(req, String.class, "subject").orElseThrow());
+            getParameter(req, Integer.class, "assignee").map(
+                    userid -> userid >= 0 ? new User(userid) : null
+            ).ifPresent(issue::setAssignee);
+            getParameter(req, String.class, "description").ifPresent(issue::setDescription);
+            getParameter(req, Date.class, "eta").ifPresent(issue::setEta);
             dao.getIssueDao().saveOrUpdate(issue);
 
-            setRedirectLocation(req, "./projects/issues/");
+            // TODO: redirect to issue overview
+            // specifying the issue parameter keeps the edited issue as breadcrumb
+            setRedirectLocation(req, "./projects/view?issue="+issue.getId());
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-            LOG.debug("Successfully updated issue {} for project {}", issue.getId(), selectedProject.getName());
-        } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
+            LOG.debug("Successfully updated issue {} for project {}", issue.getId(), sessionSelection.project.getName());
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
             // TODO: set request attribute with error text
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
-            configureEditIssueForm(req, dao, Optional.ofNullable(issue), selectedProject);
+            sessionSelection.selectIssue(issue);
+            configureEditIssueForm(req, dao, sessionSelection);
         }
 
         return ResponseType.HTML;
--- a/src/main/java/de/uapcore/lightpit/modules/UsersModule.java	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/modules/UsersModule.java	Fri May 22 21:23:57 2020 +0200
@@ -91,7 +91,7 @@
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
 
             LOG.debug("Successfully updated user {}", user.getUsername());
-        } catch (NoSuchElementException | NumberFormatException | SQLException ex) {
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
             // TODO: set request attribute with error text
             req.setAttribute("user", user);
             setContentPage(req, "user-form");
--- a/src/main/resources/localization/projects.properties	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/resources/localization/projects.properties	Fri May 22 21:23:57 2020 +0200
@@ -29,6 +29,8 @@
 
 no-projects=Welcome to LightPIT. Start off by creating a new project!
 
+menu.issues=Issues
+
 thead.name=Name
 thead.description=Description
 thead.repoUrl=Repository
@@ -40,6 +42,7 @@
 tooltip.ordinal=Use to override lexicographic ordering.
 
 placeholder.null-owner=Unassigned
+placeholder.null-assignee=Unassigned
 
 version.status.Future=Future
 version.status.Unreleased=Unreleased
@@ -48,8 +51,31 @@
 version.status.Deprecated=Deprecated
 
 thead.issue.subject=Subject
+thead.issue.description=Description
+thead.issue.assignee=Assignee
+thead.issue.affected-version=Affected Version
+thead.issue.affected-versions=Affected Versions
+thead.issue.scheduled-version=Scheduled for Version
+thead.issue.scheduled-versions=Scheduled for Versions
+thead.issue.resolved-version=Resolved in Version
+thead.issue.resolved-versions=Resolved in Versions
 thead.issue.category=Category
 thead.issue.status=Status
 thead.issue.created=Created
 thead.issue.updated=Updated
 thead.issue.eta=ETA
+
+issue.category.Feature=Feature
+issue.category.Improvement=Improvement
+issue.category.Bug=Bug
+issue.category.Task=Task
+issue.category.Test=Test
+
+issue.status.InSpecification=Specification
+issue.status.ToDo=To Do
+issue.status.Scheduled=Scheduled
+issue.status.InProgress=In Progress
+issue.status.InReview=Review
+issue.status.Done=Done
+issue.status.Rejected=Rejected
+issue.status.Withdrawn=Withdrawn
--- a/src/main/resources/localization/projects_de.properties	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/resources/localization/projects_de.properties	Fri May 22 21:23:57 2020 +0200
@@ -29,6 +29,8 @@
 
 no-projects=Wilkommen bei LightPIT. Beginnen Sie mit der Erstellung eines Projektes!
 
+menu.issues=Vorg\u00e4nge
+
 thead.name=Name
 thead.description=Beschreibung
 thead.repoUrl=Repository
@@ -40,6 +42,7 @@
 tooltip.ordinal=\u00dcbersteuert die lexikographische Sortierung.
 
 placeholder.null-owner=Nicht Zugewiesen
+placeholder.null-assignee=Niemandem
 
 version.status.Future=Geplant
 version.status.Unreleased=Unver\u00f6ffentlicht
@@ -48,8 +51,31 @@
 version.status.Deprecated=Veraltet
 
 thead.issue.subject=Thema
+thead.issue.description=Beschreibung
+thead.issue.assignee=Zugewiesen
+thead.issue.affected-version=Betroffene Version
+thead.issue.affected-versions=Betroffene Versionen
+thead.issue.scheduled-version=Zielversion
+thead.issue.scheduled-versions=Zielversionen
+thead.issue.resolved-version=Gel\u00f6st in Version
+thead.issue.resolved-versions=Gel\u00f6st in Versionen
 thead.issue.category=Kategorie
 thead.issue.status=Status
 thead.issue.created=Erstellt
 thead.issue.updated=Aktualisiert
 thead.issue.eta=Zieldatum
+
+issue.category.Feature=Feature
+issue.category.Improvement=Verbesserung
+issue.category.Bug=Fehler
+issue.category.Task=Aufgabe
+issue.category.Test=Test
+
+issue.status.InSpecification=Spezifikation
+issue.status.ToDo=Zu Erledigen
+issue.status.Scheduled=Geplant
+issue.status.InProgress=In Arbeit
+issue.status.InReview=Im Review
+issue.status.Done=Erledigt
+issue.status.Rejected=Zur\u00fcckgewiesen
+issue.status.Withdrawn=Zur\u00fcckgezogen
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Fri May 22 21:23:57 2020 +0200
@@ -0,0 +1,162 @@
+<%--
+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" %>
+<%@page import="de.uapcore.lightpit.Constants" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<c:set scope="page" var="moduleInfo" value="${requestScope[Constants.REQ_ATTR_MODULE_INFO]}"/>
+
+<jsp:useBean id="issue" type="de.uapcore.lightpit.entities.Issue" scope="request"/>
+<jsp:useBean id="issueStatusEnum" type="de.uapcore.lightpit.entities.IssueStatus[]" scope="request"/>
+<jsp:useBean id="issueCategoryEnum" type="de.uapcore.lightpit.entities.IssueCategory[]" scope="request"/>
+<jsp:useBean id="versions" type="java.util.List<de.uapcore.lightpit.entities.Version>" scope="request"/>
+<jsp:useBean id="users" type="java.util.List<de.uapcore.lightpit.entities.User>" scope="request"/>
+
+<form action="./${moduleInfo.modulePath}/issues/commit" method="post">
+    <table class="formtable">
+        <colgroup>
+            <col>
+            <col style="width: 75ch">
+        </colgroup>
+        <tbody>
+        <tr>
+            <th><fmt:message key="thead.issue.category"/></th>
+            <td>
+                <select name="category">
+                    <c:forEach var="category" items="${issueCategoryEnum}">
+                        <option
+                                <c:if test="${category eq issue.category}">selected</c:if>
+                                value="${category}">
+                            <fmt:message key="issue.category.${category}" />
+                        </option>
+                    </c:forEach>
+                </select>
+            </td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.status"/></th>
+            <td>
+                <select name="status">
+                    <c:forEach var="status" items="${issueStatusEnum}">
+                        <option
+                                <c:if test="${status eq issue.status}">selected</c:if>
+                                value="${status}">
+                                <fmt:message key="issue.status.${status}" />
+                        </option>
+                    </c:forEach>
+                </select>
+            </td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.subject"/></th>
+            <td><input name="subject" type="text" maxlength="20" required value="<c:out value="${issue.subject}"/>" /></td>
+        </tr>
+        <tr>
+            <th class="vtop"><fmt:message key="thead.issue.description"/></th>
+            <td>
+                <textarea name="description"><c:out value="${issue.description}"/></textarea>
+            </td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.assignee"/></th>
+            <td>
+                <select name="assignee">
+                    <option value="-1"><fmt:message key="placeholder.null-assignee"/></option>
+                    <c:forEach var="user" items="${users}">
+                        <option
+                                <c:if test="${not empty issue.assignee and user eq issue.assignee}">selected</c:if>
+                                value="${user.id}"><c:out value="${user.displayname}"/></option>
+                    </c:forEach>
+                </select>
+            </td>
+        </tr>
+        <tr>
+            <th>
+                <c:choose>
+                    <c:when test="${issue.affectedVersions.size() gt 1}">
+                        <fmt:message key="thead.issue.affected-versions"/>
+                    </c:when>
+                    <c:otherwise>
+                        <fmt:message key="thead.issue.affected-version"/>
+                    </c:otherwise>
+                </c:choose>
+            </th>
+            <td>TODO</td>
+        </tr>
+        <tr>
+            <th>
+                <c:choose>
+                    <c:when test="${issue.scheduledVersions.size() gt 1}">
+                        <fmt:message key="thead.issue.scheduled-versions"/>
+                    </c:when>
+                    <c:otherwise>
+                        <fmt:message key="thead.issue.scheduled-version"/>
+                    </c:otherwise>
+                </c:choose>
+            </th>
+            <td>TODO</td>
+        </tr>
+        <tr>
+            <th>
+                <c:choose>
+                    <c:when test="${issue.resolvedVersions.size() gt 1}">
+                        <fmt:message key="thead.issue.resolved-versions"/>
+                    </c:when>
+                    <c:otherwise>
+                        <fmt:message key="thead.issue.resolved-version"/>
+                    </c:otherwise>
+                </c:choose>
+            </th>
+            <td>TODO</td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.eta"/></th>
+            <td><input name="eta" type="date" value="<fmt:formatDate value="${issue.eta}" pattern="YYYY-MM-dd" />" /> </td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.created"/></th>
+            <td><fmt:formatDate value="${issue.created}" /></td>
+        </tr>
+        <tr>
+            <th><fmt:message key="thead.issue.updated"/></th>
+            <td><fmt:formatDate value="${issue.updated}" /></td>
+        </tr>
+        </tbody>
+        <tfoot>
+        <tr>
+            <td colspan="2">
+                <input type="hidden" name="id" value="${issue.id}"/>
+                <a href="./${moduleInfo.modulePath}/view?pid=${issue.project.id}" 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>
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Fri May 22 21:23:57 2020 +0200
@@ -66,8 +66,8 @@
 <table id="issue-list" class="datatable medskip">
     <thead>
     <tr>
-        <th></th>
         <th><fmt:message key="thead.issue.subject"/></th>
+        <th><fmt:message key="thead.issue.assignee"/></th>
         <th><fmt:message key="thead.issue.category"/></th>
         <th><fmt:message key="thead.issue.status"/></th>
         <th><fmt:message key="thead.issue.created"/></th>
@@ -76,5 +76,38 @@
         <!-- TODO: add other information -->
     </tr>
     </thead>
-    <!-- TODO: add actual list -->
+    <tbody>
+    <c:forEach var="issue" items="${issues}">
+        <tr>
+            <td>
+                <a href="./projects/issues/edit?id=${issue.id}">
+                <c:out value="${issue.subject}" />
+                </a>
+            </td>
+            <td>
+                <c:if test="${not empty issue.assignee}">
+                    <c:out value="${issue.assignee.shortDisplayname}" />
+                </c:if>
+                <c:if test="${empty issue.assignee}">
+                    <fmt:message key="placeholder.null-assignee" />
+                </c:if>
+            </td>
+            <td>
+                <fmt:message key="issue.category.${issue.category}" />
+            </td>
+            <td>
+                <fmt:message key="issue.status.${issue.status}" />
+            </td>
+            <td>
+                <fmt:formatDate value="${issue.created}" type="BOTH"/>
+            </td>
+            <td>
+                <fmt:formatDate value="${issue.updated}" type="BOTH"/>
+            </td>
+            <td>
+                <fmt:formatDate value="${issue.eta}" />
+            </td>
+        </tr>
+    </c:forEach>
+    </tbody>
 </table>
--- a/src/main/webapp/WEB-INF/jsp/project-form.jsp	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/project-form.jsp	Fri May 22 21:23:57 2020 +0200
@@ -43,15 +43,15 @@
         <tbody>
         <tr>
             <th><fmt:message key="thead.name"/></th>
-            <td><input name="name" type="text" maxlength="20" required value="${project.name}"/></td>
+            <td><input name="name" type="text" maxlength="20" required value="<c:out value="${project.name}"/>" /></td>
         </tr>
         <tr>
             <th class="vtop"><fmt:message key="thead.description"/></th>
-            <td><input type="text" name="description" maxlength="200" value="${project.description}"/></td>
+            <td><input type="text" name="description" maxlength="200" value="<c:out value="${project.description}"/>" /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.repoUrl"/></th>
-            <td><input name="repoUrl" type="url" maxlength="50" value="${project.repoUrl}"/></td>
+            <td><input name="repoUrl" type="url" maxlength="50" value="<c:out value="${project.repoUrl}"/>" /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.owner"/></th>
@@ -60,7 +60,7 @@
                     <option value="-1"><fmt:message key="placeholder.null-owner"/></option>
                     <c:forEach var="user" items="${users}">
                         <option
-                                <c:if test="${not empty project.owner and user.id eq project.owner.id}">selected</c:if>
+                                <c:if test="${not empty project.owner and user eq project.owner}">selected</c:if>
                                 value="${user.id}"><c:out value="${user.displayname}"/></option>
                     </c:forEach>
                 </select>
--- a/src/main/webapp/WEB-INF/jsp/user-form.jsp	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/user-form.jsp	Fri May 22 21:23:57 2020 +0200
@@ -42,20 +42,20 @@
         <tbody>
         <tr>
             <th><fmt:message key="thead.username"/></th>
-            <td><input name="username" type="text" maxlength="50" required value="${user.username}"
+            <td><input name="username" type="text" maxlength="50" required value="<c:out value="${user.username}"/>"
                        <c:if test="${user.id ge 0}">readonly</c:if> /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.givenname"/></th>
-            <td><input name="givenname" type="text" maxlength="50" value="${user.givenname}"/></td>
+            <td><input name="givenname" type="text" maxlength="50" value="<c:out value="${user.givenname}"/>" /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.lastname"/></th>
-            <td><input name="lastname" type="text" maxlength="50" value="${user.lastname}"/></td>
+            <td><input name="lastname" type="text" maxlength="50" value="<c:out value="${user.lastname}"/>" /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.mail"/></th>
-            <td><input name="mail" type="email" maxlength="50" value="${user.mail}"/></td>
+            <td><input name="mail" type="email" maxlength="50" value="<c:out value="${user.mail}"/>" /></td>
         </tr>
         </tbody>
         <tfoot>
--- a/src/main/webapp/WEB-INF/jsp/version-form.jsp	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/version-form.jsp	Fri May 22 21:23:57 2020 +0200
@@ -44,7 +44,7 @@
         <tbody>
         <tr>
             <th><fmt:message key="thead.version.name"/></th>
-            <td><input name="name" type="text" maxlength="20" required value="${version.name}"/></td>
+            <td><input name="name" type="text" maxlength="20" required value="<c:out value="${version.name}"/>" /></td>
         </tr>
         <tr>
             <th><fmt:message key="thead.version.status"/></th>
@@ -69,8 +69,9 @@
         <tr>
             <td colspan="2">
                 <input type="hidden" name="id" value="${version.id}"/>
-                <a href="./${moduleInfo.modulePath}/versions/" class="button"><fmt:message bundle="${lightpit_bundle}"
-                                                                                           key="button.cancel"/></a>
+                <a href="./${moduleInfo.modulePath}/view?pid=${version.project.id}" 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>
--- a/src/main/webapp/lightpit.css	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/lightpit.css	Fri May 22 21:23:57 2020 +0200
@@ -158,6 +158,10 @@
     width: 100%;
 }
 
+table.formtable input[type=date] {
+    width: auto;
+}
+
 table.formtable tfoot td {
     text-align: right;
 }
--- a/src/main/webapp/projects.css	Fri May 22 17:26:27 2020 +0200
+++ b/src/main/webapp/projects.css	Fri May 22 21:23:57 2020 +0200
@@ -26,3 +26,7 @@
  * POSSIBILITY OF SUCH DAMAGE.
  *
  */
+
+#issue-list td {
+    white-space: nowrap;
+}
\ No newline at end of file

mercurial