From c9de84169e407fbf1963e7ad0e1f3f3e57cfae24 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Thu, 01 May 2014 14:55:57 -0400
Subject: [PATCH] Merged #17 "CRUD Milestone Pages"

---
 src/main/java/com/gitblit/wicket/pages/TicketsPage.java       |  106 ++++++++-
 src/main/java/com/gitblit/wicket/pages/EditMilestonePage.java |  189 +++++++++++++++++
 src/main/java/com/gitblit/wicket/pages/NewMilestonePage.html  |   37 +++
 src/main/java/com/gitblit/tickets/QueryResult.java            |    8 
 src/main/java/com/gitblit/tickets/ITicketService.java         |   60 +++++
 src/main/java/com/gitblit/wicket/GitBlitWebApp.java           |    4 
 src/main/java/com/gitblit/wicket/GitBlitWebApp.properties     |    8 
 src/main/java/com/gitblit/wicket/pages/EditMilestonePage.html |   39 +++
 src/main/java/com/gitblit/wicket/pages/NewMilestonePage.java  |  140 ++++++++++++
 src/main/java/com/gitblit/tickets/TicketLabel.java            |    7 
 src/main/java/com/gitblit/wicket/pages/TicketsPage.html       |   43 +++
 src/main/java/com/gitblit/tickets/TicketMilestone.java        |   12 +
 12 files changed, 631 insertions(+), 22 deletions(-)

diff --git a/src/main/java/com/gitblit/tickets/ITicketService.java b/src/main/java/com/gitblit/tickets/ITicketService.java
index c2f3283..3261ca9 100644
--- a/src/main/java/com/gitblit/tickets/ITicketService.java
+++ b/src/main/java/com/gitblit/tickets/ITicketService.java
@@ -49,6 +49,7 @@
 import com.gitblit.models.TicketModel.Patchset;
 import com.gitblit.models.TicketModel.Status;
 import com.gitblit.tickets.TicketIndexer.Lucene;
+import com.gitblit.utils.DeepCopier;
 import com.gitblit.utils.DiffUtils;
 import com.gitblit.utils.DiffUtils.DiffStat;
 import com.gitblit.utils.StringUtils;
@@ -556,9 +557,10 @@
 	public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
 		for (TicketMilestone ms : getMilestones(repository)) {
 			if (ms.name.equalsIgnoreCase(milestone)) {
+				TicketMilestone tm = DeepCopier.copy(ms);
 				String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
-				ms.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
-				return ms;
+				tm.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
+				return tm;
 			}
 		}
 		return null;
@@ -639,6 +641,22 @@
 	 * @since 1.4.0
 	 */
 	public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
