Sun, 24 May 2020 15:30:43 +0200
adds project overview page
1.1 --- a/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java Sat May 23 14:13:09 2020 +0200 1.2 +++ b/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java Sun May 24 15:30:43 2020 +0200 1.3 @@ -282,6 +282,13 @@ 1.4 protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { 1.5 final String paramValue = req.getParameter(name); 1.6 if (paramValue == null) return Optional.empty(); 1.7 + if (clazz.equals(Boolean.class)) { 1.8 + if (paramValue.toLowerCase().equals("false") || paramValue.equals("0")) { 1.9 + return Optional.of((T)Boolean.FALSE); 1.10 + } else { 1.11 + return Optional.of((T)Boolean.TRUE); 1.12 + } 1.13 + } 1.14 if (clazz.equals(String.class)) return Optional.of((T) paramValue); 1.15 if (java.sql.Date.class.isAssignableFrom(clazz)) { 1.16 try {
2.1 --- a/src/main/java/de/uapcore/lightpit/dao/VersionDao.java Sat May 23 14:13:09 2020 +0200 2.2 +++ b/src/main/java/de/uapcore/lightpit/dao/VersionDao.java Sun May 24 15:30:43 2020 +0200 2.3 @@ -30,6 +30,7 @@ 2.4 2.5 import de.uapcore.lightpit.entities.Project; 2.6 import de.uapcore.lightpit.entities.Version; 2.7 +import de.uapcore.lightpit.entities.VersionStatistics; 2.8 2.9 import java.sql.SQLException; 2.10 import java.util.List; 2.11 @@ -44,4 +45,31 @@ 2.12 * @throws SQLException on any kind of SQL error 2.13 */ 2.14 List<Version> list(Project project) throws SQLException; 2.15 + 2.16 + /** 2.17 + * Retrieves statistics about issues that arose in a version. 2.18 + * 2.19 + * @param version the version 2.20 + * @return version statistics 2.21 + * @throws SQLException on any kind of SQL error 2.22 + */ 2.23 + VersionStatistics statsOpenedIssues(Version version) throws SQLException; 2.24 + 2.25 + /** 2.26 + * Retrieves statistics about issues that are scheduled for a version. 2.27 + * 2.28 + * @param version the version 2.29 + * @return version statistics 2.30 + * @throws SQLException on any kind of SQL error 2.31 + */ 2.32 + VersionStatistics statsScheduledIssues(Version version) throws SQLException; 2.33 + 2.34 + /** 2.35 + * Retrieves statistics about issues that are resolved in a version. 2.36 + * 2.37 + * @param version the version 2.38 + * @return version statistics 2.39 + * @throws SQLException on any kind of SQL error 2.40 + */ 2.41 + VersionStatistics statsResolvedIssues(Version version) throws SQLException; 2.42 }
3.1 --- a/src/main/java/de/uapcore/lightpit/dao/postgres/PGVersionDao.java Sat May 23 14:13:09 2020 +0200 3.2 +++ b/src/main/java/de/uapcore/lightpit/dao/postgres/PGVersionDao.java Sun May 24 15:30:43 2020 +0200 3.3 @@ -29,9 +29,7 @@ 3.4 package de.uapcore.lightpit.dao.postgres; 3.5 3.6 import de.uapcore.lightpit.dao.VersionDao; 3.7 -import de.uapcore.lightpit.entities.Project; 3.8 -import de.uapcore.lightpit.entities.Version; 3.9 -import de.uapcore.lightpit.entities.VersionStatus; 3.10 +import de.uapcore.lightpit.entities.*; 3.11 3.12 import java.sql.Connection; 3.13 import java.sql.PreparedStatement; 3.14 @@ -44,13 +42,14 @@ 3.15 public final class PGVersionDao implements VersionDao { 3.16 3.17 private final PreparedStatement insert, update, list, find; 3.18 + private final PreparedStatement issuesAffected, issuesScheduled, issuesResolved; 3.19 3.20 public PGVersionDao(Connection connection) throws SQLException { 3.21 list = connection.prepareStatement( 3.22 "select versionid, project, name, ordinal, status " + 3.23 "from lpit_version " + 3.24 "where project = ? " + 3.25 - "order by ordinal, lower(name)"); 3.26 + "order by ordinal desc, lower(name) desc"); 3.27 3.28 find = connection.prepareStatement( 3.29 "select versionid, project, name, ordinal, status " + 3.30 @@ -63,6 +62,28 @@ 3.31 update = connection.prepareStatement( 3.32 "update lpit_version set name = ?, ordinal = ?, status = ?::version_status where versionid = ?" 3.33 ); 3.34 + 3.35 + issuesAffected = connection.prepareStatement( 3.36 + "select category, status, count(*) as issuecount " + 3.37 + "from lpit_issue_affected_version " + 3.38 + "join lpit_issue using (issueid) " + 3.39 + "where versionid = ? " + 3.40 + "group by category, status" 3.41 + ); 3.42 + issuesScheduled = connection.prepareStatement( 3.43 + "select category, status, count(*) as issuecount " + 3.44 + "from lpit_issue_scheduled_version " + 3.45 + "join lpit_issue using (issueid) " + 3.46 + "where versionid = ? " + 3.47 + "group by category, status" 3.48 + ); 3.49 + issuesResolved = connection.prepareStatement( 3.50 + "select category, status, count(*) as issuecount " + 3.51 + "from lpit_issue_resolved_version " + 3.52 + "join lpit_issue using (issueid) " + 3.53 + "where versionid = ? " + 3.54 + "group by category, status" 3.55 + ); 3.56 } 3.57 3.58 private Version mapColumns(ResultSet result) throws SQLException { 3.59 @@ -74,6 +95,20 @@ 3.60 return version; 3.61 } 3.62 3.63 + private VersionStatistics versionStatistics(Version version, PreparedStatement stmt) throws SQLException { 3.64 + stmt.setInt(1, version.getId()); 3.65 + final var result = stmt.executeQuery(); 3.66 + final var stats = new VersionStatistics(version); 3.67 + while (result.next()) { 3.68 + stats.setIssueCount( 3.69 + IssueCategory.valueOf(result.getString("category")), 3.70 + IssueStatus.valueOf(result.getString("status")), 3.71 + result.getInt("issuecount") 3.72 + ); 3.73 + } 3.74 + return stats; 3.75 + } 3.76 + 3.77 @Override 3.78 public void save(Version instance) throws SQLException { 3.79 Objects.requireNonNull(instance.getName()); 3.80 @@ -121,4 +156,19 @@ 3.81 } 3.82 } 3.83 } 3.84 + 3.85 + @Override 3.86 + public VersionStatistics statsOpenedIssues(Version version) throws SQLException { 3.87 + return versionStatistics(version, issuesAffected); 3.88 + } 3.89 + 3.90 + @Override 3.91 + public VersionStatistics statsScheduledIssues(Version version) throws SQLException { 3.92 + return versionStatistics(version, issuesScheduled); 3.93 + } 3.94 + 3.95 + @Override 3.96 + public VersionStatistics statsResolvedIssues(Version version) throws SQLException { 3.97 + return versionStatistics(version, issuesResolved); 3.98 + } 3.99 }
4.1 --- a/src/main/java/de/uapcore/lightpit/entities/IssueStatus.java Sat May 23 14:13:09 2020 +0200 4.2 +++ b/src/main/java/de/uapcore/lightpit/entities/IssueStatus.java Sun May 24 15:30:43 2020 +0200 4.3 @@ -29,12 +29,27 @@ 4.4 package de.uapcore.lightpit.entities; 4.5 4.6 public enum IssueStatus { 4.7 - InSpecification, 4.8 - ToDo, 4.9 - Scheduled, 4.10 - InProgress, 4.11 - InReview, 4.12 - Done, 4.13 - Rejected, 4.14 - Withdrawn 4.15 + InSpecification(0), 4.16 + ToDo(0), 4.17 + Scheduled(1), 4.18 + InProgress(1), 4.19 + InReview(1), 4.20 + Done(2), 4.21 + Rejected(2), 4.22 + Withdrawn(2), 4.23 + Duplicate(2); 4.24 + 4.25 + private int phase; 4.26 + 4.27 + IssueStatus(int phase) { 4.28 + this.phase = phase; 4.29 + } 4.30 + 4.31 + public int getPhase() { 4.32 + return phase; 4.33 + } 4.34 + 4.35 + public static int phaseCount() { 4.36 + return 3; 4.37 + } 4.38 }
5.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 5.2 +++ b/src/main/java/de/uapcore/lightpit/entities/VersionStatistics.java Sun May 24 15:30:43 2020 +0200 5.3 @@ -0,0 +1,101 @@ 5.4 +/* 5.5 + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. 5.6 + * 5.7 + * Copyright 2018 Mike Becker. All rights reserved. 5.8 + * 5.9 + * Redistribution and use in source and binary forms, with or without 5.10 + * modification, are permitted provided that the following conditions are met: 5.11 + * 5.12 + * 1. Redistributions of source code must retain the above copyright 5.13 + * notice, this list of conditions and the following disclaimer. 5.14 + * 5.15 + * 2. Redistributions in binary form must reproduce the above copyright 5.16 + * notice, this list of conditions and the following disclaimer in the 5.17 + * documentation and/or other materials provided with the distribution. 5.18 + * 5.19 + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 5.20 + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 5.21 + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 5.22 + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 5.23 + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 5.24 + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 5.25 + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 5.26 + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 5.27 + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 5.28 + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 5.29 + * POSSIBILITY OF SUCH DAMAGE. 5.30 + * 5.31 + */ 5.32 +package de.uapcore.lightpit.entities; 5.33 + 5.34 +public class VersionStatistics { 5.35 + 5.36 + private final Version version; 5.37 + private int[][] issueCount; 5.38 + 5.39 + private int[] rowTotals = null; 5.40 + private int[] columnTotals = null; 5.41 + private int total = -1; 5.42 + 5.43 + public VersionStatistics(Version version) { 5.44 + this.version = version; 5.45 + issueCount = new int[IssueCategory.values().length][IssueStatus.values().length]; 5.46 + } 5.47 + 5.48 + public Version getVersion() { 5.49 + return version; 5.50 + } 5.51 + 5.52 + public void setIssueCount(IssueCategory category, IssueStatus status, int count) { 5.53 + issueCount[category.ordinal()][status.ordinal()] = count; 5.54 + total = -1; 5.55 + rowTotals = columnTotals = null; 5.56 + } 5.57 + 5.58 + public int[][] getIssueCount() { 5.59 + return issueCount; 5.60 + } 5.61 + 5.62 + public int[] getRowTotals() { 5.63 + if (rowTotals != null) return rowTotals; 5.64 + final int cn = IssueCategory.values().length; 5.65 + final int sn = IssueStatus.values().length; 5.66 + final var totals = new int[cn]; 5.67 + for (int i = 0 ; i < cn ; i++) { 5.68 + totals[i] = 0; 5.69 + for (int j = 0 ; j < sn ; j++) { 5.70 + totals[i] += issueCount[i][j]; 5.71 + } 5.72 + } 5.73 + return rowTotals = totals; 5.74 + } 5.75 + 5.76 + public int[] getColumnTotals() { 5.77 + if (columnTotals != null) return columnTotals; 5.78 + final int cn = IssueCategory.values().length; 5.79 + final int sn = IssueStatus.values().length; 5.80 + final var totals = new int[sn]; 5.81 + for (int i = 0 ; i < sn ; i++) { 5.82 + totals[i] = 0; 5.83 + for (int j = 0 ; j < cn ; j++) { 5.84 + totals[i] += issueCount[j][i]; 5.85 + } 5.86 + } 5.87 + return columnTotals = totals; 5.88 + } 5.89 + 5.90 + public int getTotal() { 5.91 + if (this.total >= 0) { 5.92 + return this.total; 5.93 + } 5.94 + int total = 0; 5.95 + final int cn = IssueCategory.values().length; 5.96 + final int sn = IssueStatus.values().length; 5.97 + for (int i = 0 ; i < sn ; i++) { 5.98 + for (int j = 0 ; j < cn ; j++) { 5.99 + total += issueCount[j][i]; 5.100 + } 5.101 + } 5.102 + return this.total = total; 5.103 + } 5.104 +}
6.1 --- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java Sat May 23 14:13:09 2020 +0200 6.2 +++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java Sun May 24 15:30:43 2020 +0200 6.3 @@ -57,9 +57,10 @@ 6.4 6.5 private static final Logger LOG = LoggerFactory.getLogger(ProjectsModule.class); 6.6 6.7 - public static final String SESSION_ATTR_SELECTED_PROJECT = fqn(ProjectsModule.class, "selected-project"); 6.8 - public static final String SESSION_ATTR_SELECTED_ISSUE = fqn(ProjectsModule.class, "selected-issue"); 6.9 - public static final String SESSION_ATTR_SELECTED_VERSION = fqn(ProjectsModule.class, "selected-version"); 6.10 + public static final String SESSION_ATTR_SELECTED_PROJECT = fqn(ProjectsModule.class, "selected_project"); 6.11 + public static final String SESSION_ATTR_SELECTED_ISSUE = fqn(ProjectsModule.class, "selected_issue"); 6.12 + public static final String SESSION_ATTR_SELECTED_VERSION = fqn(ProjectsModule.class, "selected_version"); 6.13 + public static final String SESSION_ATTR_HIDE_ZEROS = fqn(ProjectsModule.class, "stats_hide_zeros"); 6.14 6.15 private class SessionSelection { 6.16 final HttpSession session; 6.17 @@ -128,15 +129,40 @@ 6.18 } 6.19 } 6.20 6.21 + private void setAttributeHideZeros(HttpServletRequest req) { 6.22 + final Boolean value; 6.23 + final var param = getParameter(req, Boolean.class, "reduced"); 6.24 + if (param.isPresent()) { 6.25 + value = param.get(); 6.26 + req.getSession().setAttribute(SESSION_ATTR_HIDE_ZEROS, value); 6.27 + } else { 6.28 + final var sessionValue = req.getSession().getAttribute(SESSION_ATTR_HIDE_ZEROS); 6.29 + if (sessionValue != null) { 6.30 + value = (Boolean) sessionValue; 6.31 + } else { 6.32 + value = false; 6.33 + req.getSession().setAttribute(SESSION_ATTR_HIDE_ZEROS, value); 6.34 + } 6.35 + } 6.36 + req.setAttribute("statsHideZeros", value); 6.37 + } 6.38 + 6.39 @Override 6.40 protected String getResourceBundleName() { 6.41 return "localization.projects"; 6.42 } 6.43 6.44 + 6.45 + private static final int BREADCRUMB_LEVEL_ROOT = 0; 6.46 + private static final int BREADCRUMB_LEVEL_PROJECT = 1; 6.47 + private static final int BREADCRUMB_LEVEL_VERSION = 2; 6.48 + private static final int BREADCRUMB_LEVEL_ISSUE_LIST = 3; 6.49 + private static final int BREADCRUMB_LEVEL_ISSUE = 4; 6.50 + 6.51 /** 6.52 * Creates the breadcrumb menu. 6.53 * 6.54 - * @param level the current active level (0: root, 1: project, 2: version, 3: issue) 6.55 + * @param level the current active level (0: root, 1: project, 2: version, 3: issue list, 4: issue) 6.56 * @param sessionSelection the currently selected objects 6.57 * @return a dynamic breadcrumb menu trying to display as many levels as possible 6.58 */ 6.59 @@ -147,7 +173,7 @@ 6.60 entry = new MenuEntry(new ResourceKey("localization.lightpit", "menu.projects"), 6.61 "projects/"); 6.62 breadcrumbs.add(entry); 6.63 - if (level == 0) entry.setActive(true); 6.64 + if (level == BREADCRUMB_LEVEL_ROOT) entry.setActive(true); 6.65 6.66 if (sessionSelection.project != null) { 6.67 if (sessionSelection.project.getId() < 0) { 6.68 @@ -157,7 +183,7 @@ 6.69 entry = new MenuEntry(sessionSelection.project.getName(), 6.70 "projects/view?pid=" + sessionSelection.project.getId()); 6.71 } 6.72 - if (level == 1) entry.setActive(true); 6.73 + if (level == BREADCRUMB_LEVEL_PROJECT) entry.setActive(true); 6.74 breadcrumbs.add(entry); 6.75 } 6.76 6.77 @@ -170,15 +196,19 @@ 6.78 // TODO: change link to issue overview for that version 6.79 "projects/versions/edit?id=" + sessionSelection.version.getId()); 6.80 } 6.81 - if (level == 2) entry.setActive(true); 6.82 + if (level == BREADCRUMB_LEVEL_VERSION) entry.setActive(true); 6.83 + breadcrumbs.add(entry); 6.84 + } 6.85 + 6.86 + if (sessionSelection.project != null) { 6.87 + entry = new MenuEntry(new ResourceKey("localization.projects", "menu.issues"), 6.88 + // TODO: maybe also add selected version 6.89 + "projects/issues/?pid=" + sessionSelection.project.getId()); 6.90 + if (level == BREADCRUMB_LEVEL_ISSUE_LIST) entry.setActive(true); 6.91 breadcrumbs.add(entry); 6.92 } 6.93 6.94 if (sessionSelection.issue != null) { 6.95 - entry = new MenuEntry(new ResourceKey("localization.projects", "menu.issues"), 6.96 - // TODO: change link to a separate issue view (maybe depending on the selected version) 6.97 - "projects/view?pid=" + sessionSelection.issue.getProject().getId()); 6.98 - breadcrumbs.add(entry); 6.99 if (sessionSelection.issue.getId() < 0) { 6.100 entry = new MenuEntry(new ResourceKey("localization.projects", "button.issue.create"), 6.101 "projects/issues/edit"); 6.102 @@ -187,7 +217,7 @@ 6.103 // TODO: maybe change link to a view rather than directly opening the editor 6.104 "projects/issues/edit?id=" + sessionSelection.issue.getId()); 6.105 } 6.106 - if (level == 3) entry.setActive(true); 6.107 + if (level == BREADCRUMB_LEVEL_ISSUE) entry.setActive(true); 6.108 breadcrumbs.add(entry); 6.109 } 6.110 6.111 @@ -202,7 +232,7 @@ 6.112 setContentPage(req, "projects"); 6.113 setStylesheet(req, "projects"); 6.114 6.115 - setBreadcrumbs(req, getBreadcrumbs(0, sessionSelection)); 6.116 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ROOT, sessionSelection)); 6.117 6.118 return ResponseType.HTML; 6.119 } 6.120 @@ -211,7 +241,7 @@ 6.121 req.setAttribute("project", selection.project); 6.122 req.setAttribute("users", dao.getUserDao().list()); 6.123 setContentPage(req, "project-form"); 6.124 - setBreadcrumbs(req, getBreadcrumbs(1, selection)); 6.125 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_PROJECT, selection)); 6.126 } 6.127 6.128 @RequestMapping(requestPath = "edit", method = HttpMethod.GET) 6.129 @@ -253,29 +283,60 @@ 6.130 } 6.131 6.132 @RequestMapping(requestPath = "view", method = HttpMethod.GET) 6.133 - public ResponseType view(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException { 6.134 + public ResponseType view(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException { 6.135 final var sessionSelection = new SessionSelection(req, dao); 6.136 + if (sessionSelection.project == null) { 6.137 + resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No project selected."); 6.138 + return ResponseType.NONE; 6.139 + } 6.140 6.141 - req.setAttribute("versions", dao.getVersionDao().list(sessionSelection.project)); 6.142 - req.setAttribute("issues", dao.getIssueDao().list(sessionSelection.project)); 6.143 + final var versionDao = dao.getVersionDao(); 6.144 + final var versions = versionDao.list(sessionSelection.project); 6.145 + final var statsAffected = new ArrayList<VersionStatistics>(); 6.146 + final var statsScheduled = new ArrayList<VersionStatistics>(); 6.147 + final var statsResolved = new ArrayList<VersionStatistics>(); 6.148 + for (Version version : versions) { 6.149 + statsAffected.add(versionDao.statsOpenedIssues(version)); 6.150 + statsScheduled.add(versionDao.statsScheduledIssues(version)); 6.151 + statsResolved.add(versionDao.statsResolvedIssues(version)); 6.152 + } 6.153 6.154 - setBreadcrumbs(req, getBreadcrumbs(1, sessionSelection)); 6.155 + setAttributeHideZeros(req); 6.156 + 6.157 + req.setAttribute("versions", versions); 6.158 + req.setAttribute("statsAffected", statsAffected); 6.159 + req.setAttribute("statsScheduled", statsScheduled); 6.160 + req.setAttribute("statsResolved", statsResolved); 6.161 + 6.162 + req.setAttribute("issueStatusEnum", IssueStatus.values()); 6.163 + req.setAttribute("issueCategoryEnum", IssueCategory.values()); 6.164 + 6.165 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_PROJECT, sessionSelection)); 6.166 setContentPage(req, "project-details"); 6.167 + setStylesheet(req, "projects"); 6.168 6.169 return ResponseType.HTML; 6.170 } 6.171 6.172 private void configureEditVersionForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException { 6.173 + final var versionDao = dao.getVersionDao(); 6.174 req.setAttribute("projects", dao.getProjectDao().list()); 6.175 req.setAttribute("version", selection.version); 6.176 req.setAttribute("versionStatusEnum", VersionStatus.values()); 6.177 6.178 + req.setAttribute("issueStatusEnum", IssueStatus.values()); 6.179 + req.setAttribute("issueCategoryEnum", IssueCategory.values()); 6.180 + req.setAttribute("statsAffected", versionDao.statsOpenedIssues(selection.version)); 6.181 + req.setAttribute("statsScheduled", versionDao.statsScheduledIssues(selection.version)); 6.182 + req.setAttribute("statsResolved", versionDao.statsResolvedIssues(selection.version)); 6.183 + setAttributeHideZeros(req); 6.184 + 6.185 setContentPage(req, "version-form"); 6.186 - setBreadcrumbs(req, getBreadcrumbs(2, selection)); 6.187 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_VERSION, selection)); 6.188 } 6.189 6.190 @RequestMapping(requestPath = "versions/edit", method = HttpMethod.GET) 6.191 - public ResponseType editVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException { 6.192 + public ResponseType editVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException { 6.193 final var sessionSelection = new SessionSelection(req, dao); 6.194 6.195 sessionSelection.selectVersion(findByParameter(req, Integer.class, "id", dao.getVersionDao()::find) 6.196 @@ -286,7 +347,7 @@ 6.197 } 6.198 6.199 @RequestMapping(requestPath = "versions/commit", method = HttpMethod.POST) 6.200 - public ResponseType commitVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException { 6.201 + public ResponseType commitVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException { 6.202 final var sessionSelection = new SessionSelection(req, dao); 6.203 6.204 var version = new Version(-1, sessionSelection.project); 6.205 @@ -320,11 +381,28 @@ 6.206 req.setAttribute("users", dao.getUserDao().list()); 6.207 6.208 setContentPage(req, "issue-form"); 6.209 - setBreadcrumbs(req, getBreadcrumbs(3, selection)); 6.210 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ISSUE, selection)); 6.211 + } 6.212 + 6.213 + @RequestMapping(requestPath = "issues/", method = HttpMethod.GET) 6.214 + public ResponseType issues(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException { 6.215 + final var sessionSelection = new SessionSelection(req, dao); 6.216 + if (sessionSelection.project == null) { 6.217 + resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No project selected."); 6.218 + return ResponseType.NONE; 6.219 + } 6.220 + 6.221 + req.setAttribute("issues", dao.getIssueDao().list(sessionSelection.project)); 6.222 + 6.223 + setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ISSUE_LIST, sessionSelection)); 6.224 + setContentPage(req, "issues"); 6.225 + setStylesheet(req, "projects"); 6.226 + 6.227 + return ResponseType.HTML; 6.228 } 6.229 6.230 @RequestMapping(requestPath = "issues/edit", method = HttpMethod.GET) 6.231 - public ResponseType editIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException { 6.232 + public ResponseType editIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException { 6.233 final var sessionSelection = new SessionSelection(req, dao); 6.234 6.235 sessionSelection.selectIssue(findByParameter(req, Integer.class, "id", 6.236 @@ -335,7 +413,7 @@ 6.237 } 6.238 6.239 @RequestMapping(requestPath = "issues/commit", method = HttpMethod.POST) 6.240 - public ResponseType commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException, SQLException { 6.241 + public ResponseType commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException { 6.242 final var sessionSelection = new SessionSelection(req, dao); 6.243 6.244 Issue issue = new Issue(-1, sessionSelection.project);
7.1 --- a/src/main/resources/localization/projects.properties Sat May 23 14:13:09 2020 +0200 7.2 +++ b/src/main/resources/localization/projects.properties Sun May 24 15:30:43 2020 +0200 7.3 @@ -26,6 +26,10 @@ 7.4 button.create=New Project 7.5 button.version.create=New Version 7.6 button.issue.create=New Issue 7.7 +button.issue.list=Show Issues 7.8 + 7.9 +button.stats.hidezeros=Reduced View 7.10 +button.stats.showzeros=Full View 7.11 7.12 no-projects=Welcome to LightPIT. Start off by creating a new project! 7.13 7.14 @@ -45,12 +49,18 @@ 7.15 placeholder.null-owner=Unassigned 7.16 placeholder.null-assignee=Unassigned 7.17 7.18 +version.label=Version 7.19 version.status.Future=Future 7.20 version.status.Unreleased=Unreleased 7.21 version.status.Released=Released 7.22 version.status.LTS=LTS 7.23 version.status.Deprecated=Deprecated 7.24 7.25 +version.statistics.affected=Affected by Issues 7.26 +version.statistics.scheduled=Scheduled Issues 7.27 +version.statistics.resolved=Resolved Issues 7.28 +version.statistics.total=Total 7.29 + 7.30 thead.issue.project=Project 7.31 thead.issue.subject=Subject 7.32 thead.issue.description=Description 7.33 @@ -81,3 +91,4 @@ 7.34 issue.status.Done=Done 7.35 issue.status.Rejected=Rejected 7.36 issue.status.Withdrawn=Withdrawn 7.37 +issue.status.Duplicate=Duplicate
8.1 --- a/src/main/resources/localization/projects_de.properties Sat May 23 14:13:09 2020 +0200 8.2 +++ b/src/main/resources/localization/projects_de.properties Sun May 24 15:30:43 2020 +0200 8.3 @@ -26,6 +26,10 @@ 8.4 button.create=Neues Projekt 8.5 button.version.create=Neue Version 8.6 button.issue.create=Neuer Vorgang 8.7 +button.issue.list=Vorg\u00e4nge 8.8 + 8.9 +button.stats.hidezeros=Reduzierte Ansicht 8.10 +button.stats.showzeros=Komplettansicht 8.11 8.12 no-projects=Wilkommen bei LightPIT. Beginnen Sie mit der Erstellung eines Projektes! 8.13 8.14 @@ -45,12 +49,18 @@ 8.15 placeholder.null-owner=Nicht Zugewiesen 8.16 placeholder.null-assignee=Niemandem 8.17 8.18 +version.label=Version 8.19 version.status.Future=Geplant 8.20 version.status.Unreleased=Unver\u00f6ffentlicht 8.21 version.status.Released=Ver\u00f6ffentlicht 8.22 version.status.LTS=Langzeitsupport 8.23 version.status.Deprecated=Veraltet 8.24 8.25 +version.statistics.affected=Betroffen von Vorg\u00e4ngen 8.26 +version.statistics.scheduled=Geplante Vorg\u00e4nge 8.27 +version.statistics.resolved=Gel\u00f6ste Vorg\u00e4nge 8.28 +version.statistics.total=Summe 8.29 + 8.30 thead.issue.project=Projekt 8.31 thead.issue.subject=Thema 8.32 thead.issue.description=Beschreibung 8.33 @@ -81,3 +91,4 @@ 8.34 issue.status.Done=Erledigt 8.35 issue.status.Rejected=Zur\u00fcckgewiesen 8.36 issue.status.Withdrawn=Zur\u00fcckgezogen 8.37 +issue.status.Duplicate=Duplikat
9.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 9.2 +++ b/src/main/webapp/WEB-INF/jsp/issues.jsp Sun May 24 15:30:43 2020 +0200 9.3 @@ -0,0 +1,85 @@ 9.4 +<%-- 9.5 +DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. 9.6 + 9.7 +Copyright 2018 Mike Becker. All rights reserved. 9.8 + 9.9 +Redistribution and use in source and binary forms, with or without 9.10 +modification, are permitted provided that the following conditions are met: 9.11 + 9.12 +1. Redistributions of source code must retain the above copyright 9.13 +notice, this list of conditions and the following disclaimer. 9.14 + 9.15 +2. Redistributions in binary form must reproduce the above copyright 9.16 +notice, this list of conditions and the following disclaimer in the 9.17 +documentation and/or other materials provided with the distribution. 9.18 + 9.19 +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 9.20 +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 9.21 +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 9.22 +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 9.23 +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 9.24 +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 9.25 +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 9.26 +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 9.27 +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 9.28 +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 9.29 +--%> 9.30 +<%@page pageEncoding="UTF-8" %> 9.31 +<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> 9.32 +<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> 9.33 + 9.34 +<jsp:useBean id="issues" type="java.util.List<de.uapcore.lightpit.entities.Issue>" scope="request"/> 9.35 + 9.36 +<div id="tool-area"> 9.37 + <div> 9.38 + <a href="./projects/issues/edit" class="button"><fmt:message key="button.issue.create"/></a> 9.39 + </div> 9.40 +</div> 9.41 + 9.42 +<table id="issue-list" class="datatable medskip"> 9.43 + <thead> 9.44 + <tr> 9.45 + <th><fmt:message key="thead.issue.subject"/></th> 9.46 + <th><fmt:message key="thead.issue.assignee"/></th> 9.47 + <th><fmt:message key="thead.issue.category"/></th> 9.48 + <th><fmt:message key="thead.issue.status"/></th> 9.49 + <th><fmt:message key="thead.issue.created"/></th> 9.50 + <th><fmt:message key="thead.issue.updated"/></th> 9.51 + <th><fmt:message key="thead.issue.eta"/></th> 9.52 + </tr> 9.53 + </thead> 9.54 + <tbody> 9.55 + <c:forEach var="issue" items="${issues}"> 9.56 + <tr> 9.57 + <td> 9.58 + <a href="./projects/issues/edit?id=${issue.id}"> 9.59 + <c:out value="${issue.subject}" /> 9.60 + </a> 9.61 + </td> 9.62 + <td> 9.63 + <c:if test="${not empty issue.assignee}"> 9.64 + <c:out value="${issue.assignee.shortDisplayname}" /> 9.65 + </c:if> 9.66 + <c:if test="${empty issue.assignee}"> 9.67 + <fmt:message key="placeholder.null-assignee" /> 9.68 + </c:if> 9.69 + </td> 9.70 + <td> 9.71 + <fmt:message key="issue.category.${issue.category}" /> 9.72 + </td> 9.73 + <td> 9.74 + <fmt:message key="issue.status.${issue.status}" /> 9.75 + </td> 9.76 + <td> 9.77 + <fmt:formatDate value="${issue.created}" type="BOTH"/> 9.78 + </td> 9.79 + <td> 9.80 + <fmt:formatDate value="${issue.updated}" type="BOTH"/> 9.81 + </td> 9.82 + <td> 9.83 + <fmt:formatDate value="${issue.eta}" /> 9.84 + </td> 9.85 + </tr> 9.86 + </c:forEach> 9.87 + </tbody> 9.88 +</table>
10.1 --- a/src/main/webapp/WEB-INF/jsp/project-details.jsp Sat May 23 14:13:09 2020 +0200 10.2 +++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp Sun May 24 15:30:43 2020 +0200 10.3 @@ -25,87 +25,46 @@ 10.4 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10.5 --%> 10.6 <%@page pageEncoding="UTF-8" %> 10.7 -<%@page import="de.uapcore.lightpit.modules.ProjectsModule" %> 10.8 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> 10.9 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> 10.10 10.11 -<c:set scope="page" var="selectedProject" value="${sessionScope[ProjectsModule.SESSION_ATTR_SELECTED_PROJECT]}"/> 10.12 - 10.13 <jsp:useBean id="versions" type="java.util.List<de.uapcore.lightpit.entities.Version>" scope="request"/> 10.14 -<jsp:useBean id="issues" type="java.util.List<de.uapcore.lightpit.entities.Issue>" scope="request"/> 10.15 +<jsp:useBean id="statsAffected" type="java.util.List<de.uapcore.lightpit.entities.VersionStatistics>" scope="request"/> 10.16 +<jsp:useBean id="statsScheduled" type="java.util.List<de.uapcore.lightpit.entities.VersionStatistics>" scope="request"/> 10.17 +<jsp:useBean id="statsResolved" type="java.util.List<de.uapcore.lightpit.entities.VersionStatistics>" scope="request"/> 10.18 +<jsp:useBean id="issueStatusEnum" type="de.uapcore.lightpit.entities.IssueStatus[]" scope="request"/> 10.19 +<jsp:useBean id="issueCategoryEnum" type="de.uapcore.lightpit.entities.IssueCategory[]" scope="request"/> 10.20 +<jsp:useBean id="statsHideZeros" type="java.lang.Boolean" scope="request"/> 10.21 10.22 <div id="tool-area"> 10.23 <a href="./projects/versions/edit" class="button"><fmt:message key="button.version.create"/></a> 10.24 <a href="./projects/issues/edit" class="button"><fmt:message key="button.issue.create"/></a> 10.25 + <a href="./projects/issues/" class="button"><fmt:message key="button.issue.list"/></a> 10.26 + <c:if test="${not statsHideZeros}"> 10.27 + <a href="./projects/view?reduced=1" class="button"><fmt:message key="button.stats.hidezeros"/></a> 10.28 + </c:if> 10.29 + <c:if test="${statsHideZeros}"> 10.30 + <a href="./projects/view?reduced=0" class="button"><fmt:message key="button.stats.showzeros"/></a> 10.31 + </c:if> 10.32 </div> 10.33 10.34 -<c:if test="${not empty versions}"> 10.35 - <table id="version-list" class="datatable medskip"> 10.36 - <thead> 10.37 - <tr> 10.38 - <th></th> 10.39 - <th><fmt:message key="thead.version.name"/></th> 10.40 - <th><fmt:message key="thead.version.status"/></th> 10.41 - </tr> 10.42 - </thead> 10.43 - <tbody> 10.44 - <c:forEach var="version" items="${versions}"> 10.45 - <tr class="nowrap"> 10.46 - <td style="width: 2em;"><a href="./projects/versions/edit?id=${version.id}">✎</a> 10.47 - </td> 10.48 - <td><c:out value="${version.name}"/></td> 10.49 - <td><fmt:message key="version.status.${version.status}"/></td> 10.50 - </tr> 10.51 - </c:forEach> 10.52 - </tbody> 10.53 - </table> 10.54 -</c:if> 10.55 +<div id="version-stats"> 10.56 +<c:forEach var="version" items="${versions}" varStatus="iter"> 10.57 + <h2> 10.58 + <fmt:message key="version.label" /> <c:out value="${version.name}" /> - <fmt:message key="version.status.${version.status}"/> 10.59 + <a href="./projects/versions/edit?id=${version.id}">✎</a> 10.60 + </h2> 10.61 10.62 -<table id="issue-list" class="datatable medskip"> 10.63 - <thead> 10.64 - <tr> 10.65 - <th><fmt:message key="thead.issue.subject"/></th> 10.66 - <th><fmt:message key="thead.issue.assignee"/></th> 10.67 - <th><fmt:message key="thead.issue.category"/></th> 10.68 - <th><fmt:message key="thead.issue.status"/></th> 10.69 - <th><fmt:message key="thead.issue.created"/></th> 10.70 - <th><fmt:message key="thead.issue.updated"/></th> 10.71 - <th><fmt:message key="thead.issue.eta"/></th> 10.72 - <!-- TODO: add other information --> 10.73 - </tr> 10.74 - </thead> 10.75 - <tbody> 10.76 - <c:forEach var="issue" items="${issues}"> 10.77 - <tr> 10.78 - <td> 10.79 - <a href="./projects/issues/edit?id=${issue.id}"> 10.80 - <c:out value="${issue.subject}" /> 10.81 - </a> 10.82 - </td> 10.83 - <td> 10.84 - <c:if test="${not empty issue.assignee}"> 10.85 - <c:out value="${issue.assignee.shortDisplayname}" /> 10.86 - </c:if> 10.87 - <c:if test="${empty issue.assignee}"> 10.88 - <fmt:message key="placeholder.null-assignee" /> 10.89 - </c:if> 10.90 - </td> 10.91 - <td> 10.92 - <fmt:message key="issue.category.${issue.category}" /> 10.93 - </td> 10.94 - <td> 10.95 - <fmt:message key="issue.status.${issue.status}" /> 10.96 - </td> 10.97 - <td> 10.98 - <fmt:formatDate value="${issue.created}" type="BOTH"/> 10.99 - </td> 10.100 - <td> 10.101 - <fmt:formatDate value="${issue.updated}" type="BOTH"/> 10.102 - </td> 10.103 - <td> 10.104 - <fmt:formatDate value="${issue.eta}" /> 10.105 - </td> 10.106 - </tr> 10.107 - </c:forEach> 10.108 - </tbody> 10.109 -</table> 10.110 + <h3><fmt:message key="version.statistics.affected" /></h3> 10.111 + <c:set var="stats" value="${statsAffected[iter.index]}" /> 10.112 + <%@include file="../jspf/version-stats.jsp" %> 10.113 + 10.114 + <h3><fmt:message key="version.statistics.scheduled" /></h3> 10.115 + <c:set var="stats" value="${statsScheduled[iter.index]}" /> 10.116 + <%@include file="../jspf/version-stats.jsp" %> 10.117 + 10.118 + <h3><fmt:message key="version.statistics.resolved" /></h3> 10.119 + <c:set var="stats" value="${statsResolved[iter.index]}" /> 10.120 + <%@include file="../jspf/version-stats.jsp" %> 10.121 +</c:forEach> 10.122 +</div>
11.1 --- a/src/main/webapp/WEB-INF/jsp/version-form.jsp Sat May 23 14:13:09 2020 +0200 11.2 +++ b/src/main/webapp/WEB-INF/jsp/version-form.jsp Sun May 24 15:30:43 2020 +0200 11.3 @@ -32,6 +32,11 @@ 11.4 <jsp:useBean id="version" type="de.uapcore.lightpit.entities.Version" scope="request"/> 11.5 <jsp:useBean id="versionStatusEnum" type="de.uapcore.lightpit.entities.VersionStatus[]" scope="request"/> 11.6 11.7 +<jsp:useBean id="statsAffected" type="de.uapcore.lightpit.entities.VersionStatistics" scope="request"/> 11.8 +<jsp:useBean id="statsScheduled" type="de.uapcore.lightpit.entities.VersionStatistics" scope="request"/> 11.9 +<jsp:useBean id="statsResolved" type="de.uapcore.lightpit.entities.VersionStatistics" scope="request"/> 11.10 +<jsp:useBean id="statsHideZeros" type="java.lang.Boolean" scope="request"/> 11.11 + 11.12 <form action="./projects/versions/commit" method="post"> 11.13 <table class="formtable" style="width: 35ch"> 11.14 <colgroup> 11.15 @@ -95,3 +100,15 @@ 11.16 </tfoot> 11.17 </table> 11.18 </form> 11.19 + 11.20 +<h3><fmt:message key="version.statistics.affected" /></h3> 11.21 +<c:set var="stats" value="${statsAffected}" /> 11.22 +<%@include file="../jspf/version-stats.jsp" %> 11.23 + 11.24 +<h3><fmt:message key="version.statistics.scheduled" /></h3> 11.25 +<c:set var="stats" value="${statsScheduled}" /> 11.26 +<%@include file="../jspf/version-stats.jsp" %> 11.27 + 11.28 +<h3><fmt:message key="version.statistics.resolved" /></h3> 11.29 +<c:set var="stats" value="${statsResolved}" /> 11.30 +<%@include file="../jspf/version-stats.jsp" %> 11.31 \ No newline at end of file
12.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 12.2 +++ b/src/main/webapp/WEB-INF/jspf/version-stats.jsp Sun May 24 15:30:43 2020 +0200 12.3 @@ -0,0 +1,54 @@ 12.4 +<%@ taglib uri = "http://java.sun.com/jsp/jstl/functions" prefix = "fn" %> 12.5 + 12.6 +<table class="datatable"> 12.7 + <c:if test="${statsHideZeros}"> 12.8 + <c:set var="visibleColumns" value="0"/> 12.9 + <c:forEach var="idx" begin="0" end="${fn:length(issueStatusEnum)-1}"> 12.10 + <c:set var="visibleColumns" value="${visibleColumns + (stats.columnTotals[idx] eq 0 ? 0 :1)}"/> 12.11 + </c:forEach> 12.12 + </c:if> 12.13 + <c:if test="${not statsHideZeros}"> 12.14 + <c:set var="visibleColumns" value="${fn:length(issueStatusEnum)}" /> 12.15 + </c:if> 12.16 + <c:set var="colwidth"><fmt:formatNumber value="${100/(visibleColumns+2)}" maxFractionDigits="0" /></c:set> 12.17 + <colgroup> 12.18 + <c:forEach var="idx" begin="1" end="${visibleColumns+2}"> 12.19 + <col width="${colwidth}%"> 12.20 + </c:forEach> 12.21 + </colgroup> 12.22 + <thead> 12.23 + <tr> 12.24 + <th></th> 12.25 + <c:forEach var="issueStatus" items="${issueStatusEnum}" varStatus="statusIter"> 12.26 + <c:if test="${not statsHideZeros or stats.columnTotals[statusIter.index] gt 0}"> 12.27 + <th class="hcenter"><fmt:message key="issue.status.${issueStatus}"/></th> 12.28 + </c:if> 12.29 + </c:forEach> 12.30 + <th class="hcenter"><fmt:message key="version.statistics.total"/> </th> 12.31 + </tr> 12.32 + </thead> 12.33 + <tbody> 12.34 + <c:forEach var="issueCategory" items="${issueCategoryEnum}" varStatus="categoryIter"> 12.35 + <c:if test="${not statsHideZeros or stats.rowTotals[categoryIter.index] gt 0}"> 12.36 + <tr> 12.37 + <th><fmt:message key="issue.category.${issueCategory}" /></th> 12.38 + <c:forEach var="issueStatus" items="${issueStatusEnum}" varStatus="statusIter"> 12.39 + <c:if test="${not statsHideZeros or stats.columnTotals[statusIter.index] gt 0}"> 12.40 + <td>${stats.issueCount[categoryIter.index][statusIter.index]}</td> 12.41 + </c:if> 12.42 + </c:forEach> 12.43 + <td>${stats.rowTotals[categoryIter.index]}</td> 12.44 + </tr> 12.45 + </c:if> 12.46 + </c:forEach> 12.47 + <tr> 12.48 + <th><fmt:message key="version.statistics.total"/> </th> 12.49 + <c:forEach var="issueStatus" items="${issueStatusEnum}" varStatus="statusIter"> 12.50 + <c:if test="${not statsHideZeros or stats.columnTotals[statusIter.index] gt 0}"> 12.51 + <td>${stats.columnTotals[statusIter.index]}</td> 12.52 + </c:if> 12.53 + </c:forEach> 12.54 + <td>${stats.total}</td> 12.55 + </tr> 12.56 + </tbody> 12.57 +</table> 12.58 \ No newline at end of file
13.1 --- a/src/main/webapp/lightpit.css Sat May 23 14:13:09 2020 +0200 13.2 +++ b/src/main/webapp/lightpit.css Sun May 24 15:30:43 2020 +0200 13.3 @@ -120,7 +120,7 @@ 13.4 width: auto; 13.5 border-style: solid; 13.6 border-width: 1pt; 13.7 - border-color: black; 13.8 + border-color: silver; 13.9 border-collapse: collapse; 13.10 } 13.11 13.12 @@ -178,6 +178,10 @@ 13.13 text-align: center; 13.14 } 13.15 13.16 +.hright { 13.17 + text-align: right; 13.18 +} 13.19 + 13.20 .smalltext { 13.21 font-size: smaller; 13.22 }
14.1 --- a/src/main/webapp/projects.css Sat May 23 14:13:09 2020 +0200 14.2 +++ b/src/main/webapp/projects.css Sun May 24 15:30:43 2020 +0200 14.3 @@ -29,4 +29,12 @@ 14.4 14.5 #issue-list td { 14.6 white-space: nowrap; 14.7 +} 14.8 + 14.9 +#version-stats h2 { 14.10 + white-space: nowrap; 14.11 +} 14.12 + 14.13 +#version-stats td { 14.14 + text-align: right; 14.15 } 14.16 \ No newline at end of file