adds issue comments

Fri, 09 Oct 2020 19:07:05 +0200

author
Mike Becker <universe@uap-core.de>
date
Fri, 09 Oct 2020 19:07:05 +0200
changeset 124
ed2e7aef2a3e
parent 123
c27eee1259bd
child 125
decc4c3854a1

adds issue comments

setup/postgres/psql_create_tables.sql 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/UserDao.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/PGUserDao.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/entities/IssueComment.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/IssueEditView.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/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 Oct 09 19:06:51 2020 +0200
+++ b/setup/postgres/psql_create_tables.sql	Fri Oct 09 19:07:05 2020 +0200
@@ -83,4 +83,12 @@
     primary key (issueid, versionid)
 );
 
-
+create table lpit_issue_comment (
+    commentid       serial          primary key,
+    issueid         integer         not null references lpit_issue(issueid),
+    userid          integer         references lpit_user(userid),
+    created         timestamp       with time zone not null default now(),
+    updated         timestamp       with time zone not null default now(),
+    updatecount     integer         not null default 0,
+    comment         text            not null
+);
--- a/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/IssueDao.java	Fri Oct 09 19:07:05 2020 +0200
@@ -29,6 +29,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;
 
@@ -59,6 +60,24 @@
     List<Issue> list(Version version) throws SQLException;
 
     /**
+     * Lists all comments for a specific issue in chronological order.
+     *
+     * @param issue the issue
+     * @return the list of comments
+     * @throws SQLException on any kind of SQL error
+     */
+    List<IssueComment> listComments(Issue issue) throws SQLException;
+
+    /**
+     * Stores the specified comment in database.
+     * This is an update-or-insert operation.
+     *
+     * @param comment the comment to save
+     * @throws SQLException on any kind of SQL error
+     */
+    void saveComment(IssueComment comment) throws SQLException;
+
+    /**
      * Saves an instances to the database.
      * Implementations of this DAO must guarantee that the generated ID is stored in the instance.
      *
--- a/src/main/java/de/uapcore/lightpit/dao/UserDao.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/UserDao.java	Fri Oct 09 19:07:05 2020 +0200
@@ -32,8 +32,19 @@
 
 import java.sql.SQLException;
 import java.util.List;
+import java.util.Optional;
 
 public interface UserDao extends GenericDao<User> {
 
     List<User> list() throws SQLException;
+
+    /**
+     * Tries to find a user by their username.
+     * The search is case-insensitive.
+     *
+     * @param username the username
+     * @return the user object or an empty optional if no such user exists
+     * @throws SQLException
+     */
+    Optional<User> findByUsername(String username) throws SQLException;
 }
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGIssueDao.java	Fri Oct 09 19:07:05 2020 +0200
@@ -47,6 +47,7 @@
     private final PreparedStatement affectedVersions, resolvedVersions;
     private final PreparedStatement clearAffected, clearResolved;
     private final PreparedStatement insertAffected, insertResolved;
+    private final PreparedStatement insertComment, updateComment, listComments;
 
     public PGIssueDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
@@ -107,9 +108,19 @@
         );
         clearResolved = connection.prepareStatement("delete from lpit_issue_resolved_version where issueid = ?");
         insertResolved = connection.prepareStatement("insert into lpit_issue_resolved_version (issueid, versionid) values (?,?)");
+
+        insertComment = connection.prepareStatement(
+                "insert into lpit_issue_comment (issueid, comment, userid) values (?, ? ,?)"
+        );
+        updateComment = connection.prepareStatement(
+                "update lpit_issue_comment set comment = ?, updated = now(), updatecount = updatecount+1 where commentid = ?"
+        );
+        listComments = connection.prepareStatement(
+                "select * from lpit_issue_comment left join lpit_user using (userid) where issueid = ? order by created"
+        );
     }
 