+		return renameMilestone(repository, oldName, newName, createdBy, true);
+	}
+
+	/**
+	 * Renames a milestone.
+	 *
+	 * @param repository
+	 * @param oldName
+	 * @param newName
+	 * @param createdBy
+	 * @param notifyOpenTickets
+	 * @return true if successful
+	 * @since 1.6.0
+	 */
+	public synchronized boolean renameMilestone(RepositoryModel repository, String oldName,
+			String newName, String createdBy, boolean notifyOpenTickets) {
 		if (StringUtils.isEmpty(newName)) {
 			throw new IllegalArgumentException("new milestone can not be empty!");
 		}
@@ -651,7 +669,7 @@
 			config.setString(MILESTONE, newName, STATUS, milestone.status.name());
 			config.setString(MILESTONE, newName, COLOR, milestone.color);
 			if (milestone.due != null) {
-				config.setString(MILESTONE, milestone.name, DUE,
+				config.setString(MILESTONE, newName, DUE,
 						new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
 			}
 			config.save();
@@ -663,9 +681,13 @@
 				Change change = new Change(createdBy);
 				change.setField(Field.milestone, newName);
 				TicketModel ticket = updateTicket(repository, qr.number, change);
-				notifier.queueMailing(ticket);
+				if (notifyOpenTickets && ticket.isOpen()) {
+					notifier.queueMailing(ticket);
+				}
 			}
-			notifier.sendAll();
+			if (notifyOpenTickets) {
+				notifier.sendAll();
+			}
 
 			return true;
 		} catch (IOException e) {
@@ -688,11 +710,27 @@
 	 * @since 1.4.0
 	 */
 	public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
+		return deleteMilestone(repository, milestone, createdBy, true);
+	}
+
+	/**
+	 * Deletes a milestone.
+	 *
+	 * @param repository
+	 * @param milestone
+	 * @param createdBy
+	 * @param notifyOpenTickets
+	 * @return true if successful
+	 * @since 1.6.0
+	 */
+	public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone,
+			String createdBy, boolean notifyOpenTickets) {
 		if (StringUtils.isEmpty(milestone)) {
 			throw new IllegalArgumentException("milestone can not be empty!");
 		}
 		Repository db = null;
 		try {
+			TicketMilestone tm = getMilestone(repository, milestone);
 			db = repositoryManager.getRepository(repository.name);
 			StoredConfig config = db.getConfig();
 			config.unsetSection(MILESTONE, milestone);
@@ -700,6 +738,18 @@
 
 			milestonesCache.remove(repository.name);
 
+			TicketNotifier notifier = createNotifier();
+			for (QueryResult qr : tm.tickets) {
+				Change change = new Change(createdBy);
+				change.setField(Field.milestone, "");
+				TicketModel ticket = updateTicket(repository, qr.number, change);
+				if (notifyOpenTickets && ticket.isOpen()) {
+					notifier.queueMailing(ticket);
+				}
+			}
+			if (notifyOpenTickets) {
+				notifier.sendAll();
+			}
 			return true;
 		} catch (IOException e) {
 			log.error("failed to delete milestone " + milestone + " in " + repository, e);
diff --git a/src/main/java/com/gitblit/tickets/QueryResult.java b/src/main/java/com/gitblit/tickets/QueryResult.java
index 9f5d3a5..7a2b1ab 100644
--- a/src/main/java/com/gitblit/tickets/QueryResult.java
+++ b/src/main/java/com/gitblit/tickets/QueryResult.java
@@ -74,6 +74,14 @@
 		return type != null && Type.Proposal == type;
 	}
 
+	public boolean isOpen() {
+		return !status.isClosed();
+	}
+
+	public boolean isClosed() {
+		return status.isClosed();
+	}
+
 	public boolean isMerged() {
 		return Status.Merged == status && !StringUtils.isEmpty(mergeSha);
 	}
diff --git a/src/main/java/com/gitblit/tickets/TicketLabel.java b/src/main/java/com/gitblit/tickets/TicketLabel.java
index 686ce88..a7f0ebe 100644
--- a/src/main/java/com/gitblit/tickets/TicketLabel.java
+++ b/src/main/java/com/gitblit/tickets/TicketLabel.java
@@ -30,14 +30,17 @@
 
 	private static final long serialVersionUID = 1L;
 
-	public final String name;
+	public String name;
 
 	public String color;
 
 	public List<QueryResult> tickets;
 
-
 	public TicketLabel(String name) {
+		setName(name);
+	}
+	
+	public void setName(String name) {
 		this.name = name;
 		this.color = StringUtils.getColor(name);
 	}
diff --git a/src/main/java/com/gitblit/tickets/TicketMilestone.java b/src/main/java/com/gitblit/tickets/TicketMilestone.java
index c6b4fcc..dacedda 100644
--- a/src/main/java/com/gitblit/tickets/TicketMilestone.java
+++ b/src/main/java/com/gitblit/tickets/TicketMilestone.java
@@ -38,6 +38,18 @@
 		status = Status.Open;
 	}
 
