diff --git a/pom.xml b/pom.xml index 348d87c..0390fcf 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 1.2.2 1.7.5 6.6.0 + 3.2.1 @@ -133,9 +134,9 @@ 1.1.1-SNAPSHOT - org.avaje - ebean - 2.8.1 + org.avaje.ebeanorm + avaje-ebeanorm + ${ebean.version} org.ocpsoft.prettytime @@ -241,9 +242,9 @@ - org.avaje - ebean-maven-enhancement-plugin - 2.8.1 + org.avaje.ebeanorm + avaje-ebeanorm-mavenenhancer + ${ebean.version} process-classes diff --git a/src/main/java/org/lbogdanov/poker/core/Constants.java b/src/main/java/org/lbogdanov/poker/core/Constants.java index 8404803..35f783f 100644 --- a/src/main/java/org/lbogdanov/poker/core/Constants.java +++ b/src/main/java/org/lbogdanov/poker/core/Constants.java @@ -33,6 +33,8 @@ public final class Constants { public static final int USER_LAST_NAME_MAX_LENGTH = 128; public static final int USER_EMAIL_MAX_LENGTH = 254; public static final int USER_EXTERNAL_ID_MAX_LENGTH = 64; + public static final int ITEM_TITLE_MAX_LENGTH = 128; + public static final int ITEM_DESCRIPTION_MAX_LENGTH = 4096; public static final String OAUTH_FILTER_URL = "oauth"; public static final String OAUTH_CLBK_FILTER_URL = "oauth-clbk"; diff --git a/src/main/java/org/lbogdanov/poker/core/Duration.java b/src/main/java/org/lbogdanov/poker/core/Estimate.java similarity index 61% rename from src/main/java/org/lbogdanov/poker/core/Duration.java rename to src/main/java/org/lbogdanov/poker/core/Estimate.java index b8ad9e8..1453846 100644 --- a/src/main/java/org/lbogdanov/poker/core/Duration.java +++ b/src/main/java/org/lbogdanov/poker/core/Estimate.java @@ -15,26 +15,38 @@ */ package org.lbogdanov.poker.core; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import javax.persistence.*; + import com.google.common.base.Joiner; +import com.google.common.base.Objects; import com.google.common.base.Preconditions; -import com.google.common.primitives.Ints; - /** - * Represents a time interval stored as a number of minutes, is used to represent an estimate in a Planning Poker game. + * Represents a session item estimate. * * @author Alexandra Fomina */ -public class Duration implements Comparable { +@Entity +@Table(name = "ESTIMATES") +@IdClass(EstimatePrimaryKey.class) +public class Estimate implements Serializable { static final int MINUTES_PER_HOUR = 60; static final int MINUTES_PER_DAY = MINUTES_PER_HOUR * 8; static final int MINUTES_PER_WEEK = MINUTES_PER_DAY * 5; - private int minutes; + @ManyToOne + @PrimaryKeyJoinColumn(name = "USER_ID", referencedColumnName = "ID") + private User user; + @ManyToOne + @PrimaryKeyJoinColumn(name = "ITEM_ID", referencedColumnName = "ID") + private Item item; + @Column(name = "ESTIMATE", nullable = false) + private Integer value; /** * Parses the given String to a List of Duration objects. @@ -46,8 +58,8 @@ public class Duration implements Comparable { * @return the durations represented by the given String * @throws IllegalArgumentException if the given String doesn't matches syntax */ - public static List parse(String input) { - List durations = new ArrayList(); + public static List parse(String input) { + List durations = new ArrayList(); StringBuilder duration = new StringBuilder(); for (char chr : input.toCharArray()) { if (Character.isWhitespace(chr) || chr == ',' || chr == ';') { @@ -73,7 +85,7 @@ public static List parse(String input) { default: throw new IllegalArgumentException(duration.toString() + chr); } - durations.add(new Duration(Integer.parseInt(duration.toString()) * mul)); + durations.add(new Estimate(Integer.parseInt(duration.toString()) * mul)); duration.setLength(0); } } @@ -84,38 +96,98 @@ public static List parse(String input) { } /** - * Creates a zero valued Duration instance. + * Creates a zero valued Estimate instance. + */ + public Estimate() { + value = 0; + } + + /** + * Creates an Estimate instance with a specified value. + * + * @param value the estimate value in minutes + */ + public Estimate(int value) { + setValue(value); + } + + /** + * Returns an estimate author. + * + * @return the user */ - public Duration() { - this(0); + public User getUser() { + return user; } /** - * Creates a Duration instance with a specified number of minutes. + * Sets an estimate author. * - * @param minutes the initial number of minutes, must be non-negative + * @param user the user to set */ - public Duration(int minutes) { - setMinutes(minutes); + public void setUser(User user) { + this.user = user; } /** - * Returns a number of minutes in this time interval. + * Returns an item evaluated by an estimate. * - * @return the minutes the number of minutes + * @return the item */ - public int getMinutes() { - return minutes; + public Item getItem() { + return item; } /** - * Sets a number of minutes that this time interval has. + * Sets an estimation item. * - * @param minutes the number of minutes, must be non-negative + * @param item the item to set + */ + public void setItem(Item item) { + this.item = item; + } + + /** + * Returns an estimate value. + * + * @return the estimate value + */ + public Integer getValue() { + return value; + } + + /** + * Sets an estimate value. + * + * @param value the estimate value to set, must be non-negative + */ + public void setValue(Integer value) { + Preconditions.checkArgument(value >= 0, "Value must be non-negative"); + this.value = value; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hashCode(getItem(), getUser()); + } + + /** + * {@inheritDoc} */ - public void setMinutes(int minutes) { - Preconditions.checkArgument(minutes >= 0, "Value must be non-negative"); - this.minutes = minutes; + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof Estimate) { + Estimate other = (Estimate) obj; + return Objects.equal(this.getItem(), other.getItem()) + && Objects.equal(this.getUser(), other.getUser()); + } + return false; } /** @@ -123,7 +195,7 @@ public void setMinutes(int minutes) { */ @Override public String toString() { - int n = minutes; + int n = value; if (n == 0) { return "0"; } @@ -146,34 +218,4 @@ public String toString() { return Joiner.on(' ').join(duration); } - /** - * {@inheritDoc} - */ - @Override - public int compareTo(Duration that) { - return Ints.compare(this.minutes, that.minutes); - } - - /** - * {@inheritDoc} - */ - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (other instanceof Duration) { - return this.getMinutes() == ((Duration) other).getMinutes(); - } - return false; - } - - /** - * {@inheritDoc} - */ - @Override - public int hashCode() { - return getMinutes(); - } - } diff --git a/src/main/java/org/lbogdanov/poker/core/EstimatePrimaryKey.java b/src/main/java/org/lbogdanov/poker/core/EstimatePrimaryKey.java new file mode 100644 index 0000000..ab7d839 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/core/EstimatePrimaryKey.java @@ -0,0 +1,68 @@ +package org.lbogdanov.poker.core; + +import java.io.Serializable; + +import com.google.common.base.Objects; + +/** + * A primary key for {@link Estimate} class. + * + * @author afomina + */ +public class EstimatePrimaryKey implements Serializable { + + private User user; + private Item item; + + /** + * @return the user + */ + public User getUser() { + return user; + } + + /** + * @param user the user to set + */ + public void setUser(User user) { + this.user = user; + } + + /** + * @return the item + */ + public Item getItem() { + return item; + } + + /** + * @param item the item to set + */ + public void setItem(Item item) { + this.item = item; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hashCode(getItem(), getUser()); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof EstimatePrimaryKey) { + EstimatePrimaryKey other = (EstimatePrimaryKey) obj; + return Objects.equal(this.getItem(), other.getItem()) + && Objects.equal(this.getUser(), other.getUser()); + } + return false; + } +} diff --git a/src/main/java/org/lbogdanov/poker/core/Item.java b/src/main/java/org/lbogdanov/poker/core/Item.java new file mode 100644 index 0000000..da9ecc4 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/core/Item.java @@ -0,0 +1,92 @@ +package org.lbogdanov.poker.core; + +import static org.lbogdanov.poker.core.Constants.ITEM_DESCRIPTION_MAX_LENGTH; +import static org.lbogdanov.poker.core.Constants.ITEM_TITLE_MAX_LENGTH; + +import java.util.Collections; +import java.util.List; + +import javax.persistence.*; + +import com.google.common.base.Objects; + +/** + * Represents an item for Planning Poker session evaluation. + * + * @author Alexandra Fomina + */ +@Entity +@Table(name = "ITEMS") +public class Item extends AbstractEntity { + + @Column(name = "TITLE", length = ITEM_TITLE_MAX_LENGTH, nullable = false) + private String title = ""; + @Column(name = "DESCRIPTION", length = ITEM_DESCRIPTION_MAX_LENGTH, nullable = true) + private String description = ""; + @ManyToOne + @JoinColumn(name = "SESSION_ID", nullable = false) + private Session session; + @OneToMany(mappedBy = "item", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List estimates = Collections.emptyList(); + + /** + * Returns an item title. + * + * @return the name + */ + public String getTitle() { + return title; + } + + /** + * Sets an item title. + * + * @param title the title to set + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Returns an item description. + * + * @return the description + */ + public String getDescription() { + return description; + } + + /** + * Sets an item description. + * + * @param description the description to set + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hashCode(getTitle()); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof Item) { + Item other = (Item) obj; + return Objects.equal(this.getTitle(), other.getTitle()) && + Objects.equal(this.getDescription(), other.getDescription()); + } + return false; + } + +} diff --git a/src/main/java/org/lbogdanov/poker/core/ItemService.java b/src/main/java/org/lbogdanov/poker/core/ItemService.java new file mode 100644 index 0000000..cbffc63 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/core/ItemService.java @@ -0,0 +1,40 @@ +package org.lbogdanov.poker.core; + +/** + * A service to manipulate an {@link Item} instances and its estimates. + * + * @author Alexandra Fomina + */ +public interface ItemService { + + /** + * Deletes an item. + * + * @param item the item to delete + */ + public void delete(Item item); + + /** + * Persists an item object in a storage. + * + * @param item the item to save + */ + public void save(Item item); + + /** + * Approves a specified estimate. + * + * @param estimate the estimate to approve + */ + public void approve(Estimate estimate); + + /** + * Returns a specified user estimate for a specified item. + * + * @param user the estimate author + * @param item the estimate item + * @return the estimate + */ + public Estimate find(User user, Item item); + +} diff --git a/src/main/java/org/lbogdanov/poker/core/Session.java b/src/main/java/org/lbogdanov/poker/core/Session.java index fd485c9..b8e62f0 100644 --- a/src/main/java/org/lbogdanov/poker/core/Session.java +++ b/src/main/java/org/lbogdanov/poker/core/Session.java @@ -20,11 +20,14 @@ import static org.lbogdanov.poker.core.Constants.SESSION_ESTIMATES_MAX_LENGTH; import static org.lbogdanov.poker.core.Constants.SESSION_NAME_MAX_LENGTH; +import java.util.Collections; import java.util.Date; +import java.util.List; import javax.persistence.*; import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; /** @@ -50,6 +53,8 @@ public class Session extends AbstractEntity { private User author; @Column(name = "ESTIMATES", length = SESSION_ESTIMATES_MAX_LENGTH, nullable = false) private String estimates; + @OneToMany(mappedBy = "session", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List items = Collections.emptyList(); /** * Returns a session name. @@ -157,6 +162,24 @@ public void setEstimates(String estimates) { this.estimates = estimates; } + /** + * Returns session items. + * + * @return the items + */ + public List getItems() { + return ImmutableList.copyOf(items); + } + + /** + * Sets session items. + * + * @param items the items to set + */ + public void setItems(List items) { + this.items = items; + } + /** * {@inheritDoc} */ diff --git a/src/main/java/org/lbogdanov/poker/core/SessionService.java b/src/main/java/org/lbogdanov/poker/core/SessionService.java index 1348b0d..ff1361f 100644 --- a/src/main/java/org/lbogdanov/poker/core/SessionService.java +++ b/src/main/java/org/lbogdanov/poker/core/SessionService.java @@ -76,4 +76,11 @@ public interface SessionService { */ public void delete(Session session); + /** + * Persists a specified session object in a storage. + * + * @param session the session to save + */ + public void save(Session session); + } diff --git a/src/main/java/org/lbogdanov/poker/core/impl/ItemServiceImpl.java b/src/main/java/org/lbogdanov/poker/core/impl/ItemServiceImpl.java new file mode 100644 index 0000000..86bc0f0 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/core/impl/ItemServiceImpl.java @@ -0,0 +1,59 @@ +package org.lbogdanov.poker.core.impl; + +import javax.inject.Inject; + +import org.lbogdanov.poker.core.*; + +import com.avaje.ebean.EbeanServer; +import com.avaje.ebean.annotation.Transactional; + +/** + * An {@link ItemService} interface implementation. + * + * @author Alexandra Fomina + */ +public class ItemServiceImpl implements ItemService { + + @Inject + private EbeanServer ebean; + @Inject + private UserService userService; + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void delete(Item item) { + ebean.delete(item); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void save(Item item) { + ebean.save(item); + } + + /** + * {@inheritDoc} + */ + @Override + public void approve(Estimate estimate) { + if (estimate.getUser() == null) { + estimate.setUser(userService.getCurrentUser()); + } + ebean.save(estimate); + } + + /** + * {@inheritDoc} + */ + @Override + public Estimate find(User user, Item item) { + return ebean.find(Estimate.class).where().eq("user", user).eq("item", item).findUnique(); + } + +} diff --git a/src/main/java/org/lbogdanov/poker/core/impl/SessionServiceImpl.java b/src/main/java/org/lbogdanov/poker/core/impl/SessionServiceImpl.java index 3b248f1..a0c3528 100644 --- a/src/main/java/org/lbogdanov/poker/core/impl/SessionServiceImpl.java +++ b/src/main/java/org/lbogdanov/poker/core/impl/SessionServiceImpl.java @@ -25,10 +25,9 @@ import javax.inject.Singleton; import org.lbogdanov.poker.core.*; +import org.lbogdanov.poker.core.PagingList; -import com.avaje.ebean.EbeanServer; -import com.avaje.ebean.ExpressionList; -import com.avaje.ebean.Query; +import com.avaje.ebean.*; import com.avaje.ebean.annotation.Transactional; import com.google.common.base.Strings; @@ -83,13 +82,24 @@ public Session find(String code) { @Override @Transactional(readOnly = true) public PagingList find(User user, String name, String orderBy, boolean ascending, int pageSize) { - // TODO Union with session where the user participated - ExpressionList expr = ebean.find(Session.class) - .where().eq("author", user); + //TODO Pagination doesn't work with raw sql + RawSql rawSql = RawSqlBuilder.parse("select s.id, s.name, s.code, s.created, s.description, s.estimates, s.author_id" + + " from sessions s " + + "left outer join items i on i.session_id=s.id " + + "left outer join estimates e on e.item_id=i.id " + + "where e.user_id = ? or s.author_id = ?") + .columnMapping("s.name", "name") + .columnMapping("s.description", "description") + .columnMapping("s.created", "created") + .columnMapping("s.author_id", "author.id") + .create(); + ExpressionList expr = ebean.find(Session.class).setRawSql(rawSql) + .setParameter(1, user.getId()).setParameter(2, user.getId()) + .where(); if (!Strings.isNullOrEmpty(name)) { expr = expr.ilike("name", name); } - Query query = ascending ? expr.orderBy().asc(orderBy) : expr.orderBy().asc(orderBy); + Query query = ascending ? expr.orderBy().asc(orderBy) : expr.orderBy().desc(orderBy); return new EbeanPagingList(query.findPagingList(pageSize)); } @@ -118,6 +128,15 @@ public void delete(Session session) { ebean.delete(session); } + /** + * {@inheritDoc} + */ + @Override + @Transactional + public void save(Session session) { + ebean.save(session); + } + /** * Generates a new alphanumeric code of a specified length which can be used to uniquely identify a session. * diff --git a/src/main/java/org/lbogdanov/poker/web/AppInitializer.java b/src/main/java/org/lbogdanov/poker/web/AppInitializer.java index c0c0880..b89c3f4 100644 --- a/src/main/java/org/lbogdanov/poker/web/AppInitializer.java +++ b/src/main/java/org/lbogdanov/poker/web/AppInitializer.java @@ -37,6 +37,7 @@ import org.atmosphere.cpr.ApplicationConfig; import org.atmosphere.cpr.MeteorServlet; import org.lbogdanov.poker.core.*; +import org.lbogdanov.poker.core.impl.ItemServiceImpl; import org.lbogdanov.poker.core.impl.SessionServiceImpl; import org.lbogdanov.poker.core.impl.UserServiceImpl; import org.lbogdanov.poker.util.Settings; @@ -152,10 +153,14 @@ protected void configureServlets() { dbConfig.setDefaultServer(true); dbConfig.addClass(Session.class); dbConfig.addClass(User.class); + dbConfig.addClass(Item.class); + dbConfig.addClass(Estimate.class); + dbConfig.addClass(EstimatePrimaryKey.class); bind(EbeanServer.class).toInstance(EbeanServerFactory.create(dbConfig)); bind(SessionService.class).to(SessionServiceImpl.class); bind(UserService.class).to(UserServiceImpl.class); + bind(ItemService.class).to(ItemServiceImpl.class); bind(WebApplication.class).to(PokerWebApplication.class); bind(MeteorServlet.class).in(Singleton.class); bind(ObjectMapper.class).toProvider(new Provider() { diff --git a/src/main/java/org/lbogdanov/poker/web/markup/ItemPanel.java b/src/main/java/org/lbogdanov/poker/web/markup/ItemPanel.java new file mode 100644 index 0000000..29c96ae --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/web/markup/ItemPanel.java @@ -0,0 +1,156 @@ +package org.lbogdanov.poker.web.markup; + +import static org.lbogdanov.poker.core.Estimate.parse; + +import javax.inject.Inject; + +import org.apache.wicket.Component; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.markup.html.AjaxFallbackLink; +import org.apache.wicket.ajax.markup.html.form.AjaxFallbackButton; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.event.Broadcast; +import org.apache.wicket.extensions.ajax.markup.html.AjaxEditableLabel; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.FormComponent; +import org.apache.wicket.markup.html.form.HiddenField; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.link.Link; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.model.PropertyModel; +import org.lbogdanov.poker.core.*; +import org.lbogdanov.poker.web.plugin.SimpleSliderPlugin; +import org.lbogdanov.poker.web.util.ItemChangeEvent; +import org.lbogdanov.poker.web.util.ItemRemoveEvent; +import org.lbogdanov.poker.web.util.Message; + +import com.google.common.base.Objects; +import com.google.common.base.Strings; + +/** + * Represents an UI for Planning Poker session item. + * + * @author Alexandra Fomina + */ +public class ItemPanel extends Panel { + + @Inject + private ItemService itemService; + @Inject + private UserService userService; + private Link removeLink; + private boolean isModerator; + + @SuppressWarnings("unchecked") + public ItemPanel(String id, final Item item, final Session session, Object origin) { + super(id, Model.of(item)); + if (origin instanceof User) { + isModerator = Objects.equal(origin, session.getAuthor()); + } else { + isModerator = Objects.equal(origin, getSession().getId()); + } + Component title; + Component description; + if (isModerator) { + title = new AjaxEditableLabel("title", new PropertyModel(item, "title")) { + + @Override + protected void onSubmit(AjaxRequestTarget target) { + String title = getEditor().getModelObject(); + if (!Objects.equal(item.getTitle(), title)) { + item.setTitle(title); + itemChanged(item); + } + super.onSubmit(target); + } + + @Override + protected FormComponent newEditor(MarkupContainer parent, String componentId, IModel model) { + return super.newEditor(parent, componentId, model).setRequired(true); + } + + @Override + protected void onError(AjaxRequestTarget target) { + if (target != null) { + target.add(this); + } + } + + }; + description = new AjaxEditableLabel("description", new PropertyModel(item, "description")) { + + @Override + protected void onSubmit(AjaxRequestTarget target) { + String description = getEditor().getModelObject(); + if (!Objects.equal(item.getDescription(), description)) { + item.setDescription(Strings.nullToEmpty(description)); + itemChanged(item); + } + super.onSubmit(target); + } + + }; + } else { + title = new Label("title", PropertyModel.of(getDefaultModel(), "title")); + description = new Label("description", PropertyModel.of(getDefaultModel(), "description")) + .add(new AttributeAppender("id", "description")); + } + removeLink = new AjaxFallbackLink("remove", (IModel) getDefaultModel()) { + + @Override + public void onClick(AjaxRequestTarget target) { + Item item = this.getModelObject(); + itemService.delete(item); + send(getPage(), Broadcast.BREADTH, new ItemRemoveEvent(null, item)); + } + + }; + + Form estimationForm = new Form("estimation"); + estimationForm.add(new TextField("estIdx", Model.of("")).add(AttributeAppender.append("data-slider-range", "0," + (session.getEstimates().split("\\s").length - 1))), + new HiddenField("estimate", Model.of("")), new AjaxFallbackButton("submit", estimationForm) { + + @Override + protected void onSubmit(AjaxRequestTarget target, Form form) { + Estimate estimate = itemService.find(userService.getCurrentUser(), item); + if (estimate == null) { + estimate = new Estimate(); + } + String est = ((TextField) form.get("estimate")).getModelObject(); + estimate.setValue(parse(est).get(0).getValue()); + if (estimate.getItem() == null) { + estimate.setItem(item); + } + itemService.approve(estimate); + } + + }); + add(title.setOutputMarkupId(true), description, removeLink, estimationForm.setOutputMarkupId(true)); + + } + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + response.render(JavaScriptHeaderItem.forReference(SimpleSliderPlugin.get())); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + removeLink.setVisible(isModerator); + } + + private void itemChanged(Item item) { + itemService.save(item); + Message msg = new ItemChangeEvent(getSession().getId(), item); + send(getPage(), Broadcast.BREADTH, msg); + } + +} \ No newline at end of file diff --git a/src/main/java/org/lbogdanov/poker/web/page/IndexPage.java b/src/main/java/org/lbogdanov/poker/web/page/IndexPage.java index e8ccbb5..a0b5555 100644 --- a/src/main/java/org/lbogdanov/poker/web/page/IndexPage.java +++ b/src/main/java/org/lbogdanov/poker/web/page/IndexPage.java @@ -40,7 +40,7 @@ import org.apache.wicket.validation.IValidatable; import org.apache.wicket.validation.IValidator; import org.apache.wicket.validation.ValidationError; -import org.lbogdanov.poker.core.Duration; +import org.lbogdanov.poker.core.Estimate; import org.lbogdanov.poker.core.Session; import org.lbogdanov.poker.core.SessionService; import org.lbogdanov.poker.core.UserService; @@ -178,7 +178,7 @@ protected void onError(AjaxRequestTarget target, Form form) { @Override public void validate(IValidatable validatable) { try { - Duration.parse(validatable.getValue()); + Estimate.parse(validatable.getValue()); } catch (IllegalArgumentException e) { ValidationError error = new ValidationError(); error.addKey("session.create.estimates.invalidEstimate").setVariable("estimate", e.getMessage()); diff --git a/src/main/java/org/lbogdanov/poker/web/page/SessionPage.java b/src/main/java/org/lbogdanov/poker/web/page/SessionPage.java index 8d97897..366e023 100644 --- a/src/main/java/org/lbogdanov/poker/web/page/SessionPage.java +++ b/src/main/java/org/lbogdanov/poker/web/page/SessionPage.java @@ -18,8 +18,7 @@ import static org.lbogdanov.poker.core.Constants.LABEL_MAX_LENGTH; import java.text.DateFormat; -import java.util.Date; -import java.util.Locale; +import java.util.*; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -36,35 +35,49 @@ import org.apache.wicket.atmosphere.EventBus; import org.apache.wicket.atmosphere.ResourceRegistrationListener; import org.apache.wicket.atmosphere.Subscribe; +import org.apache.wicket.event.IEvent; import org.apache.wicket.markup.head.CssHeaderItem; import org.apache.wicket.markup.head.IHeaderResponse; import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.RequiredTextField; +import org.apache.wicket.markup.html.form.StatelessForm; import org.apache.wicket.markup.html.form.TextArea; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.markup.html.list.PropertyListView; +import org.apache.wicket.model.CompoundPropertyModel; +import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.CssResourceReference; import org.apache.wicket.request.resource.ResourceReference; +import org.apache.wicket.util.collections.MiniMap; +import org.apache.wicket.util.template.PackageTextTemplate; import org.atmosphere.cpr.AtmosphereResource; import org.atmosphere.cpr.Broadcaster; import org.atmosphere.cpr.BroadcasterFactory; import org.atmosphere.cpr.Meteor; +import org.lbogdanov.poker.core.Item; import org.lbogdanov.poker.core.Session; import org.lbogdanov.poker.core.SessionService; import org.lbogdanov.poker.core.UserService; import org.lbogdanov.poker.web.markup.BodylessLabel; +import org.lbogdanov.poker.web.markup.BootstrapFeedbackPanel; +import org.lbogdanov.poker.web.markup.ItemPanel; import org.lbogdanov.poker.web.markup.LimitableLabel; import org.lbogdanov.poker.web.plugin.CustomScrollbarPlugin; -import org.lbogdanov.poker.web.util.ChatMessage; -import org.lbogdanov.poker.web.util.Message; -import org.lbogdanov.poker.web.util.OriginFilter; +import org.lbogdanov.poker.web.util.*; import org.ocpsoft.prettytime.Duration; import org.ocpsoft.prettytime.PrettyTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Objects; import com.google.common.base.Strings; import com.google.common.collect.Iterables; @@ -119,7 +132,7 @@ private Subscriber() {} private static final Logger LOG = LoggerFactory.getLogger(SessionPage.class); private static final ResourceReference CSS = new CssResourceReference(SessionPage.class, "session.css"); - private static final ResourceReference JS = new PageScriptResourceReference(SessionPage.class, "session.js"); + private static final PackageTextTemplate JS_TEXT_TEMPLATE = new PackageTextTemplate(SessionPage.class, "session.js", "text/javascript"); @Inject private SessionService sessionService; @@ -161,6 +174,72 @@ protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { }); + WebMarkupContainer moderatorPane = new WebMarkupContainer("moderator") { + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(Objects.equal(userService.getCurrentUser(), session.getAuthor())); + } + + }; + IModel> itemModel = new IModel>() { + + @Override + public void detach() {} + + @Override + public List getObject() { + return sessionService.find(session.getCode()).getItems(); + } + + @Override + public void setObject(List object) { + session.setItems(object); + } + + }; + final ListView items = new PropertyListView("items", itemModel) { + + @Override + protected void populateItem(ListItem item) { + populateListItem(item, userService.getCurrentUser()); + } + + }; + Form itemForm = new StatelessForm("itemForm", new CompoundPropertyModel(new Item())); + itemForm.add(new RequiredTextField("title"), new TextArea("description"), + new AjaxFallbackButton("addItem", itemForm) { + + @SuppressWarnings("unchecked") + @Override + protected void onSubmit(AjaxRequestTarget target, Form form) { + Form itemForm = (Form) form; + Item item = itemForm.getModelObject(); + List sessionItems = new LinkedList(session.getItems()); + sessionItems.add(item); + session.setItems(sessionItems); + sessionService.save(session); + post(session.getCode(), new ItemMessage(getSession().getId(), item)); + itemForm.setModelObject(new Item()); + } + + @Override + protected void onError(AjaxRequestTarget target, Form form) { + if (target != null) { + target.add(form); + } + } + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { + super.updateAjaxAttributes(attributes); + attributes.getAjaxCallListeners().add( + new AjaxCallListener().onPrecondition("return $('#itemTitle').val().length > 0;")); + } + }); + moderatorPane.add(itemForm); + LimitableLabel name = new LimitableLabel("session.name", session.getName()); if (!Strings.isNullOrEmpty(session.getDescription())) { name.add(AttributeModifier.append("class", "tip"), @@ -170,7 +249,9 @@ protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { add(chatForm.setOutputMarkupId(true), name.setMaxLength(LABEL_MAX_LENGTH), new BodylessLabel("session.code", session.getCode()).setMaxLength(LABEL_MAX_LENGTH), new BodylessLabel("session.author", session.getAuthor()).setMaxLength(LABEL_MAX_LENGTH), - new BodylessLabel("session.created", formatDate(session.getCreated())).setMaxLength(LABEL_MAX_LENGTH)); + new BodylessLabel("session.created", formatDate(session.getCreated())).setMaxLength(LABEL_MAX_LENGTH), + new WebMarkupContainer("itemsContainer").add(items).setOutputMarkupId(true), moderatorPane.setOutputMarkupId(true), + new BootstrapFeedbackPanel("feedback").setOutputMarkupId(true)); } /** @@ -182,7 +263,28 @@ public void renderHead(IHeaderResponse response) { response.render(CssHeaderItem.forReference(CSS)); response.render(JavaScriptHeaderItem.forReference(I18N)); response.render(JavaScriptHeaderItem.forReference(CustomScrollbarPlugin.get())); - response.render(JavaScriptHeaderItem.forReference(JS)); + Map variables = new MiniMap(5); + try { + variables.put("estimates", mapper.writeValueAsString(session.getEstimates().split("\\s"))); + } catch (JsonProcessingException e) { + LOG.error(e.getMessage()); + } + response.render(JavaScriptHeaderItem.forScript(JS_TEXT_TEMPLATE.asString(variables), "")); + } + + @Subscribe + @SuppressWarnings("unchecked") + public void appendItem(AjaxRequestTarget target, ItemMessage msg) throws JsonProcessingException { + Item item = msg.message; + ListView listView = (ListView) get("itemsContainer").get("items"); + ListItem listItem = populateListItem(new ListItem(listView.size(), Model.of(item)), msg.origin); + listView.add(listItem.setOutputMarkupId(true)); + msg.setMarkupId(listItem.getMarkupId()); + String jsonMsg = mapper.writeValueAsString(msg); + target.prependJavaScript(String.format("Poker.appendItem(%s);", jsonMsg)); + target.add(listItem, get("feedback")); + target.appendJavaScript(String.format("Poker.slider(%s);", jsonMsg)); + success(SessionPage.this.getString("item.new", Model.of(item))); } /** @@ -201,6 +303,28 @@ public void publishMessage(AjaxRequestTarget target, Message msg) throws Exce } } + @Subscribe + public void removeItem(AjaxRequestTarget target, ItemRemoveEvent event) { + warn(SessionPage.this.getString("item.removed", Model.of(event.message))); + target.add(get("feedback")); + } + + @Subscribe(filter = OriginFilter.class) + public void editItem(AjaxRequestTarget target, ItemChangeEvent event) throws Exception { + info(SessionPage.this.getString("item.modified", Model.of(event.message))); + target.add(get("feedback")); + } + + @Override + public void onEvent(IEvent event) { + Object payload = event.getPayload(); + if (payload instanceof ItemRemoveEvent) { + post(session.getCode(), (ItemRemoveEvent) payload); + } else if (payload instanceof ItemChangeEvent) { + post(session.getCode(), (ItemChangeEvent) payload); + } + } + private static void post(Object channel, Message message) { Broadcaster broadcaster = BroadcasterFactory.getDefault().lookup(channel); if (broadcaster == null) { @@ -225,4 +349,10 @@ private String formatDate(Date created) { } } + private ListItem populateListItem(ListItem listItem, Object origin) { + listItem.add(new ItemPanel("item", listItem.getModelObject(), session, origin)) + .add(new AttributeModifier("id", "item" + listItem.getModelObject().getId())); + return listItem; + } + } diff --git a/src/main/java/org/lbogdanov/poker/web/plugin/SimpleSliderPlugin.java b/src/main/java/org/lbogdanov/poker/web/plugin/SimpleSliderPlugin.java new file mode 100644 index 0000000..ebbdd9b --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/web/plugin/SimpleSliderPlugin.java @@ -0,0 +1,47 @@ +package org.lbogdanov.poker.web.plugin; + +import java.util.Arrays; + +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.HeaderItem; +import org.apache.wicket.request.resource.CssResourceReference; +import org.apache.wicket.request.resource.ResourceReference; +import org.apache.wicket.resource.JQueryPluginResourceReference; + +import com.google.common.collect.Iterables; + +/** + * A ResourceRefernece for JQuery simple slider. + * + * @author Alexandra Fomina + */ +public class SimpleSliderPlugin extends JQueryPluginResourceReference { + + private static final SimpleSliderPlugin INSTANCE = new SimpleSliderPlugin(); + private static final ResourceReference CSS = new CssResourceReference(SimpleSliderPlugin.class, "simple-slider.css"); + private static final ResourceReference VOLUME_THEME = new CssResourceReference(SimpleSliderPlugin.class, + "simple-slider-volume.css"); + + /** + * Returns a single SimpleSliderPlugin instance. + * + * @return the instance + */ + public static SimpleSliderPlugin get() { + return INSTANCE; + } + + /** + * {@inheritDoc} + */ + @Override + public Iterable getDependencies() { + return Iterables.concat(super.getDependencies(), Arrays.asList(CssHeaderItem.forReference(CSS), + CssHeaderItem.forReference(VOLUME_THEME))); + } + + private SimpleSliderPlugin() { + super(SimpleSliderPlugin.class, "simple-slider.js"); + } + +} diff --git a/src/main/java/org/lbogdanov/poker/web/util/ItemChangeEvent.java b/src/main/java/org/lbogdanov/poker/web/util/ItemChangeEvent.java new file mode 100644 index 0000000..95d7f89 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/web/util/ItemChangeEvent.java @@ -0,0 +1,17 @@ +package org.lbogdanov.poker.web.util; + +import org.lbogdanov.poker.core.Item; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +/** + * @author Alexandra Fomina + */ +@JsonTypeName("itemEdit") +public class ItemChangeEvent extends Message { + + public ItemChangeEvent(Object origin, Item message) { + super(origin, message); + } + +} diff --git a/src/main/java/org/lbogdanov/poker/web/util/ItemMessage.java b/src/main/java/org/lbogdanov/poker/web/util/ItemMessage.java new file mode 100644 index 0000000..2b8f806 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/web/util/ItemMessage.java @@ -0,0 +1,33 @@ +package org.lbogdanov.poker.web.util; + +import org.lbogdanov.poker.core.Item; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +/** + * @author Alexandra Fomina + */ +@JsonTypeName("itemAdd") +public class ItemMessage extends Message { + + private String markupId; + + public ItemMessage(Object origin, Item message) { + super(origin, message); + } + + /** + * @return the markupId + */ + public String getMarkupId() { + return markupId; + } + + /** + * @param markupId the markupId to set + */ + public void setMarkupId(String markupId) { + this.markupId = markupId; + } + +} diff --git a/src/main/java/org/lbogdanov/poker/web/util/ItemRemoveEvent.java b/src/main/java/org/lbogdanov/poker/web/util/ItemRemoveEvent.java new file mode 100644 index 0000000..04c06d4 --- /dev/null +++ b/src/main/java/org/lbogdanov/poker/web/util/ItemRemoveEvent.java @@ -0,0 +1,17 @@ +package org.lbogdanov.poker.web.util; + +import org.lbogdanov.poker.core.Item; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +/** + * @author Alexandra Fomina + */ +@JsonTypeName("itemRemove") +public class ItemRemoveEvent extends Message { + + public ItemRemoveEvent(Object origin, Item message) { + super(origin, message); + } + +} \ No newline at end of file diff --git a/src/main/resources/common/org/lbogdanov/poker/web/markup/ItemPanel.html b/src/main/resources/common/org/lbogdanov/poker/web/markup/ItemPanel.html new file mode 100644 index 0000000..9877a48 --- /dev/null +++ b/src/main/resources/common/org/lbogdanov/poker/web/markup/ItemPanel.html @@ -0,0 +1,21 @@ + +
+ × +
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+
+
\ No newline at end of file diff --git a/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.html b/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.html index 5cce60b..a7af64c 100644 --- a/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.html +++ b/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.html @@ -19,8 +19,34 @@

-
+
+ + + +
+
+ +
+
+   +
+
+
+ +
+ +
+
+ + +
+
+ +
+
+
+
diff --git a/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.properties b/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.properties index f7abfe9..b4b149a 100644 --- a/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.properties +++ b/src/main/resources/common/org/lbogdanov/poker/web/page/SessionPage.properties @@ -3,4 +3,10 @@ session.info=Session info session.name=Name session.code=Code session.author=Moderator -session.created=Created \ No newline at end of file +session.created=Created +item.title=Title +item.description=Description +item.add=Add item +item.removed=Item '${title}' removed +item.new=New item '${title}' added +item.modified=Item '${title}' modified diff --git a/src/main/resources/common/org/lbogdanov/poker/web/page/session.css b/src/main/resources/common/org/lbogdanov/poker/web/page/session.css index e505b09..f974805 100644 --- a/src/main/resources/common/org/lbogdanov/poker/web/page/session.css +++ b/src/main/resources/common/org/lbogdanov/poker/web/page/session.css @@ -16,3 +16,12 @@ hr { #chatLog div.error { color: red; } + +#items { + overflow: auto; + height: 450px; +} + +.estimation .row { + margin-left: 0px; +} diff --git a/src/main/resources/common/org/lbogdanov/poker/web/page/session.js b/src/main/resources/common/org/lbogdanov/poker/web/page/session.js index a3fb10b..a35a99c 100644 --- a/src/main/resources/common/org/lbogdanov/poker/web/page/session.js +++ b/src/main/resources/common/org/lbogdanov/poker/web/page/session.js @@ -9,7 +9,24 @@ var Poker = (function() { $(".mCSB_container", chatLog).append(msg); chatLog.mCustomScrollbar("update"); chatLog.mCustomScrollbar("scrollTo", "last"); - }; + }, + showEstimate = function(slider, value) { + slider.siblings("span").text(value); + slider.siblings("#est").val(value); + }, + showSliderValue = function(event, data) { + showEstimate($(this), estimates[data.value]); + }, + removeItem = function(id) { + $("#item" + id).remove(); + $("#items").mCustomScrollbar("update"); + }, + editItem = function(item) { + var element = $("#item" + item.id); + element.find("span[id ^= title]").text(item.title); + element.find("span[id ^= description]").text(item.description == null? "": item.description); + }, + estimates = ${estimates}; $(function() { // send chat messages on Ctrl / Meta + Enter, ignore single line break in a message input @@ -26,12 +43,15 @@ var Poker = (function() { // turn Bootstrap tooltips on $(".tip").tooltip(); // turn custom scrollbars on - $("#chatLog").mCustomScrollbar({ - theme: "dark", - scrollButtons: { - enable: true + $("#chatLog, #items").mCustomScrollbar({ + theme : "dark", + scrollButtons : { + enable : true } }); + var slider = $(".estimate"); + slider.bind("slider:changed", showSliderValue); + showEstimate(slider, estimates[slider.val()]); }); return { @@ -55,7 +75,32 @@ var Poker = (function() { case "chatMsg": appendMsg($.i18n.printf(msgTpl, [msg.author, msg.message])); break; + case "itemAdd": + break; + case "itemRemove": + removeItem(msg.message.id); + break; + case "itemEdit": + editItem(msg.message); + break; } + }, + appendItem: function(itemMsg) { + var item = $('', {id: itemMsg.markupId}); + $('#items .mCSB_container').append(item); + }, + slider: function(itemMsg) { + var items = $("#items"), + slider = $("#item" + itemMsg.message.id).find(".estimate"); + slider.simpleSlider({ + snap: 'true', + range: [0, (estimates.length - 1)], + step: '1' + }); + slider.bind("slider:changed", showSliderValue); + showEstimate(slider, estimates[0]); + items.mCustomScrollbar("update"); + items.mCustomScrollbar("scrollTo", "last"); } }; })(); diff --git a/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider-volume.css b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider-volume.css new file mode 100644 index 0000000..d8a876b --- /dev/null +++ b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider-volume.css @@ -0,0 +1,48 @@ +.slider-volume { + width: 300px; +} + +.slider-volume > .dragger { + width: 16px; + height: 16px; + margin: 0 auto; + border: 1px solid rgba(255,255,255,0.6); + + -moz-box-shadow: 0 0px 2px 1px rgba(0,0,0,0.5), 0 2px 5px 2px rgba(0,0,0,0.2); + -webkit-box-shadow: 0 0px 2px 1px rgba(0,0,0,0.5), 0 2px 5px 2px rgba(0,0,0,0.2); + box-shadow: 0 0px 2px 1px rgba(0,0,0,0.5), 0 2px 5px 2px rgba(0,0,0,0.2); + + -moz-border-radius: 10px; + -webkit-border-radius: 10px; + border-radius: 10px; + + background: #c5c5c5; + background: -moz-linear-gradient(90deg, rgba(180,180,180,1) 20%, rgba(230,230,230,1) 50%, rgba(180,180,180,1) 80%); + background: -webkit-radial-gradient( 50% 0%, 12% 50%, hsla(0,0%,100%,1) 0%, hsla(0,0%,100%,0) 100%), + -webkit-radial-gradient( 50% 100%, 12% 50%, hsla(0,0%,100%,.6) 0%, hsla(0,0%,100%,0) 100%), + -webkit-radial-gradient( 50% 50%, 200% 50%, hsla(0,0%,90%,1) 5%, hsla(0,0%,85%,1) 30%, hsla(0,0%,60%,1) 100%); +} + +.slider-volume > .track, .slider-volume > .highlight-track { + height: 11px; + + background: #787878; + background: -moz-linear-gradient(top, #787878, #a2a2a2); + background: -webkit-linear-gradient(top, #787878, #a2a2a2); + background: linear-gradient(top, #787878, #a2a2a2); + + -moz-box-shadow: inset 0 2px 5px 1px rgba(0,0,0,0.15), 0 1px 0px 0px rgba(230,230,230,0.9), inset 0 0 1px 1px rgba(0,0,0,0.2); + -webkit-box-shadow: inset 0 2px 5px 1px rgba(0,0,0,0.15), 0 1px 0px 0px rgba(230,230,230,0.9), inset 0 0 1px 1px rgba(0,0,0,0.2); + box-shadow: inset 0 2px 5px 1px rgba(0,0,0,0.15), 0 1px 0px 0px rgba(230,230,230,0.9), inset 0 0 1px 1px rgba(0,0,0,0.2); + + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +.slider-volume > .highlight-track { + background-color: #c5c5c5; + background: -moz-linear-gradient(top, #c5c5c5, #a2a2a2); + background: -webkit-linear-gradient(top, #c5c5c5, #a2a2a2); + background: linear-gradient(top, #c5c5c5, #a2a2a2); +} diff --git a/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.css b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.css new file mode 100644 index 0000000..ca9bf6b --- /dev/null +++ b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.css @@ -0,0 +1,55 @@ +.slider { + width: 300px; +} + +.slider > .dragger { + background: #8DCA09; + background: -webkit-linear-gradient(top, #8DCA09, #72A307); + background: -moz-linear-gradient(top, #8DCA09, #72A307); + background: linear-gradient(top, #8DCA09, #72A307); + + -webkit-box-shadow: inset 0 2px 2px rgba(255,255,255,0.5), 0 2px 8px rgba(0,0,0,0.2); + -moz-box-shadow: inset 0 2px 2px rgba(255,255,255,0.5), 0 2px 8px rgba(0,0,0,0.2); + box-shadow: inset 0 2px 2px rgba(255,255,255,0.5), 0 2px 8px rgba(0,0,0,0.2); + + -webkit-border-radius: 10px; + -moz-border-radius: 10px; + border-radius: 10px; + + border: 1px solid #496805; + width: 16px; + height: 16px; +} + +.slider > .dragger:hover { + background: -webkit-linear-gradient(top, #8DCA09, #8DCA09); +} + + +.slider > .track, .slider > .highlight-track { + background: #ccc; + background: -webkit-linear-gradient(top, #bbb, #ddd); + background: -moz-linear-gradient(top, #bbb, #ddd); + background: linear-gradient(top, #bbb, #ddd); + + -webkit-box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); + -moz-box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); + box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); + + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; + + border: 1px solid #aaa; + height: 4px; +} + +.slider > .highlight-track { + background-color: #8DCA09; + background: -webkit-linear-gradient(top, #8DCA09, #72A307); + background: -moz-linear-gradient(top, #8DCA09, #72A307); + background: linear-gradient(top, #8DCA09, #72A307); + + border-color: #496805; +} + diff --git a/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.js b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.js new file mode 100644 index 0000000..1b917f6 --- /dev/null +++ b/src/main/resources/common/org/lbogdanov/poker/web/plugin/simple-slider.js @@ -0,0 +1,363 @@ +/* + jQuery Simple Slider + + Copyright (c) 2012 James Smith (http://loopj.com) + + Licensed under the MIT license (http://mit-license.org/) +*/ + +var __slice = [].slice, + __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + +(function($, window) { + var SimpleSlider; + SimpleSlider = (function() { + + function SimpleSlider(input, options) { + var ratio, + _this = this; + this.input = input; + this.defaultOptions = { + animate: true, + snapMid: false, + classPrefix: null, + classSuffix: null, + theme: null, + highlight: false + }; + this.settings = $.extend({}, this.defaultOptions, options); + if (this.settings.theme) { + this.settings.classSuffix = "-" + this.settings.theme; + } + this.input.hide(); + this.slider = $("
").addClass("slider" + (this.settings.classSuffix || "")).css({ + position: "relative", + userSelect: "none", + boxSizing: "border-box" + }).insertBefore(this.input); + if (this.input.attr("id")) { + this.slider.attr("id", this.input.attr("id") + "-slider"); + } + this.track = this.createDivElement("track").css({ + width: "100%" + }); + if (this.settings.highlight) { + this.highlightTrack = this.createDivElement("highlight-track").css({ + width: "0" + }); + } + this.dragger = this.createDivElement("dragger"); + this.slider.css({ + minHeight: this.dragger.outerHeight(), + marginLeft: this.dragger.outerWidth() / 2, + marginRight: this.dragger.outerWidth() / 2 + }); + this.track.css({ + marginTop: this.track.outerHeight() / -2 + }); + if (this.settings.highlight) { + this.highlightTrack.css({ + marginTop: this.track.outerHeight() / -2 + }); + } + this.dragger.css({ + marginTop: this.dragger.outerWidth() / -2, + marginLeft: this.dragger.outerWidth() / -2 + }); + this.track.mousedown(function(e) { + return _this.trackEvent(e); + }); + if (this.settings.highlight) { + this.highlightTrack.mousedown(function(e) { + return _this.trackEvent(e); + }); + } + this.dragger.mousedown(function(e) { + if (e.which !== 1) { + return; + } + _this.dragging = true; + _this.dragger.addClass("dragging"); + _this.domDrag(e.pageX, e.pageY); + return false; + }); + $("body").mousemove(function(e) { + if (_this.dragging) { + _this.domDrag(e.pageX, e.pageY); + return $("body").css({ + cursor: "pointer" + }); + } + }).mouseup(function(e) { + if (_this.dragging) { + _this.dragging = false; + _this.dragger.removeClass("dragging"); + return $("body").css({ + cursor: "auto" + }); + } + }); + this.pagePos = 0; + if (this.input.val() === "") { + this.value = this.getRange().min; + this.input.val(this.value); + } else { + this.value = this.nearestValidValue(this.input.val()); + } + this.setSliderPositionFromValue(this.value); + ratio = this.valueToRatio(this.value); + this.input.trigger("slider:ready", { + value: this.value, + ratio: ratio, + position: ratio * this.slider.outerWidth(), + el: this.slider + }); + } + + SimpleSlider.prototype.createDivElement = function(classname) { + var item; + item = $("
").addClass(classname).css({ + position: "absolute", + top: "50%", + userSelect: "none", + cursor: "pointer" + }).appendTo(this.slider); + return item; + }; + + SimpleSlider.prototype.setRatio = function(ratio) { + var value; + ratio = Math.min(1, ratio); + ratio = Math.max(0, ratio); + value = this.ratioToValue(ratio); + this.setSliderPositionFromValue(value); + return this.valueChanged(value, ratio, "setRatio"); + }; + + SimpleSlider.prototype.setValue = function(value) { + var ratio; + value = this.nearestValidValue(value); + ratio = this.valueToRatio(value); + this.setSliderPositionFromValue(value); + return this.valueChanged(value, ratio, "setValue"); + }; + + SimpleSlider.prototype.trackEvent = function(e) { + if (e.which !== 1) { + return; + } + this.domDrag(e.pageX, e.pageY, true); + this.dragging = true; + return false; + }; + + SimpleSlider.prototype.domDrag = function(pageX, pageY, animate) { + var pagePos, ratio, value; + if (animate == null) { + animate = false; + } + pagePos = pageX - this.slider.offset().left; + pagePos = Math.min(this.slider.outerWidth(), pagePos); + pagePos = Math.max(0, pagePos); + if (this.pagePos !== pagePos) { + this.pagePos = pagePos; + ratio = pagePos / this.slider.outerWidth(); + value = this.ratioToValue(ratio); + this.valueChanged(value, ratio, "domDrag"); + if (this.settings.snap) { + return this.setSliderPositionFromValue(value, animate); + } else { + return this.setSliderPosition(pagePos, animate); + } + } + }; + + SimpleSlider.prototype.setSliderPosition = function(position, animate) { + if (animate == null) { + animate = false; + } + if (animate && this.settings.animate) { + this.dragger.animate({ + left: position + }, 200); + if (this.settings.highlight) { + return this.highlightTrack.animate({ + width: position + }, 200); + } + } else { + this.dragger.css({ + left: position + }); + if (this.settings.highlight) { + return this.highlightTrack.css({ + width: position + }); + } + } + }; + + SimpleSlider.prototype.setSliderPositionFromValue = function(value, animate) { + var ratio; + if (animate == null) { + animate = false; + } + ratio = this.valueToRatio(value); + return this.setSliderPosition(ratio * this.slider.outerWidth(), animate); + }; + + SimpleSlider.prototype.getRange = function() { + if (this.settings.allowedValues) { + return { + min: Math.min.apply(Math, this.settings.allowedValues), + max: Math.max.apply(Math, this.settings.allowedValues) + }; + } else if (this.settings.range) { + return { + min: parseFloat(this.settings.range[0]), + max: parseFloat(this.settings.range[1]) + }; + } else { + return { + min: 0, + max: 1 + }; + } + }; + + SimpleSlider.prototype.nearestValidValue = function(rawValue) { + var closest, maxSteps, range, steps; + range = this.getRange(); + rawValue = Math.min(range.max, rawValue); + rawValue = Math.max(range.min, rawValue); + if (this.settings.allowedValues) { + closest = null; + $.each(this.settings.allowedValues, function() { + if (closest === null || Math.abs(this - rawValue) < Math.abs(closest - rawValue)) { + return closest = this; + } + }); + return closest; + } else if (this.settings.step) { + maxSteps = (range.max - range.min) / this.settings.step; + steps = Math.floor((rawValue - range.min) / this.settings.step); + if ((rawValue - range.min) % this.settings.step > this.settings.step / 2 && steps < maxSteps) { + steps += 1; + } + return steps * this.settings.step + range.min; + } else { + return rawValue; + } + }; + + SimpleSlider.prototype.valueToRatio = function(value) { + var allowedVal, closest, closestIdx, idx, range, _i, _len, _ref; + if (this.settings.equalSteps) { + _ref = this.settings.allowedValues; + for (idx = _i = 0, _len = _ref.length; _i < _len; idx = ++_i) { + allowedVal = _ref[idx]; + if (!(typeof closest !== "undefined" && closest !== null) || Math.abs(allowedVal - value) < Math.abs(closest - value)) { + closest = allowedVal; + closestIdx = idx; + } + } + if (this.settings.snapMid) { + return (closestIdx + 0.5) / this.settings.allowedValues.length; + } else { + return closestIdx / (this.settings.allowedValues.length - 1); + } + } else { + range = this.getRange(); + return (value - range.min) / (range.max - range.min); + } + }; + + SimpleSlider.prototype.ratioToValue = function(ratio) { + var idx, range, rawValue, step, steps; + if (this.settings.equalSteps) { + steps = this.settings.allowedValues.length; + step = Math.round(ratio * steps - 0.5); + idx = Math.min(step, this.settings.allowedValues.length - 1); + return this.settings.allowedValues[idx]; + } else { + range = this.getRange(); + rawValue = ratio * (range.max - range.min) + range.min; + return this.nearestValidValue(rawValue); + } + }; + + SimpleSlider.prototype.valueChanged = function(value, ratio, trigger) { + var eventData; + if (value.toString() === this.value.toString()) { + return; + } + this.value = value; + eventData = { + value: value, + ratio: ratio, + position: ratio * this.slider.outerWidth(), + trigger: trigger, + el: this.slider + }; + return this.input.val(value).trigger($.Event("change", eventData)).trigger("slider:changed", eventData); + }; + + return SimpleSlider; + + })(); + $.extend($.fn, { + simpleSlider: function() { + var params, publicMethods, settingsOrMethod; + settingsOrMethod = arguments[0], params = 2 <= arguments.length ? __slice.call(arguments, 1) : []; + publicMethods = ["setRatio", "setValue"]; + return $(this).each(function() { + var obj, settings; + if (settingsOrMethod && __indexOf.call(publicMethods, settingsOrMethod) >= 0) { + obj = $(this).data("slider-object"); + return obj[settingsOrMethod].apply(obj, params); + } else { + settings = settingsOrMethod; + return $(this).data("slider-object", new SimpleSlider($(this), settings)); + } + }); + } + }); + return $(function() { + return $("[data-slider]").each(function() { + var $el, allowedValues, settings, x; + $el = $(this); + settings = {}; + allowedValues = $el.data("slider-values"); + if (allowedValues) { + settings.allowedValues = (function() { + var _i, _len, _ref, _results; + _ref = allowedValues.split(","); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + x = _ref[_i]; + _results.push(parseFloat(x)); + } + return _results; + })(); + } + if ($el.data("slider-range")) { + settings.range = $el.data("slider-range").split(","); + } + if ($el.data("slider-step")) { + settings.step = $el.data("slider-step"); + } + settings.snap = $el.data("slider-snap"); + settings.equalSteps = $el.data("slider-equal-steps"); + if ($el.data("slider-theme")) { + settings.theme = $el.data("slider-theme"); + } + if ($el.attr("data-slider-highlight")) { + settings.highlight = $el.data("slider-highlight"); + } + if ($el.data("slider-animate") != null) { + settings.animate = $el.data("slider-animate"); + } + return $el.simpleSlider(settings); + }); + }); +})(this.jQuery || this.Zepto, this); diff --git a/src/main/sql/mysql.sql b/src/main/sql/mysql.sql index 4726deb..66545c5 100644 --- a/src/main/sql/mysql.sql +++ b/src/main/sql/mysql.sql @@ -24,3 +24,27 @@ CREATE TABLE `SESSIONS` ( REFERENCES `USERS` (`ID`) ON UPDATE CASCADE ON DELETE CASCADE ); + +CREATE TABLE `ITEMS` ( + `ID` BIGINT PRIMARY KEY AUTO_INCREMENT, + `TITLE` VARCHAR(128) NOT NULL, + `DESCRIPTION` VARCHAR(4096) NULL, + `SESSION_ID` BIGINT NOT NULL, + FOREIGN KEY (`SESSION_ID`) + REFERENCES `SESSIONS` (`ID`) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE `ESTIMATES` ( + `ITEM_ID` BIGINT NOT NULL, + `USER_ID` BIGINT NOT NULL, + `ESTIMATE` INT NOT NULL, + FOREIGN KEY (`ITEM_ID`) + REFERENCES `ITEMS` (`ID`) + ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (`USER_ID`) + REFERENCES `USERS` (`ID`) + ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (`USER_ID`, `ITEM_ID`) +); + diff --git a/src/test/java/org/lbogdanov/poker/core/DurationTest.java b/src/test/java/org/lbogdanov/poker/core/DurationTest.java deleted file mode 100644 index 9a240c6..0000000 --- a/src/test/java/org/lbogdanov/poker/core/DurationTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.lbogdanov.poker.core; - - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.lbogdanov.poker.core.Duration.MINUTES_PER_DAY; -import static org.lbogdanov.poker.core.Duration.MINUTES_PER_HOUR; -import static org.lbogdanov.poker.core.Duration.MINUTES_PER_WEEK; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - -/** - * Tests for {@link Duration} class. - * - * @author Alexandra Fomina - * - */ -public class DurationTest { - - @Rule - public ExpectedException thrown = ExpectedException.none(); - - /** - * Test for {@link Duration#Duration(int)} with invalid input. - */ - @Test(expected = IllegalArgumentException.class) - public void testInvalidArgument() { - new Duration(-1); - } - - /** - * Test for {@link Duration#parse(String)}. - */ - @Test - public void testParse() { - Duration[] durations = {new Duration(30), new Duration(MINUTES_PER_HOUR), - new Duration(MINUTES_PER_DAY), new Duration(MINUTES_PER_WEEK)}; - - assertArrayEquals(durations, Duration.parse("30m,1h,1d,1w").toArray()); - assertArrayEquals(durations, Duration.parse("30m 1h 1d 1w").toArray()); - assertArrayEquals(durations, Duration.parse("30m;1h;1d;1w").toArray()); - } - - /** - * Test for {@link Duration#parse(String)} with invalid input. - */ - @Test - public void testParseWithInvalidInput1() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("q"); - Duration.parse("qwerty"); - } - - /** - * Test for {@link Duration#parse(String)} with invalid input. - */ - @Test - public void testParseWithInvalidInput2() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("12y"); - Duration.parse("12y 5w 3h"); - } - - /** - * Test for {@link Duration#parse(String)} with invalid input. - */ - @Test - public void testParseWithInvalidInput3() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("."); - Duration.parse("12d.5w.3h"); - } - - /** - * Test for {@link Duration#parse(String)} with invalid input. - */ - @Test - public void testParseWithInvalidInput4() { - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("12"); - Duration.parse("2h 12"); - } - - /** - * Test for {@link Duration#toString()}. - */ - @Test - public void testToString() { - assertEquals("1w 1d 1h 1m", new Duration(MINUTES_PER_WEEK + MINUTES_PER_DAY + MINUTES_PER_HOUR + 1).toString()); - assertEquals("0", new Duration().toString()); - assertEquals("30m", new Duration(30).toString()); - assertEquals("1d", new Duration(MINUTES_PER_DAY).toString()); - } - -} diff --git a/src/test/java/org/lbogdanov/poker/core/EstimateTest.java b/src/test/java/org/lbogdanov/poker/core/EstimateTest.java new file mode 100644 index 0000000..4612659 --- /dev/null +++ b/src/test/java/org/lbogdanov/poker/core/EstimateTest.java @@ -0,0 +1,97 @@ +package org.lbogdanov.poker.core; + + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.lbogdanov.poker.core.Estimate.MINUTES_PER_DAY; +import static org.lbogdanov.poker.core.Estimate.MINUTES_PER_HOUR; +import static org.lbogdanov.poker.core.Estimate.MINUTES_PER_WEEK; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +/** + * Tests for {@link Estimate} class. + * + * @author Alexandra Fomina + * + */ +public class EstimateTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + /** + * Test for {@link Estimate#Estimate(int)} with invalid input. + */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidArgument() { + new Estimate(-1); + } + + /** + * Test for {@link Estimate#parse(String)}. + */ + @Test + public void testParse() { + Estimate[] Estimates = {new Estimate(30), new Estimate(MINUTES_PER_HOUR), + new Estimate(MINUTES_PER_DAY), new Estimate(MINUTES_PER_WEEK)}; + + assertArrayEquals(Estimates, Estimate.parse("30m,1h,1d,1w").toArray()); + assertArrayEquals(Estimates, Estimate.parse("30m 1h 1d 1w").toArray()); + assertArrayEquals(Estimates, Estimate.parse("30m;1h;1d;1w").toArray()); + } + + /** + * Test for {@link Estimate#parse(String)} with invalid input. + */ + @Test + public void testParseWithInvalidInput1() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("q"); + Estimate.parse("qwerty"); + } + + /** + * Test for {@link Estimate#parse(String)} with invalid input. + */ + @Test + public void testParseWithInvalidInput2() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("12y"); + Estimate.parse("12y 5w 3h"); + } + + /** + * Test for {@link Estimate#parse(String)} with invalid input. + */ + @Test + public void testParseWithInvalidInput3() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("."); + Estimate.parse("12d.5w.3h"); + } + + /** + * Test for {@link Estimate#parse(String)} with invalid input. + */ + @Test + public void testParseWithInvalidInput4() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("12"); + Estimate.parse("2h 12"); + } + + /** + * Test for {@link Estimate#toString()}. + */ + @Test + public void testToString() { + assertEquals("1w 1d 1h 1m", new Estimate(MINUTES_PER_WEEK + MINUTES_PER_DAY + MINUTES_PER_HOUR + 1).toString()); + assertEquals("0", new Estimate().toString()); + assertEquals("30m", new Estimate(30).toString()); + assertEquals("1d", new Estimate(MINUTES_PER_DAY).toString()); + } + +}