Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial import.

  • Loading branch information...
commit 4356fca6a8ee5ecedc2279ca9a051cd19e926cdb 0 parents
@christiannelson authored
Showing with 8,323 additions and 0 deletions.
  1. +4 −0 .gitignore
  2. +226 −0 pom.xml
  3. +54 −0 src/main/java/xian/recipes/Bootstrapper.java
  4. +20 −0 src/main/java/xian/recipes/RecipeRepository.java
  5. +69 −0 src/main/java/xian/recipes/RecipeRepositoryImpl.java
  6. +78 −0 src/main/java/xian/recipes/model/Ingredient.java
  7. +48 −0 src/main/java/xian/recipes/model/Quantity.java
  8. +85 −0 src/main/java/xian/recipes/model/Recipe.java
  9. +42 −0 src/main/java/xian/recipes/model/Step.java
  10. +16 −0 src/main/java/xian/recipes/model/Tag.java
  11. +6 −0 src/main/java/xian/recipes/model/Unit.java
  12. +49 −0 src/main/java/xian/recipes/model/User.java
  13. +30 −0 src/main/java/xian/recipes/model/UserValidator.java
  14. +18 −0 src/main/java/xian/recipes/web/controllers/CurrentTimeController.java
  15. +136 −0 src/main/java/xian/recipes/web/controllers/RecipesController.java
  16. +55 −0 src/main/java/xian/recipes/web/controllers/UsersController.java
  17. +18 −0 src/main/java/xian/recipes/web/formatters/CustomFormattingConversionServiceFactoryBean.java
  18. +57 −0 src/main/java/xian/recipes/web/formatters/QuantityFormatter.java
  19. +24 −0 src/main/java/xian/recipes/web/formatters/UnitFormatter.java
  20. +16 −0 src/main/resources/logback.xml
  21. +55 −0 src/main/resources/xian/recipes/application-context.xml
  22. +10 −0 src/main/resources/xian/recipes/application.properties
  23. +52 −0 src/main/resources/xian/recipes/hibernate-mapping.hbm.xml
  24. +22 −0 src/main/resources/xian/recipes/hibernate.properties
  25. +30 −0 src/main/webapp/WEB-INF/dispatcher-servlet.xml
  26. +10 −0 src/main/webapp/WEB-INF/jsps/current-time.jsp
  27. +18 −0 src/main/webapp/WEB-INF/jsps/recipes/edit.jsp
  28. +42 −0 src/main/webapp/WEB-INF/jsps/recipes/index.jsp
  29. +18 −0 src/main/webapp/WEB-INF/jsps/recipes/new.jsp
  30. +55 −0 src/main/webapp/WEB-INF/jsps/recipes/show.jsp
  31. +1 −0  src/main/webapp/WEB-INF/messages.properties
  32. +38 −0 src/main/webapp/WEB-INF/tags/page.tag
  33. +97 −0 src/main/webapp/WEB-INF/tags/recipe/form.tag
  34. +21 −0 src/main/webapp/WEB-INF/tags/recipe/ingredient.tag
  35. +20 −0 src/main/webapp/WEB-INF/urlrewrite.xml
  36. +82 −0 src/main/webapp/WEB-INF/web.xml
  37. BIN  src/main/webapp/static/images/favicon.ico
  38. +97 −0 src/main/webapp/static/javascripts/application.onready.js
  39. +6,240 −0 src/main/webapp/static/javascripts/ext/jquery-1.4.2.js
  40. +58 −0 src/main/webapp/static/javascripts/ext/jquery.ezpz_hint.js
  41. +67 −0 src/main/webapp/static/javascripts/recipes-form.onready.js
  42. +46 −0 src/main/webapp/static/stylesheets/recipes.css
  43. +77 −0 src/test/java/xian/recipes/model/IngredientValidationTest.java
  44. +47 −0 src/test/java/xian/recipes/model/RecipeTest.java
  45. +26 −0 src/test/java/xian/recipes/model/RecipeValidationTest.java
  46. +43 −0 src/test/java/xian/recipes/model/UserValidatorTest.java