-    private User obtainAssignee(ResultSet result) throws SQLException {
+    private User obtainUser(ResultSet result) throws SQLException {
         final int id = result.getInt("userid");
         if (id != 0) {
             final var user = new User(id);
@@ -132,7 +143,7 @@
         issue.setCategory(IssueCategory.valueOf(result.getString("category")));
         issue.setSubject(result.getString("subject"));
         issue.setDescription(result.getString("description"));
-        issue.setAssignee(obtainAssignee(result));
+        issue.setAssignee(obtainUser(result));
         issue.setCreated(result.getTimestamp("created"));
         issue.setUpdated(result.getTimestamp("updated"));
         issue.setEta(result.getDate("eta"));
@@ -252,4 +263,38 @@
         issue.setAffectedVersions(listVersions(affectedVersions, issue));
         issue.setResolvedVersions(listVersions(resolvedVersions, issue));
     }
+
+    @Override
+    public List<IssueComment> listComments(Issue issue) throws SQLException {
+        listComments.setInt(1, issue.getId());
+        List<IssueComment> comments = new ArrayList<>();
+        try (var result = listComments.executeQuery()) {
+            while (result.next()) {
+                final var comment = new IssueComment(result.getInt("commentid"), issue);
+                comment.setCreated(result.getTimestamp("created"));
+                comment.setUpdated(result.getTimestamp("updated"));
+                comment.setUpdateCount(result.getInt("updatecount"));
+                comment.setComment(result.getString("comment"));
+                comment.setAuthor(obtainUser(result));
+                comments.add(comment);
+            }
+        }
+        return comments;
+    }
+
+    @Override
+    public void saveComment(IssueComment comment) throws SQLException {
+        Objects.requireNonNull(comment.getComment());
+        Objects.requireNonNull(comment.getIssue());
+        if (comment.getId() >= 0) {
+            updateComment.setString(1, comment.getComment());
+            updateComment.setInt(2, comment.getId());
+            updateComment.execute();
+        } else {
+            insertComment.setInt(1, comment.getIssue().getId());
+            insertComment.setString(2, comment.getComment());
+            setForeignKeyOrNull(insertComment, 3, comment.getAuthor(), User::getId);
+            insertComment.execute();
+        }
+    }
 }
--- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGUserDao.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGUserDao.java	Fri Oct 09 19:07:05 2020 +0200
@@ -38,12 +38,13 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
 import static de.uapcore.lightpit.dao.Functions.setStringOrNull;
 
 public final class PGUserDao implements UserDao {
 
-    private final PreparedStatement insert, update, list, find;
+    private final PreparedStatement insert, update, list, find, findByUsername;
 
     public PGUserDao(Connection connection) throws SQLException {
         list = connection.prepareStatement(
@@ -54,6 +55,10 @@
                 "select userid, username, lastname, givenname, mail " +
                         "from lpit_user where userid = ? ");
 
+        findByUsername = connection.prepareStatement(
+                "select userid, username, lastname, givenname, mail " +
+                        "from lpit_user where lower(username) = lower(?) ");
+
         insert = connection.prepareStatement("insert into lpit_user (username, lastname, givenname, mail) values (?, ?, ?, ?)");
         update = connection.prepareStatement("update lpit_user set lastname = ?, givenname = ?, mail = ? where userid = ?");
     }
@@ -111,4 +116,16 @@
             }
         }
     }
+
+    @Override
+    public Optional<User> findByUsername(String username) throws SQLException {
+        findByUsername.setString(1, username);
+        try (var result = findByUsername.executeQuery()) {
+            if (result.next()) {
+                return Optional.of(mapColumns(result));
+            } else {
+                return Optional.empty();
+            }
+        }
+    }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/java/de/uapcore/lightpit/entities/IssueComment.java	Fri Oct 09 19:07:05 2020 +0200
@@ -0,0 +1,85 @@
+package de.uapcore.lightpit.entities;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Objects;
+
+public class IssueComment {
+
+    private final Issue issue;
+    private final int commentid;
+
+    private User author;
+    private String comment;
+
+    private Timestamp created = Timestamp.from(Instant.now());
+    private Timestamp updated = Timestamp.from(Instant.now());
+    private int updatecount = 0;
+
+
+    public IssueComment(int id, Issue issue) {
+        this.commentid = id;
+        this.issue = issue;
+    }
+
+    public Issue getIssue() {
+        return issue;
+    }
+
+    public int getId() {
+        return commentid;
+    }
+
+    public User getAuthor() {
+        return author;
+    }
+
+    public void setAuthor(User author) {
+        this.author = author;
+    }
+
+    public String getComment() {
+        return comment;
+    }
+
+    public void setComment(String comment) {
+        this.comment = comment;
+    }
+
+    public Timestamp getCreated() {
+        return created;
+    }
+
+    public void setCreated(Timestamp created) {
+        this.created = created;
+    }
+
+    public Timestamp getUpdated() {
+        return updated;
+    }
+
+    public void setUpdated(Timestamp updated) {
+        this.updated = updated;
+    }
+
+    public int getUpdateCount() {
+        return updatecount;
+    }
+
+    public void setUpdateCount(int updatecount) {
+        this.updatecount = updatecount;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        IssueComment that = (IssueComment) o;
+        return commentid == that.commentid;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(commentid);
+    }
+}
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Fri Oct 09 19:07:05 2020 +0200
@@ -282,11 +282,15 @@
         viewModel.setIssue(issue);
         viewModel.configureVersionSelectors(viewModel.getProjectInfo().getVersions());
         viewModel.setUsers(dao.getUserDao().list());