+	public boolean isOpen() {
+		return status == Status.Open;
+	}
+
+	public boolean isOverdue() {
+		return due == null ? false : System.currentTimeMillis() > due.getTime();
+	}
+
+	public void setDue(Date due) {
+		this.due = due;
+	}
+
 	public int getProgress() {
 		int total = getTotalTickets();
 		if (total == 0) {
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java
index c4fdeda..d4c1bc4 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java
@@ -51,6 +51,7 @@
 import com.gitblit.wicket.pages.ComparePage;
 import com.gitblit.wicket.pages.DocPage;
 import com.gitblit.wicket.pages.DocsPage;
+import com.gitblit.wicket.pages.EditMilestonePage;
 import com.gitblit.wicket.pages.EditTicketPage;
 import com.gitblit.wicket.pages.ExportTicketPage;
 import com.gitblit.wicket.pages.FederationRegistrationPage;
@@ -63,6 +64,7 @@
 import com.gitblit.wicket.pages.LuceneSearchPage;
 import com.gitblit.wicket.pages.MetricsPage;
 import com.gitblit.wicket.pages.MyDashboardPage;
+import com.gitblit.wicket.pages.NewMilestonePage;
 import com.gitblit.wicket.pages.NewTicketPage;
 import com.gitblit.wicket.pages.OverviewPage;
 import com.gitblit.wicket.pages.PatchPage;
@@ -187,6 +189,8 @@
 		mount("/tickets/new", NewTicketPage.class, "r");
 		mount("/tickets/edit", EditTicketPage.class, "r", "h");
 		mount("/tickets/export", ExportTicketPage.class, "r", "h");
+		mount("/milestones/new", NewMilestonePage.class, "r");
+		mount("/milestones/edit", EditMilestonePage.class, "r", "h");
 
 		// setup the markup document urls
 		mount("/docs", DocsPage.class, "r");
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
index aeb2d9e..1394890 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -671,4 +671,10 @@
 gb.ticketIsClosed = This ticket is closed.
 gb.mergeToDescription = default integration branch for merging ticket patchsets
 gb.anonymousCanNotPropose = Anonymous users can not propose patchsets.
-gb.youDoNotHaveClonePermission = You are not permitted to clone this repository.
\ No newline at end of file
+gb.youDoNotHaveClonePermission = You are not permitted to clone this repository.
+gb.newMilestone = new milestone
+gb.editMilestone = edit milestone
+gb.notifyChangedOpenTickets = send notification for changed open tickets
+gb.overdue = overdue
+gb.openMilestones = open milestones
+gb.closedMilestones = closed milestones
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.html b/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.html
new file mode 100644
index 0000000..0897ebe
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"  
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  
+      xml:lang="en"  
+      lang="en"> 
+
+<wicket:extend>
+<body onload="document.getElementById('name').focus();">
+	
+<div class="container">
+	<!-- page header -->
+	<div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
+		<span class="project"><wicket:message key="gb.editMilestone"></wicket:message></span>
+	</div>
+
+	<form style="padding-top:5px;" wicket:id="editForm">
+	<div class="row">
+	<div class="span12">
+		<!-- Edit Milestone Table -->
+		<table class="ticket">
+			<tr><th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="name" id="name"></input></td></tr>
+			<tr><th><wicket:message key="gb.due"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="due"></input> &nbsp;<span class="help-inline" wicket:id="dueFormat"></span></td></tr>
+			<tr><th><wicket:message key="gb.status"></wicket:message><span style="color:red;">*</span></th><td class="edit"><select class="input-large" wicket:id="status"></select></td></tr>
+			<tr><th></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="notify" /> &nbsp;<span class="help-inline"><wicket:message key="gb.notifyChangedOpenTickets"></wicket:message></span></label></td></tr>
+		</table>
+	</div>
+	</div>	
+
+	<div class="row">
+	<div class="span12">
+		<div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Save" wicket:message="value:gb.save" wicket:id="save" /> &nbsp; <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /> &nbsp; <input class="btn btn-danger" type="submit" value="Delete" wicket:message="value:gb.delete" wicket:id="delete" /></div>
+	</div>
+	</div>
+	</form>
+</div>
+</body>
+
+</wicket:extend>
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.java b/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.java
new file mode 100644
index 0000000..b844442
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/pages/EditMilestonePage.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.RestartResponseException;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.form.AjaxButton;
+import org.apache.wicket.extensions.markup.html.form.DateTextField;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.Button;
+import org.apache.wicket.markup.html.form.CheckBox;
+import org.apache.wicket.markup.html.form.DropDownChoice;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+
+/**
+ * Page for creating a new milestone.
+ *
+ * @author James Moger
+ *
+ */
+public class EditMilestonePage extends RepositoryPage {
+
+	private final String oldName;
+
+	private IModel<String> nameModel;
+
+	private IModel<Date> dueModel;
+
+	private IModel<Status> statusModel;
+
+	private IModel<Boolean> notificationModel;
+
+	public EditMilestonePage(PageParameters params) {
+		super(params);
+
+		RepositoryModel model = getRepositoryModel();
+		if (!app().tickets().isAcceptingTicketUpdates(model)) {
+			// ticket service is read-only
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		UserModel currentUser = GitBlitWebSession.get().getUser();
+		if (currentUser == null) {
+			currentUser = UserModel.ANONYMOUS;
+		}
+
+		if (!currentUser.isAuthenticated || !currentUser.canAdmin(model)) {
+			// administration prohibited
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		oldName = WicketUtils.getObject(params);
+		if (StringUtils.isEmpty(oldName)) {
+			// milestone not specified
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		TicketMilestone tm = app().tickets().getMilestone(getRepositoryModel(), oldName);
+		if (tm == null) {
+			// milestone does not exist
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		setStatelessHint(false);
+		setOutputMarkupId(true);
+
+		Form<Void> form = new Form<Void>("editForm");
+		add(form);
+
+		nameModel = Model.of(tm.name);
+		dueModel = Model.of(tm.due);
+		statusModel = Model.of(tm.status);
+		notificationModel = Model.of(true);
+
+		form.add(new TextField<String>("name", nameModel));
+		form.add(new DateTextField("due", dueModel, "yyyy-MM-dd"));
+		form.add(new Label("dueFormat", "yyyy-MM-dd"));
+		form.add(new CheckBox("notify", notificationModel));
+
+		List<Status> statusChoices = Arrays.asList(Status.Open, Status.Closed);
+		form.add(new DropDownChoice<TicketModel.Status>("status", statusModel, statusChoices));
+
+		form.add(new AjaxButton("save") {
+
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
+				String name = nameModel.getObject();
+				if (StringUtils.isEmpty(name)) {
+					return;
+				}
+
+				Date due = dueModel.getObject();
+				Status status = statusModel.getObject();
+				boolean rename = !name.equals(oldName);
+				boolean notify = notificationModel.getObject();
+
+				UserModel currentUser = GitBlitWebSession.get().getUser();
+				String createdBy = currentUser.username;
+
+				TicketMilestone tm = app().tickets().getMilestone(getRepositoryModel(), oldName);
+				tm.setName(name);
+				tm.setDue(due);
+				tm.status = status;
+
+				boolean success = true;
+				if (rename) {
+					success = app().tickets().renameMilestone(getRepositoryModel(), oldName, name, createdBy, notify);
+				}
+
+				if (success && app().tickets().updateMilestone(getRepositoryModel(), tm, createdBy)) {
+					setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(getRepositoryModel().name));
+				} else {
+					// TODO error
+				}
+			}
+		});
+		Button cancel = new Button("cancel") {
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			public void onSubmit() {
+				setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+			}
+		};
+		cancel.setDefaultFormProcessing(false);
+		form.add(cancel);
+
+		Button delete = new Button("delete") {
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			public void onSubmit() {
+				UserModel currentUser = GitBlitWebSession.get().getUser();
+				String createdBy = currentUser.username;
+				boolean notify = notificationModel.getObject();
+
+				if (app().tickets().deleteMilestone(getRepositoryModel(), oldName, createdBy, notify)) {
+					setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+				} else {
+					// TODO error processing
+				}
+			}
+		};
+		delete.setDefaultFormProcessing(false);
+		form.add(delete);
+	}
+
+	@Override
+	protected String getPageName() {
+		return getString("gb.editMilestone");
+	}
+
+	@Override
+	protected Class<? extends BasePage> getRepoNavPageClass() {
+		return TicketsPage.class;
+	}
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.html b/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.html
new file mode 100644
index 0000000..2ba5d5c
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"  
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  
+      xml:lang="en"  
+      lang="en"> 
+
+<wicket:extend>
+<body onload="document.getElementById('name').focus();">
+	
+<div class="container">
+	<!-- page header -->
+	<div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
+		<span class="project"><wicket:message key="gb.newMilestone"></wicket:message></span>
+	</div>
+
+	<form style="padding-top:5px;" wicket:id="editForm">
+	<div class="row">
+	<div class="span12">
+		<!-- New Milestone Table -->
+		<table class="ticket">
+			<tr><th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="name" id="name"></input></td></tr>
+			<tr><th><wicket:message key="gb.due"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="due"></input> &nbsp;<span class="help-inline" wicket:id="dueFormat"></span></td></tr>
+		</table>
+	</div>
+	</div>	
+
+	<div class="row">
+	<div class="span12">
+		<div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Create" wicket:message="value:gb.create" wicket:id="create" /> &nbsp; <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /></div>
+	</div>
+	</div>
+	</form>
+</div>
+</body>
+
+</wicket:extend>
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.java b/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.java
new file mode 100644
index 0000000..a9f76d3
--- /dev/null
+++ b/src/main/java/com/gitblit/wicket/pages/NewMilestonePage.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.util.Date;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.RestartResponseException;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.form.AjaxButton;
+import org.apache.wicket.extensions.markup.html.form.DateTextField;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.Button;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.utils.TimeUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+
+/**
+ * Page for creating a new milestone.
+ *
+ * @author James Moger
+ *
+ */
+public class NewMilestonePage extends RepositoryPage {
+
+	private IModel<String> nameModel;
+
+	private IModel<Date> dueModel;
+
+	public NewMilestonePage(PageParameters params) {
+		super(params);
+
+		RepositoryModel model = getRepositoryModel();
+		if (!app().tickets().isAcceptingTicketUpdates(model)) {
+			// ticket service is read-only
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		UserModel currentUser = GitBlitWebSession.get().getUser();
+		if (currentUser == null) {
+			currentUser = UserModel.ANONYMOUS;
+		}
+
+		if (!currentUser.isAuthenticated || !currentUser.canAdmin(model)) {
+			// administration prohibited
+			throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+		}
+
+		setStatelessHint(false);
+		setOutputMarkupId(true);
+
+		Form<Void> form = new Form<Void>("editForm");
+		add(form);
+
+		nameModel = Model.of("");
+		dueModel = Model.of(new Date(System.currentTimeMillis() + TimeUtils.ONEDAY));
+
+		form.add(new TextField<String>("name", nameModel));
+		form.add(new DateTextField("due", dueModel, "yyyy-MM-dd"));
+		form.add(new Label("dueFormat", "yyyy-MM-dd"));
+
+		form.add(new AjaxButton("create") {
+
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
+				String name = nameModel.getObject();
+				if (StringUtils.isEmpty(name)) {
+					// invalid name
+					return;
+				}
+
+				TicketMilestone milestone = app().tickets().getMilestone(getRepositoryModel(), name);
+				if (milestone != null) {
+					// milestone already exists
+					return;
+				}
+
+				Date due = dueModel.getObject();
+
+				UserModel currentUser = GitBlitWebSession.get().getUser();
+				String createdBy = currentUser.username;
+
+				milestone = app().tickets().createMilestone(getRepositoryModel(), name, createdBy);
+				if (milestone != null) {
+					milestone.due = due;
+					app().tickets().updateMilestone(getRepositoryModel(), milestone, createdBy);
+					throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(getRepositoryModel().name));
+				} else {
+					// TODO error
+				}
+			}
+		});
+
+		Button cancel = new Button("cancel") {
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			public void onSubmit() {
+				setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+			}
+		};
+		cancel.setDefaultFormProcessing(false);
+		form.add(cancel);
+
+	}
+
+	@Override
+	protected String getPageName() {
+		return getString("gb.newMilestone");
+	}
+
+	@Override
+	protected Class<? extends BasePage> getRepoNavPageClass() {
+		return TicketsPage.class;
+	}
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.html b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html
index 7d13852..a40d312 100644
--- a/src/main/java/com/gitblit/wicket/pages/TicketsPage.html
+++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html
@@ -139,14 +139,51 @@
 	</div>
 	<div class="tab-pane" id="milestones">
 		<div class="row">
-			<div class="span9" wicket:id="milestoneList" style="padding-bottom: 10px;">
-				<h3><span wicket:id="milestoneName"></span> <small><span wicket:id="milestoneState"></span></small></h3>
-				<i style="color:#888;"class="fa fa-calendar"></i> <span wicket:id="milestoneDue"></span>
+			<span class="span12" style="padding-bottom:10px;" wicket:id="newMilestone"></span>
+		</div>
+
+		<div class="row">
+			<div class="span12"><h2><wicket:message key="gb.openMilestones"></wicket:message></h2></div>
+			<div class="span12"><hr/></div>
+		</div>		
+		<div class="row">
+			<div class="span4" wicket:id="openMilestonesList" style="padding-bottom: 20px;">
+				<div wicket:id="entryPanel"></div>
 			</div>
 		</div>
+
+		<div class="row">
+			<div class="span12"><h2><wicket:message key="gb.closedMilestones"></wicket:message></h2></div>
+			<div class="span12"><hr/></div>
+		</div>		
+		<div class="row">
+			<div class="span4" wicket:id="closedMilestonesList" style="padding-bottom: 15px;">
+				<div wicket:id="entryPanel"></div>
+			</div>
+		</div>
+		
 	</div>
 </div>
 
+<wicket:fragment wicket:id="milestoneListFragment">
+	<h3><span wicket:id="milestoneName"></span> <small><span wicket:id="milestoneState"></span></small></h3>
+	<i style="color:#888;"class="fa fa-calendar"></i> <span wicket:id="milestoneDue"></span> <span wicket:id="editMilestone"></span>
+	<div wicket:id="milestonePanel"></div>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="openMilestoneFragment">
+	<div style="clear:both;padding-bottom: 10px;">
+		<div style="margin-bottom: 5px;" class="progress progress-success">
+			<div class="bar" wicket:id="progress"></div>
+		</div>
+		<div class="milestoneOverview">
+			<span wicket:id="openTickets" />,
+			<span wicket:id="closedTickets" />,
+			<span wicket:id="totalTickets" />
+		</div>
+	</div>
+</wicket:fragment>
+
 <wicket:fragment wicket:id="noMilestoneFragment">
 <table style="width: 100%;padding-bottom: 5px;">
 <tbody>
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.java b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java
index ca509e2..5973d47 100644
--- a/src/main/java/com/gitblit/wicket/pages/TicketsPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java
@@ -20,6 +20,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
@@ -42,6 +43,7 @@
 import com.gitblit.Constants.AccessPermission;
 import com.gitblit.Keys;
 import com.gitblit.models.RegistrantAccessPermission;
+import com.gitblit.models.RepositoryModel;
 import com.gitblit.models.TicketModel;
 import com.gitblit.models.TicketModel.Status;
 import com.gitblit.models.UserModel;
@@ -646,38 +648,120 @@
 		};
 		add(ticketsView);
 
-		List<TicketMilestone> allMilestones = app().tickets().getMilestones(getRepositoryModel());
-		ListDataProvider<TicketMilestone> allMilestonesDp = new ListDataProvider<TicketMilestone>(allMilestones);
-		DataView<TicketMilestone> milestonesList = new DataView<TicketMilestone>("milestoneList", allMilestonesDp) {
+		// new milestone link
+		RepositoryModel repositoryModel = getRepositoryModel();
+		final boolean acceptingUpdates = app().tickets().isAcceptingTicketUpdates(repositoryModel)
+				 && user != null && user.canAdmin(getRepositoryModel());
+		if (acceptingUpdates) {
+			add(new LinkPanel("newMilestone", null, getString("gb.newMilestone"),
+				NewMilestonePage.class, WicketUtils.newRepositoryParameter(repositoryName)));
+		} else {
+			add(new Label("newMilestone").setVisible(false));
+		}
+
+		// milestones list
+		List<TicketMilestone> openMilestones = new ArrayList<TicketMilestone>();
+		List<TicketMilestone> closedMilestones = new ArrayList<TicketMilestone>();
+		for (TicketMilestone milestone : app().tickets().getMilestones(repositoryModel)) {
+			if (milestone.isOpen()) {
+				openMilestones.add(milestone);
+			} else {
+				closedMilestones.add(milestone);
+			}
+		}
+		Collections.sort(openMilestones, new Comparator<TicketMilestone>() {
+			@Override
+			public int compare(TicketMilestone o1, TicketMilestone o2) {
+				return o2.due.compareTo(o1.due);
+			}
+		});
+
+		Collections.sort(closedMilestones, new Comparator<TicketMilestone>() {
+			@Override
+			public int compare(TicketMilestone o1, TicketMilestone o2) {
+				return o2.due.compareTo(o1.due);
+			}
+		});
+
+		DataView<TicketMilestone> openMilestonesList = milestoneList("openMilestonesList", openMilestones, acceptingUpdates);
+		add(openMilestonesList);
+
+		DataView<TicketMilestone> closedMilestonesList = milestoneList("closedMilestonesList", closedMilestones, acceptingUpdates);
+		add(closedMilestonesList);
+	}
+
+	protected DataView<TicketMilestone> milestoneList(String wicketId, List<TicketMilestone> milestones, final boolean acceptingUpdates) {
+		ListDataProvider<TicketMilestone> milestonesDp = new ListDataProvider<TicketMilestone>(milestones);
+		DataView<TicketMilestone> milestonesList = new DataView<TicketMilestone>(wicketId, milestonesDp) {
 			private static final long serialVersionUID = 1L;
 
 			@Override
 			public void populateItem(final Item<TicketMilestone> item) {
+				Fragment entryPanel = new Fragment("entryPanel", "milestoneListFragment", this);
+				item.add(entryPanel);
+
 				final TicketMilestone tm = item.getModelObject();
-				PageParameters params = queryParameters(null, tm.name, null, null, null, desc, 1);
-				item.add(new LinkPanel("milestoneName", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true));
+				PageParameters params = queryParameters(null, tm.name, null, null, null, true, 1);
+				entryPanel.add(new LinkPanel("milestoneName", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true));
 
 				String css;
+				String status = tm.status.name();
 				switch (tm.status) {
 				case Open:
-					css = "aui-lozenge aui-lozenge-subtle";
+					if (tm.isOverdue()) {
+						css = "aui-lozenge aui-lozenge-subtle aui-lozenge-error";
+						status = "overdue";
+					} else {
+						css = "aui-lozenge aui-lozenge-subtle";
+					}
 					break;
 				default:
 					css = "aui-lozenge";
 					break;
 				}
-				Label stateLabel = new Label("milestoneState", tm.status.name());
+				Label stateLabel = new Label("milestoneState", status);
 				WicketUtils.setCssClass(stateLabel, css);
-				item.add(stateLabel);
+				entryPanel.add(stateLabel);
 
 				if (tm.due == null) {
-					item.add(new Label("milestoneDue", getString("gb.notSpecified")));
+					entryPanel.add(new Label("milestoneDue", getString("gb.notSpecified")));
 				} else {
-					item.add(WicketUtils.createDatestampLabel("milestoneDue", tm.due, getTimeZone(), getTimeUtils()));
+					entryPanel.add(WicketUtils.createDatestampLabel("milestoneDue", tm.due, getTimeZone(), getTimeUtils()));
+				}
+				if (acceptingUpdates) {
+					entryPanel.add(new LinkPanel("editMilestone", null, getString("gb.edit"), EditMilestonePage.class,
+						WicketUtils.newObjectParameter(repositoryName, tm.name)));
+				} else {
+					entryPanel.add(new Label("editMilestone").setVisible(false));
+				}
+
+				if (tm.isOpen()) {
+					// re-load milestone with query results
+					TicketMilestone m = app().tickets().getMilestone(getRepositoryModel(), tm.name);
+
+					Fragment milestonePanel = new Fragment("milestonePanel", "openMilestoneFragment", this);
+					Label label = new Label("progress");
+					WicketUtils.setCssStyle(label, "width:" + tm.getProgress() + "%;");
+					milestonePanel.add(label);
+
+					milestonePanel.add(new LinkPanel("openTickets", null,
+							MessageFormat.format(getString("gb.nOpenTickets"), m.getOpenTickets()),
+							TicketsPage.class,
+							queryParameters(null, tm.name, openStatii, null, null, true, 1)));
+
+					milestonePanel.add(new LinkPanel("closedTickets", null,
+							MessageFormat.format(getString("gb.nClosedTickets"), m.getClosedTickets()),
+							TicketsPage.class,
+							queryParameters(null, tm.name, closedStatii, null, null, true, 1)));
+
+					milestonePanel.add(new Label("totalTickets", MessageFormat.format(getString("gb.nTotalTickets"), m.getTotalTickets())));
+					entryPanel.add(milestonePanel);
+				} else {
+					entryPanel.add(new Label("milestonePanel").setVisible(false));
 				}
 			}
 		};
-		add(milestonesList);
+		return milestonesList;
 	}
 
 	protected PageParameters queryParameters(

--
Gitblit v1.9.1