Fri, 09 Oct 2020 19:07:05 +0200
adds issue comments
--- 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; +}