+        if (issue.getId() >= 0) {
+            viewModel.setComments(dao.getIssueDao().listComments(issue));
+        }
     }
 
     @RequestMapping(requestPath = "issues/edit", method = HttpMethod.GET)
     public ResponseType editIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException {
         final var viewModel = new IssueEditView();
+        populate(viewModel, req, dao);
 
         final var issueParam = getParameter(req, Integer.class, "issue");
         if (issueParam.isPresent()) {
@@ -294,10 +298,8 @@
             final var issue = issueDao.find(issueParam.get());
             issueDao.joinVersionInformation(issue);
             req.getSession().setAttribute(SESSION_ATTR_SELECTED_PROJECT, issue.getProject().getId());
-            populate(viewModel, req, dao);
             configure(viewModel, issue, dao);
         } else {
-            populate(viewModel, req, dao);
             configure(viewModel, new Issue(-1), dao);
         }
 
@@ -305,7 +307,7 @@
     }
 
     @RequestMapping(requestPath = "issues/commit", method = HttpMethod.POST)
-    public ResponseType commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException {
+    public ResponseType commitIssue(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
         Issue issue = new Issue(-1);
         try {
             issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow());
@@ -335,16 +337,57 @@
             // specifying the issue parameter keeps the edited issue as menu item
             setRedirectLocation(req, "./projects/view?pid=" + issue.getProject().getId());
             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
+
+            return ResponseType.HTML;
         } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
             // TODO: set request attribute with error text
             LOG.warn("Form validation failure: {}", ex.getMessage());
             LOG.debug("Details:", ex);
             final var viewModel = new IssueEditView();
+            populate(viewModel, req, dao);
             configure(viewModel, issue, dao);
             // TODO: set Error Text
             return forwardView(req, viewModel, "issue-form");
         }
+    }
 
-        return ResponseType.HTML;
+    @RequestMapping(requestPath = "issues/comment", method = HttpMethod.POST)
+    public ResponseType commentIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException {
+        final var issueIdParam = getParameter(req, Integer.class, "issueid");
+        if (issueIdParam.isEmpty()) {
+            resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Detected manipulated form.");
+            return ResponseType.NONE;
+        }
+        final var issue = new Issue(issueIdParam.get());
+        try {
+            final var issueComment = new IssueComment(getParameter(req, Integer.class, "commentid").orElse(-1), issue);
+            issueComment.setComment(getParameter(req, String.class, "comment").orElse(""));
+
+            if (issueComment.getComment().isBlank()) {
+                throw new IllegalArgumentException("comment.null");
+            }
+
+            LOG.debug("User {} is commenting on issue #{}", req.getRemoteUser(), issue.getId());
+            if (req.getRemoteUser() != null) {
+                dao.getUserDao().findByUsername(req.getRemoteUser()).ifPresent(issueComment::setAuthor);
+            }
+
+            dao.getIssueDao().saveComment(issueComment);
+
+            // specifying the issue parameter keeps the edited issue as menu item
+            setRedirectLocation(req, "./projects/issues/edit?issue=" + issue.getId());
+            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
+
+            return ResponseType.HTML;
+        } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
+            // TODO: set request attribute with error text
+            LOG.warn("Form validation failure: {}", ex.getMessage());
+            LOG.debug("Details:", ex);
+            final var viewModel = new IssueEditView();
+            populate(viewModel, req, dao);
+            configure(viewModel, issue, dao);
+            // TODO: set Error Text
+            return forwardView(req, viewModel, "issue-form");
+        }
     }
 }
--- a/src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java	Fri Oct 09 19:07:05 2020 +0200
@@ -11,6 +11,7 @@
     private Set<Version> versionsUpcoming = new HashSet<>();
     private Set<Version> versionsRecent = new HashSet<>();
     private List<User> users;
+    private List<IssueComment> comments;
 
     public void setIssue(Issue issue) {
         this.issue = issue;
@@ -66,4 +67,12 @@
     public IssueCategory[] getIssueCategory() {
         return IssueCategory.values();
     }
+
+    public List<IssueComment> getComments() {
+        return comments;
+    }
+
+    public void setComments(List<IssueComment> comments) {
+        this.comments = comments;
+    }
 }
--- a/src/main/resources/localization/projects.properties	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/resources/localization/projects.properties	Fri Oct 09 19:07:05 2020 +0200
@@ -28,6 +28,7 @@
 button.version.edit=Edit Version
 button.issue.create=New Issue
 button.issue.all=All Issues
+button.comment=Comment
 
 no-projects=Welcome to LightPIT. Start off by creating a new project!
 