4 .gitignore
@@ -0,0 +1,4 @@
+*.iml
+*.ipr
+*.iws
+target
226 pom.xml
@@ -0,0 +1,226 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>xian</groupId>
+ <artifactId>recipe</artifactId>
+ <packaging>war</packaging>
+ <version>1.0-SNAPSHOT</version>
+
+ <name>SDForum Spring MVC Tutorial</name>
+
+ <repositories>
+ <repository>
+ <id>jboss</id>
+ <url>http://repository.jboss.com/maven2</url>
+ </repository>
+ </repositories>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+
+ <spring.version>3.0.2.RELEASE</spring.version>
+ </properties>
+
+ <dependencies>
+
+ <!-- Testing -->
+
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit-dep</artifactId>
+ <version>4.8.1</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.hamcrest</groupId>
+ <artifactId>hamcrest-all</artifactId>
+ <version>1.2</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-test</artifactId>
+ <version>${spring.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Core -->
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>1.5.11</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>jcl-over-slf4j</artifactId>
+ <version>1.5.11</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ <version>0.9.20</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>commons-lang</groupId>
+ <artifactId>commons-lang</artifactId>
+ <version>2.5</version>
+ </dependency>
+
+ <dependency>
+ <groupId>net.sourceforge.collections</groupId>
+ <artifactId>collections-generic</artifactId>
+ <version>4.01</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.collections</groupId>
+ <artifactId>google-collections</artifactId>
+ <version>1.0</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-orm</artifactId>
+ <version>${spring.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>commons-logging</groupId>
+ <artifactId>commons-logging</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>com.carbonfive.db-support</groupId>
+ <artifactId>db-support</artifactId>
+ <version>0.9.9-m4</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.hibernate</groupId>
+ <artifactId>hibernate-validator</artifactId>
+ <version>4.0.2.GA</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.hibernate</groupId>
+ <artifactId>hibernate-core</artifactId>
+ <version>3.3.2.GA</version>
+ <exclusions>
+ <exclusion>
+ <groupId>xml-apis</groupId>
+ <artifactId>xml-apis</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>javassist</groupId>
+ <artifactId>javassist</artifactId>
+ <version>3.8.1.GA</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>mysql</groupId>
+ <artifactId>mysql-connector-java</artifactId>
+ <version>5.1.12</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>com.h2database</groupId>
+ <artifactId>h2</artifactId>
+ <version>1.2.134</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <!-- Web -->
+
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>servlet-api</artifactId>
+ <version>2.5</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>javax.servlet.jsp</groupId>
+ <artifactId>jsp-api</artifactId>
+ <version>2.1</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>jstl</artifactId>
+ <version>1.2</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.tuckey</groupId>
+ <artifactId>urlrewritefilter</artifactId>
+ <version>3.0.4</version>
+ <scope>runtime</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>commons-logging</groupId>
+ <artifactId>commons-logging</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>log4j</groupId>
+ <artifactId>log4j</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-webmvc</artifactId>
+ <version>${spring.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>commons-logging</groupId>
+ <artifactId>commons-logging</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>org.codehaus.jackson</groupId>
+ <artifactId>jackson-mapper-asl</artifactId>
+ <version>1.5.1</version>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <finalName>recipes</finalName>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>2.3</version>
+ <configuration>
+ <source>1.6</source>
+ <target>1.6</target>
+ <encoding>UTF-8</encoding>
+ </configuration>
+ </plugin>
+ <!--<plugin>-->
+ <!--<groupId>org.apache.maven.plugins</groupId>-->
+ <!--<artifactId>maven-surefire-plugin</artifactId>-->
+ <!--<version>2.4.3</version>-->
+ <!--</plugin>-->
+ </plugins>
+ </build>
+
+</project>
54 src/main/java/xian/recipes/Bootstrapper.java
@@ -0,0 +1,54 @@
+package xian.recipes;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import xian.recipes.model.Recipe;
+import xian.recipes.model.Unit;
+
+import java.math.BigDecimal;
+
+import static xian.recipes.model.Ingredient.newIngredient;
+import static xian.recipes.model.Quantity.newQuantity;
+import static xian.recipes.model.Step.newStep;
+
+public class Bootstrapper
+{
+ @Autowired
+ private RecipeRepository recipeRepository;
+
+ public void bootstrap()
+ {
+ Recipe recipe = new Recipe("Aloo Ghobi");
+ recipe.setDescription("Potatos and Cauliflower in a spicy sauce.");
+ recipe.setPreparationTime(45);
+ recipe.setCost(BigDecimal.valueOf(9.00));
+ recipe.addIngredient(newIngredient("Vegetable Oil", newQuantity(0.25, Unit.CUP)));
+ recipe.addIngredient(newIngredient("Large Onion", newQuantity(1, Unit.WHOLE), "peeled and cut into small pieces"));
+ recipe.addIngredient(newIngredient("Fresh Coriander", newQuantity(1, Unit.BUNCH), "separated into stalks and leaves and roughly chopped"));
+ recipe.addIngredient(newIngredient("Small Green Chili", newQuantity(1, Unit.WHOLE), "chopped into small pieces"));
+ recipe.addIngredient(newIngredient("Large Cauliflower", newQuantity(1, Unit.WHOLE), "eaves removed and cut evenly into eighths"));
+ recipe.addIngredient(newIngredient("Large Potatoes", newQuantity(3, Unit.WHOLE), "peeled and cut into even pieces"));
+ recipe.addIngredient(newIngredient("Diced Tomatoes", newQuantity(2, Unit.CANS)));
+ recipe.addIngredient(newIngredient("Fresh Ginger", newQuantity(2, Unit.TABLESPOON), "peeled and grated"));
+ recipe.addIngredient(newIngredient("Fresh Garlic", newQuantity(1, Unit.TEASPOON), "chopped"));
+ recipe.addIngredient(newIngredient("Cumin Seed", newQuantity(1, Unit.TEASPOON)));
+ recipe.addIngredient(newIngredient("Tumeric", newQuantity(2, Unit.TEASPOON)));
+ recipe.addIngredient(newIngredient("Salt", newQuantity(1, Unit.TEASPOON)));
+ recipe.addIngredient(newIngredient("Garam Masala", newQuantity(2, Unit.TEASPOON)));
+ recipe.addStep(newStep("Heat vegetable oil in a large saucepan."));
+ recipe.addStep(newStep("Add the chopped onion and one teaspoon of cumin seeds to the oil."));
+ recipe.addStep(newStep("Stir together and cook until onions become creamy, golden, and translucent."));
+ recipe.addStep(newStep("Add chopped coriander stalks, two teaspoons of turmeric, and one teaspoon of salt."));
+ recipe.addStep(newStep("Add chopped chillis (according to taste) Stir tomatoes into onion mixture."));
+ recipe.addStep(newStep("Add ginger and garlic; mix thoroughly."));
+ recipe.addStep(newStep("Add potatoes and cauliflower to the sauce plus a few tablespoons of water (ensuring that the mixture doesn't stick to the saucepan)."));
+ recipe.addStep(newStep("Ensure that the potatoes and cauliflower are coated with the curry sauce."));
+ recipe.addStep(newStep("Cover and allow to simmer for twenty minutes (or until potatoes are cooked)."));
+ recipe.addStep(newStep("Add two teaspoons of Garam Masala and stir."));
+ recipe.addStep(newStep("Sprinkle chopped coriander leaves on top of the curry."));
+ recipe.addStep(newStep("Turn off the heat, cover, and leave for as long as possible before serving."));
+
+ recipeRepository.save(recipe);
+
+ }
+}
+
20 src/main/java/xian/recipes/RecipeRepository.java
@@ -0,0 +1,20 @@
+package xian.recipes;
+
+import xian.recipes.model.Recipe;
+
+import java.util.List;
+
+public interface RecipeRepository
+{
+ Recipe find(long id);
+
+ List<Recipe> find();
+
+ void save(Recipe recipe);
+
+ void merge(Recipe recipe);
+
+ void update(Recipe recipe);
+
+ void destroy(Recipe recipe);
+}
69 src/main/java/xian/recipes/RecipeRepositoryImpl.java
@@ -0,0 +1,69 @@
+package xian.recipes;
+
+import org.hibernate.SessionFactory;
+import org.hibernate.classic.Session;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import xian.recipes.model.Recipe;
+
+import java.util.List;
+
+@Repository
+public class RecipeRepositoryImpl implements RecipeRepository
+{
+ private final SessionFactory sessionFactory;
+
+ @Autowired
+ public RecipeRepositoryImpl(SessionFactory sessionFactory)
+ {
+ this.sessionFactory = sessionFactory;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Recipe find(long id)
+ {
+ return (Recipe) getSession().createQuery("from Recipe where id = :id").setLong("id", id).uniqueResult();
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List<Recipe> find()
+ {
+ return getSession().createQuery("from Recipe").list();
+ }
+
+ @Override
+ @Transactional
+ public void save(Recipe recipe)
+ {
+ getSession().save(recipe);
+ }
+
+ @Override
+ @Transactional
+ public void merge(Recipe recipe)
+ {
+ getSession().merge(recipe);
+ }
+
+ @Override
+ @Transactional
+ public void update(Recipe recipe)
+ {
+ getSession().update(recipe);
+ }
+
+ @Override
+ @Transactional
+ public void destroy(Recipe recipe)
+ {
+ getSession().delete(recipe);
+ }
+
+ private Session getSession()
+ {
+ return sessionFactory.getCurrentSession();
+ }
+}
78 src/main/java/xian/recipes/model/Ingredient.java
@@ -0,0 +1,78 @@
+package xian.recipes.model;
+
+import org.hibernate.validator.constraints.Length;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.Valid;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+
+public class Ingredient
+{
+ private Long id;
+
+ @NotEmpty
+ @Length(min = 1, max = 64)
+ private String name;
+
+ @Valid
+ @NotNull
+ private Quantity quantity;
+
+ @Length(max = 200)
+ private String preparation;
+
+ public static Ingredient newIngredient(String name, Quantity quantity)
+ {
+ return new Ingredient(name, quantity, null);
+ }
+
+ public static Ingredient newIngredient(String name, Quantity quantity, String preparation)
+ {
+ return new Ingredient(name, quantity, preparation);
+ }
+
+ public Ingredient() { }
+
+ public Ingredient(String name, Quantity quantity, String preparation)
+ {
+ this.name = name;
+ this.quantity = quantity;
+ this.preparation = preparation;
+ }
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+ public Quantity getQuantity()
+ {
+ return quantity;
+ }
+
+ public void setQuantity(Quantity quantity)
+ {
+ this.quantity = quantity;
+ }
+
+ public String getPreparation()
+ {
+ return preparation;
+ }
+
+ public void setPreparation(String preparation)
+ {
+ this.preparation = preparation;
+ }
+}
48 src/main/java/xian/recipes/model/Quantity.java
@@ -0,0 +1,48 @@
+package xian.recipes.model;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+public class Quantity
+{
+ @NotNull
+ @Min(0)
+ private BigDecimal amount;
+
+ @NotNull
+ private Unit units;
+
+ public Quantity() { }
+
+ public Quantity(BigDecimal amount, Unit units)
+ {
+ this.amount = amount;
+ this.units = units;
+ }
+
+ public BigDecimal getAmount() { return amount; }
+
+ public void setAmount(BigDecimal amount) { this.amount = amount; }
+
+ public Unit getUnits() { return units; }
+
+ public void setUnits(Unit units) { this.units = units; }
+
+ // Instantiation helpers
+
+ public static Quantity newQuantity(BigDecimal amount, Unit units)
+ {
+ return new Quantity(amount, units);
+ }
+
+ public static Quantity newQuantity(double amount, Unit units)
+ {
+ return new Quantity(BigDecimal.valueOf(amount), units);
+ }
+
+ public static Quantity newQuantity(String amount, Unit units)
+ {
+ return new Quantity(new BigDecimal(amount), units);
+ }
+}
85 src/main/java/xian/recipes/model/Recipe.java
@@ -0,0 +1,85 @@
+package xian.recipes.model;
+
+import com.google.common.collect.Lists;
+import org.hibernate.validator.constraints.Length;
+import org.hibernate.validator.constraints.NotEmpty;
+import org.hibernate.validator.constraints.Range;
+import org.springframework.format.annotation.NumberFormat;
+
+import javax.validation.Valid;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.math.BigDecimal;
+import java.util.List;
+
+public class Recipe
+{
+ private Long id;
+
+ @NotEmpty
+ @Length(min = 1, max = 64)
+ private String name;
+
+ private String description;
+
+ @NotNull
+ @Range(min = 1, max = 12)
+ private int servingCount = 4;
+
+ @NotNull
+ @Min(1)
+ private int preparationTime = 10;
+
+ @NumberFormat(style = NumberFormat.Style.CURRENCY)
+ @Range(min = 0)
+ private BigDecimal cost;
+
+ @Valid
+ @Size(min = 1, message = "At least one ingredient is required for a recipe.")
+ private List<Ingredient> ingredients = Lists.newArrayList();
+
+ @Valid
+ @Size(min = 1, message = "At least one step is required.")
+ private List<Step> steps = Lists.newArrayList();
+
+ public Recipe() {}
+
+ public Recipe(String name) { this.name = name; }
+
+ public Long getId() { return id; }
+
+ public void setId(Long id) { this.id = id; }
+
+ public String getName() { return name; }
+
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+
+ public void setDescription(String description) { this.description = description; }
+
+ public int getServingCount() { return servingCount; }
+
+ public void setServingCount(int servingCount) { this.servingCount = servingCount; }
+
+ public int getPreparationTime() { return preparationTime; }
+
+ public void setPreparationTime(int preparationTime) { this.preparationTime = preparationTime; }
+
+ public BigDecimal getCost() { return cost; }
+
+ public void setCost(BigDecimal cost) { this.cost = cost; }
+
+ public List<Ingredient> getIngredients() { return ingredients; }
+
+ public void addIngredient(Ingredient ingredient) { ingredients.add(ingredient); }
+
+ public void setIngredients(List<Ingredient> ingredients) { this.ingredients = ingredients; }
+
+ public List<Step> getSteps() { return steps; }
+
+ public void addStep(Step step) { steps.add(step); }
+
+ public void setSteps(List<Step> steps) { this.steps = steps; }
+}
42 src/main/java/xian/recipes/model/Step.java
@@ -0,0 +1,42 @@
+package xian.recipes.model;
+
+import org.hibernate.validator.constraints.Length;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.constraints.NotNull;
+
+public class Step
+{
+ private Long id;
+
+ @NotEmpty
+ @Length(min = 1, max = 128)
+ private String directions;
+
+ public Step() {}
+
+ public static Step newStep(String directions)
+ {
+ return new Step(directions);
+ }
+
+ public Step(String directions)
+ {
+ this.directions = directions;
+ }
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public String getDirections()
+ {
+ return directions;
+ }
+
+ public void setDirections(String directions)
+ {
+ this.directions = directions;
+ }
+}
16 src/main/java/xian/recipes/model/Tag.java
@@ -0,0 +1,16 @@
+package xian.recipes.model;
+
+public class Tag
+{
+ private String name;
+
+ public Tag(String name)
+ {
+ this.name = name;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+}
6 src/main/java/xian/recipes/model/Unit.java
@@ -0,0 +1,6 @@
+package xian.recipes.model;
+
+public enum Unit
+{
+ TEASPOON, TABLESPOON, OUNCE, CUP, QUART, BUNCH, CANS, WHOLE
+}
49 src/main/java/xian/recipes/model/User.java
@@ -0,0 +1,49 @@
+package xian.recipes.model;
+
+import org.hibernate.validator.constraints.NotEmpty;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+public class User
+{
+ public Long id;
+
+ @NotEmpty
+ public String email;
+
+ @NotEmpty
+ public String name;
+
+ private static final AtomicLong idSequence = new AtomicLong();
+
+ public User() { }
+
+ public User(String email, String name)
+ {
+ this.email = email;
+ this.name = name;
+ }
+
+ public Long getId() { return id; }
+
+ public void setId(Long id) { this.id = id; }
+
+ public Long assignId()
+ {
+ this.id = idSequence.incrementAndGet();
+ return id;
+ }
+
+ public String getEmail() { return email; }
+
+ public void setEmail(String email) { this.email = email; }
+
+ public String getName() { return name; }
+
+ public void setName(String name) { this.name = name; }
+
+ public static User newUser(String email, String name)
+ {
+ return new User(email, name);
+ }
+}
30 src/main/java/xian/recipes/model/UserValidator.java
@@ -0,0 +1,30 @@
+package xian.recipes.model;
+
+import org.springframework.validation.Errors;
+import org.springframework.validation.Validator;
+
+import java.util.regex.Pattern;
+
+import static org.apache.commons.lang.StringUtils.isNotBlank;
+
+public class UserValidator implements Validator
+{
+ private static final Pattern EMAIL_PATTERN = Pattern.compile("^([^@\\s]+)@((?:[-a-z0-9]+\\.)+[a-z]{2,})$");
+
+ @Override
+ public boolean supports(Class<?> clazz)
+ {
+ return clazz.isAssignableFrom(User.class);
+ }
+
+ @Override
+ public void validate(Object target, Errors errors)
+ {
+ User user = (User) target;
+
+ if (isNotBlank(user.getEmail()) && !EMAIL_PATTERN.matcher(user.getEmail()).find())
+ {
+ errors.rejectValue("email", "email.invalid", "invalid email address");
+ }
+ }
+}
18 src/main/java/xian/recipes/web/controllers/CurrentTimeController.java
@@ -0,0 +1,18 @@
+package xian.recipes.web.controllers;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.util.Date;
+
+@Controller
+public class CurrentTimeController
+{
+ @RequestMapping(value = "/current-time")
+ public String currentTime(Model model)
+ {
+ model.addAttribute("currentTime", new Date());
+ return "current-time";
+ }
+}
136 src/main/java/xian/recipes/web/controllers/RecipesController.java
@@ -0,0 +1,136 @@
+package xian.recipes.web.controllers;
+
+import org.apache.commons.collections15.Predicate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.Errors;
+import org.springframework.validation.Validator;
+import org.springframework.web.bind.WebDataBinder;
+import org.springframework.web.bind.annotation.InitBinder;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import xian.recipes.RecipeRepository;
+import xian.recipes.model.Ingredient;
+import xian.recipes.model.Recipe;
+import xian.recipes.model.Step;
+
+import javax.validation.Valid;
+
+import static org.apache.commons.collections15.CollectionUtils.filter;
+import static org.apache.commons.lang.StringUtils.isNotBlank;
+
+@Controller
+@RequestMapping(value = "/recipes")
+public class RecipesController
+{
+ @Autowired
+ RecipeRepository recipeRepository;
+
+ @InitBinder
+ public void initBinder(WebDataBinder binder)
+ {
+ // https://jira.springsource.org/browse/SPR-6437
+ binder.setValidator(new DelegatingValidatorAdaptor(binder.getValidator()));
+ }
+
+ @RequestMapping(method = RequestMethod.GET)
+ public String index(Model model)
+ {
+ model.addAttribute("recipes", recipeRepository.find());
+ return "recipes/index";
+ }
+
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ public String show(@PathVariable("id") Long id, Model model)
+ {
+ Recipe recipe = recipeRepository.find(id);
+ model.addAttribute("recipe", recipe);
+ return "recipes/show";
+ }
+
+ @RequestMapping(value = "new", method = RequestMethod.GET)
+ public String newInstance(Model model)
+ {
+ model.addAttribute("recipe", new Recipe());
+ return "recipes/new";
+ }
+
+ @RequestMapping(method = RequestMethod.POST)
+ public String create(@Valid Recipe recipe, BindingResult result)
+ {
+ if (result.hasErrors()) return "recipes/new";
+
+ recipeRepository.save(recipe);
+ return "redirect:/recipes";
+ }
+
+ @RequestMapping(value = "{id}/edit", method = RequestMethod.GET)
+ public String edit(@PathVariable Long id, Model model)
+ {
+ Recipe recipe = recipeRepository.find(id);
+ model.addAttribute("recipe", recipe);
+ return "recipes/edit";
+ }
+
+ @RequestMapping(value = "{id}", method = RequestMethod.PUT)
+ public String update(@PathVariable Long id, @Valid Recipe recipe, BindingResult result)
+ {
+ if (result.hasErrors()) return "recipes/edit";
+
+ recipe.setId(id);
+ recipeRepository.merge(recipe);
+ return String.format("redirect:/recipes/%s", id);
+ }
+
+ @RequestMapping(value = "{id}", method = RequestMethod.DELETE)
+ public String destroy(@PathVariable Long id)
+ {
+ Recipe recipe = recipeRepository.find(id);
+ recipeRepository.destroy(recipe);
+ return "redirect:/recipes";
+ }
+
+ private void removeEmpties(Recipe recipe)
+ {
+ filter(recipe.getIngredients(), new Predicate<Ingredient>()
+ {
+ @Override
+ public boolean evaluate(Ingredient ingredient)
+ {
+ return isNotBlank(ingredient.getName()) || (ingredient.getQuantity() != null && ingredient.getQuantity().getAmount() != null);
+ }
+ });
+ filter(recipe.getSteps(), new Predicate<Step>()
+ {
+ @Override
+ public boolean evaluate(Step step)
+ {
+ return isNotBlank(step.getDirections());
+ }
+ });
+ }
+
+ private class DelegatingValidatorAdaptor implements Validator
+ {
+ private Validator validator;
+
+ public DelegatingValidatorAdaptor(Validator validator) { this.validator = validator; }
+
+ @Override
+ public boolean supports(Class<?> clazz)
+ {
+ return validator.supports(clazz);
+ }
+
+ @Override
+ public void validate(Object target, Errors errors)
+ {
+ if (target instanceof Recipe) removeEmpties((Recipe) target);
+
+ validator.validate(target, errors);
+ }
+ }
+}
55 src/main/java/xian/recipes/web/controllers/UsersController.java
@@ -0,0 +1,55 @@
+package xian.recipes.web.controllers;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.WebDataBinder;
+import org.springframework.web.bind.annotation.InitBinder;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import xian.recipes.model.User;
+import xian.recipes.model.UserValidator;
+
+import javax.validation.Valid;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static java.lang.String.format;
+
+@Controller
+@RequestMapping("/users")
+public class UsersController
+{
+ private Map<Long, User> users = new ConcurrentHashMap<Long, User>();
+
+ @InitBinder
+ public void initBinder(WebDataBinder binder)
+ {
+ binder.setValidator(new UserValidator());
+ }
+
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ public String show(@PathVariable Long id, Model model)
+ {
+ model.addAttribute("user", users.get(id));
+ return "users/show";
+ }
+
+ @RequestMapping(value = "new", method = RequestMethod.GET)
+ public String newInstance(Model model)
+ {
+ model.addAttribute("user", new User());
+ return "users/new";
+ }
+
+ @RequestMapping(method = RequestMethod.POST)
+ public String create(@Valid User user, BindingResult result)
+ {
+ if (result.hasErrors()) return "users/new";
+
+ user.assignId();
+ users.put(user.getId(), user);
+ return format("redirect:/users/%s", user.getId());
+ }
+}
18 src/main/java/xian/recipes/web/formatters/CustomFormattingConversionServiceFactoryBean.java
@@ -0,0 +1,18 @@
+package xian.recipes.web.formatters;
+
+import org.springframework.format.FormatterRegistry;
+import org.springframework.format.support.FormattingConversionServiceFactoryBean;
+import xian.recipes.model.Quantity;
+import xian.recipes.model.Unit;
+
+public class CustomFormattingConversionServiceFactoryBean extends FormattingConversionServiceFactoryBean
+{
+ @Override
+ protected void installFormatters(FormatterRegistry registry)
+ {
+ super.installFormatters(registry);
+
+ registry.addFormatterForFieldType(Quantity.class, new QuantityFormatter());
+ registry.addFormatterForFieldType(Unit.class, new UnitFormatter());
+ }
+}
57 src/main/java/xian/recipes/web/formatters/QuantityFormatter.java
@@ -0,0 +1,57 @@
+package xian.recipes.web.formatters;
+
+import org.springframework.format.Formatter;
+import sun.reflect.generics.reflectiveObjects.NotImplementedException;
+import xian.recipes.model.Quantity;
+
+import java.math.BigDecimal;
+import java.text.ParseException;
+import java.util.HashMap;
+import java.util.Locale;
+
+import static com.google.common.collect.Maps.newHashMap;
+
+public class QuantityFormatter implements Formatter<Quantity>
+{
+ private final static HashMap<BigDecimal, String> fractions = newHashMap();
+
+ static
+ {
+ fractions.put(new BigDecimal(".25"), "1/4");
+ fractions.put(new BigDecimal(".3"), "1/3");
+ fractions.put(new BigDecimal(".33"), "1/3");
+ fractions.put(new BigDecimal(".5"), "1/2");
+ fractions.put(new BigDecimal(".6"), "2/3");
+ fractions.put(new BigDecimal(".66"), "2/3");
+ fractions.put(new BigDecimal(".75"), "3/4");
+ }
+
+ @Override
+ public String print(Quantity quantity, Locale locale)
+ {
+ if (quantity.getAmount() == null) return "";
+
+ StringBuilder sb = new StringBuilder();
+
+ if (fractions.containsKey(quantity.getAmount().stripTrailingZeros()))
+ {
+ sb.append(fractions.get(quantity.getAmount().stripTrailingZeros()));
+ }
+ else
+ {
+ int a = quantity.getAmount().toString().indexOf(".") + 1;
+ int b = quantity.getAmount().toString().indexOf("0", a);
+ sb.append(String.format("%." + (b - a) + "f", quantity.getAmount()));
+ }
+
+ sb.append(" ");
+ sb.append(quantity.getUnits().toString().toLowerCase(locale));
+ return sb.toString();
+ }
+
+ @Override
+ public Quantity parse(String text, Locale locale) throws ParseException
+ {
+ throw new NotImplementedException();
+ }
+}
24 src/main/java/xian/recipes/web/formatters/UnitFormatter.java
@@ -0,0 +1,24 @@
+package xian.recipes.web.formatters;
+
+import org.apache.commons.lang.StringUtils;
+import org.springframework.format.Formatter;
+import org.springframework.format.Printer;
+import xian.recipes.model.Unit;
+
+import java.text.ParseException;
+import java.util.Locale;
+
+public class UnitFormatter implements Formatter<Unit>
+{
+ @Override
+ public String print(Unit unit, Locale locale)
+ {
+ return StringUtils.lowerCase(unit.toString());
+ }
+
+ @Override
+ public Unit parse(String text, Locale locale) throws ParseException
+ {
+ return Enum.valueOf(Unit.class, text.toUpperCase().trim());
+ }
+}
16 src/main/resources/logback.xml
@@ -0,0 +1,16 @@
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+ <pattern>%-6level- %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <root level="DEBUG">
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+ <logger name="org.springframework" level="ALL"/>
+ <logger name="org.hibernate" level="WARN"/>
+ <!--<logger name="org.springframework.transaction.interceptor" level="DEBUG"/>-->
+ <!--<logger name="org.springframework.orm.hibernate3" level="DEBUG"/>-->
+</configuration>
55 src/main/resources/xian/recipes/application-context.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:tx="http://www.springframework.org/schema/tx"
+ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+ http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd
+ http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd"
+ default-autowire="constructor">
+
+ <context:component-scan base-package="xian.recipes"/>
+
+ <bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
+ <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE"/>
+ <property name="ignoreResourceNotFound" value="true"/>
+ <property name="locations">
+ <list>
+ <value>classpath:/xian/recipes/application.properties</value>
+ </list>
+ </property>
+ </bean>
+
+ <bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean" />
+
+ <!-- Hibernate Configuration -->
+ <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
+ <property name="driverClass" value="${db.driver}"/>
+ <property name="url" value="${db.url}"/>
+ <property name="username" value="${db.username}"/>
+ <property name="password" value="${db.password}"/>
+ </bean>
+
+ <util:properties id="hibernateProperties" location="classpath:/xian/recipes/hibernate.properties"
+ local-override="true">
+ <prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
+ <prop key="hibernate.format_sql">${hibernate.format_sql}</prop>
+ <prop key="hibernate.use_sql_comments">${hibernate.use_sql_comments}</prop>
+ </util:properties>
+
+ <bean id="sessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean">
+ <property name="dataSource" ref="dataSource"/>
+ <property name="hibernateProperties" ref="hibernateProperties"/>
+ <property name="mappingLocations" value="classpath*:/xian/recipes/**/*.hbm.xml"/>
+ </bean>
+
+ <bean id="transactionManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager">
+ <property name="sessionFactory" ref="sessionFactory"/>
+ </bean>
+
+ <tx:annotation-driven transaction-manager="transactionManager"/>
+
+ <!-- Application -->
+ <bean class="xian.recipes.Bootstrapper" init-method="bootstrap"/>
+</beans>
10 src/main/resources/xian/recipes/application.properties
@@ -0,0 +1,10 @@
+db.driver = com.mysql.jdbc.Driver
+db.url = jdbc:mysql://localhost/recipes_development
+#db.driver = org.h2.Driver
+#db.url = jdbc:h2:file:./db/development.h2db
+db.username = root
+db.password =
+
+hibernate.show_sql = false
+hibernate.format_sql = false
+hibernate.use_sql_comments = false
52 src/main/resources/xian/recipes/hibernate-mapping.hbm.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0"?>
+<!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">
+<hibernate-mapping default-lazy="false" default-access="field">
+
+ <typedef class="com.carbonfive.db.hibernate.usertypes.EnumUserType" name="UnitType">
+ <param name="enumClass">xian.recipes.model.Unit</param>
+ </typedef>
+
+
+ <class name="xian.recipes.model.Recipe" table="recipes">
+ <id name="id" column="id" type="long">
+ <generator class="native"/>
+ </id>
+ <property name="name"/>
+ <property name="description"/>
+ <property name="servingCount"/>
+ <property name="preparationTime"/>
+ <property name="cost"/>
+
+ <list name="ingredients" cascade="all,delete-orphan">
+ <key column="recipe_id" not-null="true"/>
+ <index column="idx" type="int"/>
+ <one-to-many class="xian.recipes.model.Ingredient"/>
+ </list>
+
+ <list name="steps" cascade="all,delete-orphan">
+ <key column="recipe_id" not-null="true"/>
+ <index column="idx" type="int"/>
+ <one-to-many class="xian.recipes.model.Step"/>
+ </list>
+ </class>
+
+ <class name="xian.recipes.model.Ingredient" table="ingredients">
+ <id name="id" column="id" type="long">
+ <generator class="native"/>
+ </id>
+ <property name="name"/>
+ <component name="quantity">
+ <property name="amount"/>
+ <property name="units" type="UnitType"/>
+ </component>
+ <property name="preparation"/>
+ </class>
+
+ <class name="xian.recipes.model.Step" table="steps">
+ <id name="id" column="id" type="long">
+ <generator class="native"/>
+ </id>
+ <property name="directions"/>
+ </class>
+
+</hibernate-mapping>
22 src/main/resources/xian/recipes/hibernate.properties
@@ -0,0 +1,22 @@
+#
+# Hibernate Settings - Not intended to be overridden across environments.
+#
+
+hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
+#hibernate.dialect=org.hibernate.dialect.H2Dialect
+hibernate.hbm2ddl.auto=update
+
+#hibernate.max_fetch_depth=3
+#hibernate.jdbc.fetch_size=25
+#hibernate.jdbc.batch_size=5
+#hibernate.jdbc.batch_versioned_data=true
+#hibernate.jdbc.use_scrollable_resultset=true
+#hibernate.jdbc.use_streams_for_binary=true
+
+#hibernate.cache.provider_class=org.hibernate.cache.EhCacheProvider
+hibernate.cache.provider_class=org.hibernate.cache.NoCacheProvider
+hibernate.cache.use_second_level_cache=false
+hibernate.cache.use_query_cache=false
+#hibernate.cache.use_minimal_puts=true
+#hibernate.cache.region_prefix=hibernate.test
+#hibernate.cache.use_structured_entries=true
30 src/main/webapp/WEB-INF/dispatcher-servlet.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:mvc="http://www.springframework.org/schema/mvc"
+ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+ http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd">
+
+ <mvc:annotation-driven conversion-service="conversionService"/>
+
+ <context:component-scan base-package="xian.recipes.web" use-default-filters="false">
+ <context:include-filter expression="org.springframework.stereotype.Controller" type="annotation"/>
+ </context:component-scan>
+
+ <mvc:view-controller path="/" view-name="redirect:/recipes"/>
+
+ <bean id="messageSource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
+ <property name="basename" value="/WEB-INF/messages"/>
+ <property name="cacheSeconds" value="0"/>
+ </bean>
+
+ <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
+ <property name="prefix" value="/WEB-INF/jsps/"/>
+ <property name="suffix" value=".jsp"/>
+ </bean>
+
+ <bean id="conversionService" class="xian.recipes.web.formatters.CustomFormattingConversionServiceFactoryBean"/>
+
+</beans>
10 src/main/webapp/WEB-INF/jsps/current-time.jsp
@@ -0,0 +1,10 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ taglib prefix="template" tagdir="/WEB-INF/tags" %>
+
+<jsp:useBean id="currentTime" scope="request" type="java.util.Date"/>
+
+<template:page title="Current Time" bodyClass="current-time">
+ <jsp:body>
+ ${currentTime}
+ </jsp:body>
+</template:page>
18 src/main/webapp/WEB-INF/jsps/recipes/edit.jsp
@@ -0,0 +1,18 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
+<%@ taglib prefix="template" tagdir="/WEB-INF/tags" %>
+<%@ taglib prefix="recipe" tagdir="/WEB-INF/tags/recipe" %>
+
+<jsp:useBean id="recipe" scope="request" type="xian.recipes.model.Recipe"/>
+
+<template:page title="Edit Recipe">
+ <jsp:attribute name="head">
+ <script type="text/javascript" src="/static/javascripts/recipes-form.onready.js"></script>
+ </jsp:attribute>
+ <jsp:body>
+ <recipe:form recipe="${recipe}" operation="update"/>
+ </jsp:body>
+</template:page>
42 src/main/webapp/WEB-INF/jsps/recipes/index.jsp
@@ -0,0 +1,42 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
+<%@ taglib prefix="template" tagdir="/WEB-INF/tags" %>
+
+<jsp:useBean id="recipes" scope="request" type="java.util.List<xian.recipes.model.Recipe>"/>
+
+<template:page title="Recipes" bodyClass="">
+ <jsp:body>
+ <table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Serves</th>
+ <th>Time</th>
+ <th>Cost</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <c:forEach items="${recipes}" var="recipe">
+ <tr>
+ <td><a href="/recipes/${recipe.id}">${recipe.name}</a></td>
+ <td>${recipe.servingCount}</td>
+ <td>${recipe.preparationTime}</td>
+ <td><spring:eval expression="recipe.cost"/></td>
+ <td>
+ <a href="/recipes/${recipe.id}/edit">Edit</a>
+ <a href="/recipes/${recipe.id}" data-confirm="Are you sure?" data-method="delete" rel="nofollow">Delete</a>
+ </td>
+ </tr>
+ </c:forEach>
+ </table>
+
+ <div class="actions">
+ <ul>
+ <li><a href="/recipes/new">Add a recipe</a></li>
+ </ul>
+ </div>
+
+ </jsp:body>
+</template:page>
18 src/main/webapp/WEB-INF/jsps/recipes/new.jsp
@@ -0,0 +1,18 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
+<%@ taglib prefix="template" tagdir="/WEB-INF/tags" %>
+<%@ taglib prefix="recipe" tagdir="/WEB-INF/tags/recipe" %>
+
+<jsp:useBean id="recipe" scope="request" type="xian.recipes.model.Recipe"/>
+
+<template:page title="New Recipe">
+ <jsp:attribute name="head">
+ <script type="text/javascript" src="/static/javascripts/recipes-form.onready.js"></script>
+ </jsp:attribute>
+ <jsp:body>
+ <recipe:form recipe="${recipe}" operation="create"/>
+ </jsp:body>
+</template:page>
55 src/main/webapp/WEB-INF/jsps/recipes/show.jsp
@@ -0,0 +1,55 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="template" tagdir="/WEB-INF/tags" %>
+
+<jsp:useBean id="recipe" scope="request" type="xian.recipes.model.Recipe"/>
+
+<template:page title="${recipe.name}" bodyClass="">
+ <jsp:body>
+ <ul>
+ <li>
+ <b>Name</b>
+ ${recipe.name}
+ </li>
+ <li>
+ <b>Description</b>
+ ${recipe.description}
+ </li>
+ <li>
+ <b>Serves</b>
+ ${recipe.servingCount}
+ </li>
+ <li>
+ <b>Prep. Time</b>
+ ${recipe.preparationTime} minutes
+ </li>
+ <li>
+ <b>Cost (Approximate)</b>
+ <spring:eval expression="recipe.cost"/>
+ </li>
+ <li>
+ <b>Ingredients</b>
+ <ul>
+ <c:forEach items="${recipe.ingredients}" var="ingredient" varStatus="loopStatus">
+ <li><spring:eval expression="ingredient.quantity"/>&nbsp;${ingredient.name}&nbsp;<c:if test="${!empty ingredient.preparation}">- ${ingredient.preparation}</c:if></li>
+ </c:forEach>
+ </ul>
+ </li>
+ <li>
+ <b>Directions</b>
+ <ol>
+ <c:forEach items="${recipe.steps}" var="step" varStatus="loopStatus">
+ <li>${step.directions}</li>
+ </c:forEach>
+ </ol>
+ </li>
+ </ul>
+
+ <div class="actions">
+ <ul>
+ <li><a href="/recipes/${recipe.id}/edit">Edit</a></li>
+ </ul>
+ </div>
+ </jsp:body>
+</template:page>
1  src/main/webapp/WEB-INF/messages.properties
@@ -0,0 +1 @@
+typeMismatch=invalid entry
38 src/main/webapp/WEB-INF/tags/page.tag
@@ -0,0 +1,38 @@
+<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core_rt" %>
+
+<%@ attribute name="title" %>
+<%@ attribute name="bodyClass" %>
+
+<%@ attribute name="head" fragment="true" %>
+<%@ attribute name="titlebar" fragment="true" %>
+
+<!doctype html>
+<html lang="en">
+<head>
+ <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
+
+ <title>${title}</title>
+
+ <link type="text/css" rel="stylesheet" media="all" href="/static/stylesheets/recipes.css">
+
+ <script type="text/javascript" src="/static/javascripts/ext/jquery-1.4.2.js"></script>
+ <script type="text/javascript" src="/static/javascripts/ext/jquery.ezpz_hint.js"></script>
+ <%--<script type="text/javascript" src="/static/javascripts/application.js"></script>--%>
+ <script type="text/javascript" src="/static/javascripts/application.onready.js"></script>
+
+ <jsp:invoke fragment="head"/>
+</head>
+
+<body class="${bodyClass}">
+<div id="head">
+ <div id="message-container">
+ <div id="message"></div>
+ </div>
+</div>
+<div id="page">
+ <div id="content">
+ <jsp:doBody/>
+ </div>
+</div>
+</body>
+</html>
97 src/main/webapp/WEB-INF/tags/recipe/form.tag
@@ -0,0 +1,97 @@
+<%@ tag body-content="scriptless" %>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
+<%@ taglib prefix="recipe" tagdir="/WEB-INF/tags/recipe" %>
+
+<%@ attribute name="recipe" type="xian.recipes.model.Recipe" required="true" %>
+<%@ attribute name="operation" required="true" %>
+
+<c:choose>
+ <c:when test="${operation == 'create'}">
+ <c:set var="action">/recipes</c:set>
+ <c:set var="method">post</c:set>
+ <c:set var="buttonText">Create</c:set>
+ </c:when>
+ <c:otherwise>
+ <c:set var="action">/recipes/${recipe.id}</c:set>
+ <c:set var="method">put</c:set>
+ <c:set var="buttonText">Update</c:set>
+ </c:otherwise>
+</c:choose>
+
+<form:form commandName="recipe" action="${action}" method="${method}">
+ <input type="hidden" name="id" value="${recipe.id}"/>
+
+ <fieldset>
+ <legend>Recipe</legend>
+
+ <p>
+ <form:label path="name">Name</form:label><br/>
+ <form:input path="name" cssClass="recipe-name" cssErrorClass="recipe-name error" autocomplete="off"/>
+ <form:errors path="name" delimiter=", "/>
+ </p>
+
+ <p>
+ <form:label path="description">Description</form:label><br/>
+ <form:textarea path="description" cssClass="description" cssErrorClass="description error"/>
+ <form:errors path="description" delimiter=", "/>
+ </p>
+
+ <p>
+ <form:label path="servingCount">Servings</form:label><br/>
+ <form:input path="servingCount" cssErrorClass="error" autocomplete="off"/>
+ <form:errors path="servingCount" delimiter=", "/>
+ </p>
+
+ <p>
+ <form:label path="preparationTime">Preparation Time (minutes)</form:label><br/>
+ <form:input path="preparationTime" cssErrorClass="error" autocomplete="off"/>
+ <form:errors path="preparationTime" delimiter=", "/>
+ </p>
+
+ <p>
+ <form:label path="cost">Cost (approximate)</form:label><br/>
+ <form:input path="cost" cssErrorClass="error" autocomplete="off" title="$15.00"/>
+ <form:errors path="cost" delimiter=", "/>
+ </p>
+ </fieldset>
+ <fieldset>
+ <legend>Ingredients</legend>
+ <form:errors path="ingredients"/>
+ <ul class="ingredients">
+ <c:forEach items="${recipe.ingredients}" var="ingredient" varStatus="loopStatus">
+ <li>
+ <recipe:ingredient name="ingredients[${loopStatus.index}]" ingredient="${ingredient}"/>
+ <button type="button" class="delete">Delete</button>
+ </li>
+ </c:forEach>
+ <li>
+ <recipe:ingredient name="ingredients[${fn:length(recipe.ingredients)}]"/>
+ <button type="button" class="delete">Delete</button>
+ </li>
+ </ul>
+ <button type="button" class="add-ingredient">Add Another</button>
+ </fieldset>
+ <fieldset>
+ <legend>Directions</legend>
+ <form:errors path="steps"/>
+ <ol class="steps">
+ <c:forEach items="${recipe.steps}" var="step" varStatus="loopStatus">
+ <li>
+ <form:input path="steps[${loopStatus.index}].directions" autocomplete="off"/>
+ <form:errors path="steps[${loopStatus.index}].directions" delimiter=", "/>
+ <button type="button" class="delete">Delete</button>
+ </li>
+ </c:forEach>
+ <li>
+ <form:input path="steps[${fn:length(recipe.steps)}].directions" autocomplete="off"/>
+ <form:errors path="steps[${fn:length(recipe.steps)}].directions" delimiter=", "/>
+ <button type="button" class="delete">Delete</button>
+ </li>
+ </ol>
+ <button type="button" class="add-step">Add Another</button>
+ </fieldset>
+ <button type="submit">${buttonText}</button>
+</form:form>
21 src/main/webapp/WEB-INF/tags/recipe/ingredient.tag
@@ -0,0 +1,21 @@
+<%@ tag body-content="scriptless" %>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
+
+<%@ attribute name="name" required="true" %>
+<%@ attribute name="ingredient" type="xian.recipes.model.Ingredient" %>
+
+<form:input path="${name}.quantity.amount" cssClass="amount" cssErrorClass="amount error" autocomplete="off"
+ title="${empty ingredient ? '2' : ''}"/>
+<form:select path="${name}.quantity.units" cssClass="units" cssErrorClass="units error">
+ <c:forEach items="<%= xian.recipes.model.Unit.values() %>" var="unit">
+ <form:option value="${unit}"/>
+ </c:forEach>
+</form:select>
+<form:input path="${name}.name" cssClass="name" cssErrorClass="name error" autocomplete="off"
+ title="${empty ingredient ? 'Apples' : ''}"/>
+<form:input path="${name}.preparation" cssClass="preparation" cssErrorClass="preparation error" autocomplete="off"
+ title="${empty ingredient ? 'sliced thinly' : ''}"/>
+<form:errors path="${name}" delimiter=", "/>
20 src/main/webapp/WEB-INF/urlrewrite.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE urlrewrite PUBLIC "-//tuckey.org//DTD UrlRewrite 3.0//EN" "http://tuckey.org/res/dtds/urlrewrite3.0.dtd">
+<urlrewrite>
+
+ <rule>
+ <from>^/favicon.ico$</from>
+ <to type="forward">/static/images/favicon.ico</to>
+ </rule>
+
+ <rule>
+ <from>^/static/(.*)</from>
+ <to type="forward">/static/$1</to>
+ </rule>
+
+ <rule match-type="wildcard">
+ <from>/**</from>
+ <to type="forward">/a/$1</to>
+ </rule>
+
+</urlrewrite>
82 src/main/webapp/WEB-INF/web.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<web-app xmlns="http://java.sun.com/xml/ns/javaee"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
+ version="2.5">
+
+ <context-param>
+ <param-name>contextConfigLocation</param-name>
+ <param-value>classpath*:/xian/recipes/**/application-context*.xml</param-value>
+ </context-param>
+
+ <listener>
+ <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
+ </listener>
+
+ <filter>
+ <filter-name>characterEncodingFilter</filter-name>
+ <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
+ <init-param>
+ <param-name>encoding</param-name>
+ <param-value>UTF-8</param-value>
+ </init-param>
+ </filter>
+
+ <filter>
+ <filter-name>urlRewriteFilter</filter-name>
+ <filter-class>org.tuckey.web.filters.urlrewrite.UrlRewriteFilter</filter-class>
+ </filter>
+
+ <filter>
+ <filter-name>httpMethodFilter</filter-name>
+ <filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class>
+ </filter>
+
+ <filter-mapping>
+ <filter-name>characterEncodingFilter</filter-name>
+ <url-pattern>/*</url-pattern>
+ <dispatcher>REQUEST</dispatcher>
+ </filter-mapping>
+
+ <filter-mapping>
+ <filter-name>httpMethodFilter</filter-name>
+ <url-pattern>*</url-pattern>
+ </filter-mapping>
+
+ <filter-mapping>
+ <filter-name>urlRewriteFilter</filter-name>
+ <url-pattern>/*</url-pattern>
+ <dispatcher>REQUEST</dispatcher>
+ </filter-mapping>
+
+ <servlet>
+ <display-name>Spring Dispatcher Servlet</display-name>
+ <servlet-name>dispatcher</servlet-name>
+ <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
+ <load-on-startup>1</load-on-startup>
+ </servlet>
+
+ <servlet-mapping>
+ <servlet-name>default</servlet-name>
+ <url-pattern>/favicon.ico</url-pattern>
+ </servlet-mapping>
+
+ <servlet-mapping>
+ <servlet-name>default</servlet-name>
+ <url-pattern>/static/</url-pattern>
+ </servlet-mapping>
+
+ <servlet-mapping>
+ <servlet-name>dispatcher</servlet-name>
+ <url-pattern>/a/*</url-pattern>
+ </servlet-mapping>
+
+ <jsp-config>
+ <jsp-property-group>
+ <url-pattern>*.jsp</url-pattern>
+ <page-encoding>UTF-8</page-encoding>
+ <trim-directive-whitespaces>true</trim-directive-whitespaces>
+ </jsp-property-group>
+ </jsp-config>
+
+</web-app>
BIN  src/main/webapp/static/images/favicon.ico
Binary file not shown
97 src/main/webapp/static/javascripts/application.onready.js
@@ -0,0 +1,97 @@
+$(document).ready(function()
+{
+ // Borrowed from Rails 3.
+ $.fn.extend({
+ /**
+ * Triggers a custom event on an element and returns the event result
+ * this is used to get around not being able to ensure callbacks are placed
+ * at the end of the chain.
+ *
+ * TODO: deprecate with jQuery 1.4.2 release, in favor of subscribing to our
+ * own events and placing ourselves at the end of the chain.
+ */
+ triggerAndReturn: function (name, data)
+ {
+ var event = new $.Event(name);
+ this.trigger(event, data);
+
+ return event.result !== false;
+ },
+
+ /**
+ * Handles execution of remote calls firing overridable events along the way
+ */
+ callRemote: function ()
+ {
+ var el = this,
+ data = el.is('form') ? el.serializeArray() : [],
+ method = el.attr('method') || el.attr('data-method') || 'GET',
+ url = el.attr('action') || el.attr('href');
+
+ if (url === undefined)
+ {
+ throw "No URL specified for remote call (action or href must be present).";
+ }
+ else
+ {
+ if (el.triggerAndReturn('ajax:before'))
+ {
+ $.ajax({
+ url: url,
+ data: data,
+ dataType: 'script',
+ type: method.toUpperCase(),
+ beforeSend: function (xhr)
+ {
+ el.trigger('ajax:loading', xhr);
+ },
+ success: function (data, status, xhr)
+ {
+ el.trigger('ajax:success', [data, status, xhr]);
+ },
+ complete: function (xhr)
+ {
+ el.trigger('ajax:complete', xhr);
+ },
+ error: function (xhr, status, error)
+ {
+ el.trigger('ajax:failure', [xhr, status, error]);
+ }
+ });
+ }
+
+ el.trigger('ajax:after');
+ }
+ }
+ });
+
+ $('a[data-confirm],input[data-confirm]').live('click', function ()
+ {
+ var el = $(this);
+ if (el.triggerAndReturn('confirm'))
+ {
+ if (!confirm(el.attr('data-confirm')))
+ {
+ return false;
+ }
+ }
+ });
+
+ $('a[data-method]:not([data-remote])').live('click', function (e)
+ {
+ var link = $(this),
+ href = link.attr('href'),
+ method = link.attr('data-method'),
+ form = $('<form method="post" action="' + href + '">'),
+ metadata_input = '<input name="_method" value="' + method + '" type="hidden" />';
+
+ form.hide().append(metadata_input).appendTo('body');
+
+ e.preventDefault();
+ form.submit();
+ });
+
+
+ // Input hints
+ $('input[type=text]').ezpz_hint({ hintClass : "blur" });
+});
6,240 src/main/webapp/static/javascripts/ext/jquery-1.4.2.js
6,240 additions, 0 deletions not shown
58 src/main/webapp/static/javascripts/ext/jquery.ezpz_hint.js
@@ -0,0 +1,58 @@
+// EZPZ Hint v1.1.1; Copyright (c) 2009 Mike Enriquez, http://theezpzway.com; Released under the MIT License
+(function($){
+ $.fn.ezpz_hint = function(options){
+ var defaults = {
+ hintClass: 'ezpz-hint',
+ hintName: 'ezpz_hint_dummy_input'
+ };
+ var settings = $.extend(defaults, options);
+
+ return this.each(function(i){
+ var id = settings.hintName + '_' + i;
+ var hint;
+ var dummy_input;
+
+ // grab the input's title attribute
+ text = $(this).attr('title');
+
+ // create a dummy input and place it before the input
+ $('<input type="text" id="' + id + '" value="" />')
+ .insertBefore($(this));
+
+ // set the dummy input's attributes
+ hint = $(this).prev('input:first');
+ hint.attr('class', $(this).attr('class'));
+ hint.attr('size', $(this).attr('size'));
+ hint.attr('autocomplete', 'off');
+ hint.attr('tabIndex', $(this).attr('tabIndex'));
+ hint.addClass(settings.hintClass);
+ hint.val(text);
+
+ // hide the input
+ $(this).hide();
+
+ // don't allow autocomplete (sorry, no remember password)
+ $(this).attr('autocomplete', 'off');
+
+ // bind focus event on the dummy input to swap with the real input
+ hint.focus(function(){
+ dummy_input = $(this);
+ $(this).next('input:first').show();
+ $(this).next('input:first').focus();
+ $(this).next('input:first').unbind('blur').blur(function(){
+ if ($(this).val() == '') {
+ $(this).hide();
+ dummy_input.show();
+ }
+ });
+ $(this).hide();
+ });
+
+ // swap if there is a default value
+ if ($(this).val() != ''){
+ hint.focus();
+ };
+ });
+
+ };
+})(jQuery);
67 src/main/webapp/static/javascripts/recipes-form.onready.js
@@ -0,0 +1,67 @@
+$(document).ready(function()
+{
+ // TODO DRY these up!
+
+ $('.add-ingredient').click(function()
+ {
+ if ($(this).data('count') == null)
+ {
+ //alert("initializing");
+ var input = $('.ingredients li:last input[name!=""]:first')[0];
+ var index = parseInt($(input).attr('name').match(/.*?\[(.*?)\].*/)[1]);
+ $(this).data('count', index);
+ }
+
+ //alert($(this).data('count'));
+ var newIngredient = $('.ingredients li:last').clone(true).appendTo($('.ingredients'));
+ var newIndex = $(this).data('count') + 1;
+
+ $(this).data('count', newIndex);
+
+ $('input,select', newIngredient).each(function()
+ {
+ $(this).attr('name', $(this).attr('name').replace(/\[.+?\]/, "[" + newIndex + "]"));
+ $(this).attr('value', '');
+ //alert($(this).attr('name'));
+ });
+
+ //$(newIngredient).ezpz_hint({ hintClass : 'blur' })
+ $('input:nth(0)', newIngredient).focus();
+ });
+
+ $('.add-step').click(function()
+ {
+ if ($(this).data('count') == null)
+ {
+ //alert("initializing");
+ var input = $('.steps li:last input[name!=""]:first')[0];
+ var index = parseInt($(input).attr('name').match(/.*?\[(.*?)\].*/)[1]);
+ $(this).data('count', index);
+ }
+
+ //alert($(this).data('count'));
+
+ var newStep = $('.steps li:last').clone(true).appendTo($('.steps'));
+ var newIndex = $(this).data('count') + 1;
+
+ $(this).data('count', newIndex);
+
+ $('input', newStep).each(function()
+ {
+ $(this).attr('name', $(this).attr('name').replace(/\[.+?\]/, "[" + newIndex + "]"));
+ $(this).attr('value', '');
+ //alert($(this).attr('name'));
+ });
+
+ $('input:nth(0)', newStep).focus();
+ });
+
+ $('.delete').click(function()
+ {
+ if ($(this).parent().siblings('li').length > 0)
+ {
+ $(this).parent().fadeOut(function() { $(this).remove(); });
+ }
+ });
+
+});
46 src/main/webapp/static/stylesheets/recipes.css
@@ -0,0 +1,46 @@
+input.blur, textarea.blur {
+ color: #999;
+}
+
+th {
+ text-align: left;
+}
+
+td {
+ padding-right: 20px;
+}
+
+.error {
+ background-color: #ff9999;
+}
+.recipe-name {
+ width: 300px;
+}
+
+.description {
+ width: 300px;
+}
+
+.ingredients li {
+ list-style: none;
+}
+
+.ingredients .amount {
+ width: 50px;
+}
+
+.ingredients .units {
+ width: 100px;
+}
+
+.ingredients .name {
+ width: 150px;
+}
+
+.ingredients .preparation {
+ width: 306px;
+}
+
+.steps input[type='text'] {
+ width: 630px;
+}
77 src/test/java/xian/recipes/model/IngredientValidationTest.java
@@ -0,0 +1,77 @@
+package xian.recipes.model;
+
+import org.apache.commons.collections15.CollectionUtils;
+import org.apache.commons.collections15.Predicate;
+import org.apache.commons.lang.StringUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
+
+import javax.validation.ConstraintViolation;
+import javax.validation.Validator;
+import java.math.BigDecimal;
+import java.util.Set;
+
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.core.IsNot.not;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@ContextConfiguration(locations = {"classpath:/xian/recipes/application-context.xml"})
+@TestExecutionListeners({DependencyInjectionTestExecutionListener.class})
+public class IngredientValidationTest
+{
+ @Autowired
+ Validator validator;
+
+ @Test
+ public void shouldRequireQuantityAndName()
+ {
+ Ingredient ingredient = new Ingredient(null, null, null);
+ assertThat(fieldViolations(ingredient, "name").isEmpty(), is(false));
+ assertThat(fieldViolations(ingredient, "quantity").isEmpty(), is(false));
+ }
+
+ @Test
+ public void shouldRequireQuanitytAmountAndUnits()
+ {
+ Ingredient ingredient = new Ingredient("Apples", new Quantity(), null);
+ assertThat(fieldViolations(ingredient, "quantity.amount").isEmpty(), is(false));
+ assertThat(fieldViolations(ingredient, "quantity.units").isEmpty(), is(false));
+ }
+
+ @Test
+ public void shouldAllowValidIngredients()
+ {
+ Ingredient ingredient = new Ingredient("Apples", new Quantity(new BigDecimal("2"), Unit.WHOLE), "cubed");
+ assertThat(validator.validate(ingredient).isEmpty(), is(true));
+ }
+
+ private Set<ConstraintViolation<Ingredient>> fieldViolations(Ingredient ingredient, String fieldName)
+ {
+ Set<ConstraintViolation<Ingredient>> violations = validator.validate(ingredient);
+ CollectionUtils.filter(violations, new ConstraintViolationPredicate(fieldName));
+ return violations;
+ }
+
+ private static class ConstraintViolationPredicate implements Predicate<ConstraintViolation<Ingredient>>
+ {
+ private final String fieldName;
+
+ private ConstraintViolationPredicate(String fieldName)
+ {
+ this.fieldName = fieldName;
+ }
+
+ @Override
+ public boolean evaluate(ConstraintViolation<Ingredient> violation)
+ {
+ return StringUtils.equals(fieldName, violation.getPropertyPath().toString());
+ }
+ }
+}
47 src/test/java/xian/recipes/model/RecipeTest.java
@@ -0,0 +1,47 @@
+package xian.recipes.model;
+
+import org.junit.Test;
+
+import java.math.BigDecimal;
+
+import static xian.recipes.model.Ingredient.newIngredient;
+import static xian.recipes.model.Quantity.newQuantity;
+import static xian.recipes.model.Step.newStep;
+
+public class RecipeTest
+{
+ @Test
+ public void shouldCreateRecipeWithIngredientsAndSteps()
+ {
+ Recipe recipe = new Recipe();
+ recipe.setName("Aloo Ghobi (Potatos and Cauliflower)");
+ recipe.setDescription("");
+ recipe.setCost(BigDecimal.valueOf(9.00));
+ recipe.setPreparationTime(45);
+ recipe.addIngredient(newIngredient("Vegetable Oil", newQuantity(0.25, Unit.CUP)));
+ recipe.addIngredient(newIngredient("Large Onion", newQuantity(1, Unit.WHOLE), "peeled and cut into small pieces"));
+ recipe.addIngredient(newIngredient("Fresh Coriander", newQuantity(1, Unit.BUNCH), "separated into stalks and leaves and roughly chopped"));
+ recipe.addIngredient(newIngredient("Small Green Chili", newQuantity(1, Unit.WHOLE), "chopped into small pieces"));
+ recipe.addIngredient(newIngredient("Large Cauliflower", newQuantity(1, Unit.WHOLE), "eaves removed and cut evenly into eighths"));
+ recipe.addIngredient(newIngredient("Large Potatoes", newQuantity(3, Unit.WHOLE), "peeled and cut into even pieces"));
+ recipe.addIngredient(newIngredient("Diced Tomoatoes", newQuantity(2, Unit.CANS), ""));
+ recipe.addIngredient(newIngredient("Fresh Ginger", newQuantity(2, Unit.TABLESPOON), "peeled and grated"));
+ recipe.addIngredient(newIngredient("Fresh Garlic", newQuantity(1, Unit.TEASPOON), "chopped"));
+ recipe.addIngredient(newIngredient("Cumin Seed", newQuantity(1, Unit.TEASPOON), ""));
+ recipe.addIngredient(newIngredient("Tumeric", newQuantity(2, Unit.TEASPOON), ""));
+ recipe.addIngredient(newIngredient("Salt", newQuantity(1, Unit.TEASPOON), ""));
+ recipe.addIngredient(newIngredient("Garam Masala", newQuantity(2, Unit.TEASPOON), ""));
+ recipe.addStep(newStep("Heat vegetable oil in a large saucepan."));
+ recipe.addStep(newStep("Add the chopped onion and one teaspoon of cumin seeds to the oil."));
+ recipe.addStep(newStep("Stir together and cook until onions become creamy, golden, and translucent."));
+ recipe.addStep(newStep("Add chopped coriander stalks, two teaspoons of turmeric, and one teaspoon of salt."));
+ recipe.addStep(newStep("Add chopped chillis (according to taste) Stir tomatoes into onion mixture."));
+ recipe.addStep(newStep("Add ginger and garlic; mix thoroughly."));
+ recipe.addStep(newStep("Add potatoes and cauliflower to the sauce plus a few tablespoons of water (ensuring that the mixture doesn't stick to the saucepan)."));
+ recipe.addStep(newStep("Ensure that the potatoes and cauliflower are coated with the curry sauce."));
+ recipe.addStep(newStep("Cover and allow to simmer for twenty minutes (or until potatoes are cooked)."));
+ recipe.addStep(newStep("Add two teaspoons of Garam Masala and stir."));
+ recipe.addStep(newStep("Sprinkle chopped coriander leaves on top of the curry."));
+ recipe.addStep(newStep("Turn off the heat, cover, and leave for as long as possible before serving."));
+ }
+}
26 src/test/java/xian/recipes/model/RecipeValidationTest.java
@@ -0,0 +1,26 @@
+package xian.recipes.model;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
+
+import javax.validation.Validator;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@ContextConfiguration(locations = {"classpath:/xian/recipes/application-context.xml"})
+@TestExecutionListeners({DependencyInjectionTestExecutionListener.class})
+public class RecipeValidationTest
+{
+ @Autowired
+ Validator validator;
+
+ @Test
+ public void shouldRequireValidIngredients()
+ {
+ // ...
+ }
+}
43 src/test/java/xian/recipes/model/UserValidatorTest.java
@@ -0,0 +1,43 @@
+package xian.recipes.model;
+
+import org.junit.Test;
+import org.springframework.validation.BeanPropertyBindingResult;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static xian.recipes.model.User.newUser;
+
+public class UserValidatorTest
+{
+ @Test
+ public void shouldAcceptValidEmail()
+ {
+ assertValidEmail(newUser("cnelson@example.com", "Christian Nelson"));
+ assertValidEmail(newUser("a@a.ca", "A"));
+ }
+
+ @Test
+ public void shouldRejectInvalidEmail()
+ {
+ assertInvalidEmail(newUser("cnelson@example.c", "Christian Nelson"));
+ assertInvalidEmail(newUser("c nelson@example.com", "Christian Nelson"));
+ assertInvalidEmail(newUser(" cnelson@example.com", "Christian Nelson"));
+ assertInvalidEmail(newUser("cnelson@example.com ", "Christian Nelson"));
+ }
+
+ private void assertValidEmail(User user)
+ {
+ BeanPropertyBindingResult errors = new BeanPropertyBindingResult(user, "user");
+ new UserValidator().validate(user, errors);
+ assertThat(errors.getFieldErrors("email").size(), is(0));
+ }
+
+ private void assertInvalidEmail(User user)
+ {
+ BeanPropertyBindingResult errors = new BeanPropertyBindingResult(user, "user");
+ new UserValidator().validate(user, errors);
+ assertThat(errors.getErrorCount(), is(greaterThan(0)));
+ assertThat(errors.getFieldError("email").getCode(), is("email.invalid"));
+ }
+}
Please sign in to comment.
Something went wrong with that request. Please try again.