@@ -95,3 +96,6 @@
 issue.status.Rejected=Rejected
 issue.status.Withdrawn=Withdrawn
 issue.status.Duplicate=Duplicate
+
+issue.comments=Comments
+issue.comments.anonauthor=Anonymous Author
--- a/src/main/resources/localization/projects_de.properties	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/resources/localization/projects_de.properties	Fri Oct 09 19:07:05 2020 +0200
@@ -28,6 +28,7 @@
 button.version.edit=Version Bearbeiten
 button.issue.create=Neuer Vorgang
 button.issue.all=Alle Vorg\u00e4nge
+button.comment=Kommentieren
 
 no-projects=Wilkommen bei LightPIT. Beginnen Sie mit der Erstellung eines Projektes!
 
@@ -94,3 +95,6 @@
 issue.status.Rejected=Zur\u00fcckgewiesen
 issue.status.Withdrawn=Zur\u00fcckgezogen
 issue.status.Duplicate=Duplikat
+
+issue.comments=Kommentare
+issue.comments.anonauthor=Anonymer Autor
\ No newline at end of file
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Fri Oct 09 19:07:05 2020 +0200
@@ -32,10 +32,10 @@
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 
 <form action="./projects/issues/commit" method="post">
-    <table class="formtable">
+    <table class="formtable fullwidth">
         <colgroup>
             <col>
-            <col style="width: 75ch">
+            <col style="width: 100%">
         </colgroup>
         <tbody>
         <c:if test="${viewmodel.issue.id ge 0}">
@@ -169,3 +169,46 @@
         </tfoot>
     </table>
 </form>
+<hr class="comments-separator"/>
+<h2><fmt:message key="issue.comments"/></h2>
+<c:if test="${viewmodel.issue.id ge 0}">
+<form id="comment-form" action="./projects/issues/comment" method="post">
+    <table class="formtable fullwidth">
+        <tbody>
+            <tr>
+                <td><textarea rows="5" name="comment"></textarea></td>
+            </tr>
+        </tbody>
+        <tfoot>
+            <tr>
+                <td>
+                    <input type="hidden" name="issueid" value="${issue.id}"/>
+                    <button type="submit"><fmt:message key="button.comment"/></button>
+                </td>
+            </tr>
+        </tfoot>
+    </table>
+</form>
+    <c:forEach var="comment" items="${viewmodel.comments}">
+        <div class="comment">
+            <div class="caption">
+                <c:if test="${not empty comment.author}">
+                    <c:out value="${comment.author.displayname}"/>
+                </c:if>
+                <c:if test="${empty comment.author}">
+                    <fmt:message key="issue.comments.anonauthor"/>
+                </c:if>
+            </div>
+            <div class="smalltext">
+                <fmt:formatDate type="BOTH" value="${comment.created}" />
+                <c:if test="${comment.updateCount gt 0}">
+                    <!-- TODO: update count -->
+                </c:if>
+            </div>
+            <div class="medskip">
+                <c:out value="${comment.comment}"/>
+            </div>
+        </div>
+    </c:forEach>
+</c:if>
+
--- a/src/main/webapp/lightpit.css	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/webapp/lightpit.css	Fri Oct 09 19:07:05 2020 +0200
@@ -65,7 +65,7 @@
     flex-flow: column;
     position: fixed;
     height: 100%;
-    width: 30ch;
+    width: 40ch; /* adjust with sidebar-spacing.margin-left */
     padding-top: 2.25rem;
     color: #3060f8;
     border-image-source: linear-gradient(to bottom, #606060, rgba(60, 60, 60, .25));
@@ -75,7 +75,7 @@
 }
 
 #content-area.sidebar-spacing {
-    margin-left: 30ch;
+    margin-left: 40ch; /* adjust with sideMenu.width */
 }
 
 #mainMenu {
@@ -197,6 +197,7 @@
 
 table.formtable tbody td > * {
     width: 100%;
+    box-sizing: border-box;
 }
 
 table.formtable input[type=date] {
--- a/src/main/webapp/projects.css	Fri Oct 09 19:06:51 2020 +0200
+++ b/src/main/webapp/projects.css	Fri Oct 09 19:07:05 2020 +0200
@@ -137,3 +137,15 @@
     color: lightgray;
     background: darkgray;
 }
+
+hr.comments-separator {
+    border-image-source: linear-gradient(to right, rgba(60, 60, 60, .1), rgba(96, 96, 96, 1), rgba(60, 60, 60, .1));
+    border-image-slice: 1;
+    border-width: 1pt;
+    border-style: none;
+    border-top-style: solid;
+}
+
+div.comment {
+    margin-bottom: 1.25em;
+}

mercurial