This commit is contained in:
Dan Elbert 2016-01-14 15:22:15 -06:00
parent 8743b32a49
commit 3b4134f684
26 changed files with 2750 additions and 29 deletions

View File

@ -17,6 +17,7 @@ gem 'bootstrap-sass', '~> 3.3.6'
gem 'turbolinks'
gem 'jbuilder', '~> 2.0'
gem 'cocoon', '~> 1.2.6'
gem 'unitwise', '~> 2.0.0'
# Use ActiveModel has_secure_password
# gem 'bcrypt', '~> 3.1.7'

View File

@ -42,6 +42,7 @@ GEM
json
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
blankslate (3.1.3)
bootstrap-sass (3.3.6)
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
@ -78,16 +79,21 @@ GEM
thor (>= 0.14, < 2.0)
json (1.8.3)
libv8 (3.16.14.13)
liner (0.2.4)
loofah (2.0.3)
nokogiri (>= 1.5.9)
mail (2.6.3)
mime-types (>= 1.16, < 3)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
mime-types (2.99)
mini_portile2 (2.0.0)
minitest (5.8.3)
multi_json (1.11.2)
nokogiri (1.6.7.1)
mini_portile2 (~> 2.0.0.rc2)
parslet (1.7.1)
blankslate (>= 2.0, <= 4.0)
rack (1.6.4)
rack-test (0.6.3)
rack (>= 1.0)
@ -141,6 +147,7 @@ GEM
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
signed_multiset (0.2.1)
sprockets (3.5.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
@ -162,6 +169,11 @@ GEM
uglifier (2.7.2)
execjs (>= 0.3.0)
json (>= 1.8.0)
unitwise (2.0.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 1.5)
signed_multiset (~> 0.2)
web-console (2.2.1)
activemodel (>= 4.0)
binding_of_caller (>= 0.7.2)
@ -186,4 +198,8 @@ DEPENDENCIES
therubyracer
turbolinks
uglifier (>= 1.3.0)
unitwise (~> 2.0.0)
web-console (~> 2.0)
BUNDLED WITH
1.10.6

View File

@ -15,4 +15,5 @@
//= require turbolinks
//= require bootstrap-sprockets
//= require cocoon
//= require typeahead
//= require_tree .

View File

@ -7,30 +7,71 @@
})
}
function initializeIngredientEditor($container, ingredientSearchEngine) {
$container.find(".ingredient-typeahead").typeahead({
},
{
name: 'ingredients',
source: ingredientSearchEngine,
display: function(datum) {
return datum.name;
}
});
}
$(document).on("ready page:load", function() {
var ingredientSearchEngine = new Bloodhound({
datumTokenizer: function(datum) {
return Bloodhound.tokenizers.whitespace(datum.name);
},
queryTokenizer: Bloodhound.tokenizers.whitespace,
identify: function(datum) { return datum.id; },
sorter: function(a, b) {
if (a.name < b.name) {
return -1;
} else if (b.name < a.name) {
return 1;
} else {
return 0;
}
},
prefetch: {
url: '/ingredients/prefetch.json',
cache: false
},
remote: {
url: '/ingredients/search.json?query=%QUERY',
wildcard: '%QUERY'
}
});
$("#step-list")
.on("cocoon:after-insert", function(e, item) {
reorder($(this));
})
.on("cocoon:after-remove", function(e, item) {
reorder($(this));
})
.on('changed', 'input.sort-order', function() {
var $this = $(this);
var $span = $this.closest(".nested-fields").find(".sort-order-display");
$span.html($this.val());
});
var $ingredientList = $("#ingredient-list");
$("#ingredient-list")
initializeIngredientEditor($ingredientList, ingredientSearchEngine);
$ingredientList
.on("cocoon:after-insert", function(e, item) {
reorder($(this));
initializeIngredientEditor(item, ingredientSearchEngine);
})
.on("cocoon:after-remove", function(e, item) {
reorder($(this));
});
$("#step-list").on('changed', 'input.sort-order', function() {
var $this = $(this);
var $span = $this.closest(".nested-fields").find(".sort-order-display");
$span.html($this.val());
});
});

View File

@ -18,6 +18,7 @@
@import "bootstrap";
@import "spacelab/_bootswatch";
@import "typeahead-bootstrap";
@import "recipes";
$footer_height: 40px;

View File

@ -0,0 +1,64 @@
span.twitter-typeahead .tt-menu,
span.twitter-typeahead .tt-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
text-align: left;
background-color: #ffffff;
border: 1px solid #cccccc;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
background-clip: padding-box;
}
span.twitter-typeahead .tt-hint {
color: #A5A5A5;
}
span.twitter-typeahead .tt-suggestion {
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.42857143;
color: #333333;
white-space: nowrap;
}
span.twitter-typeahead .tt-suggestion.tt-cursor,
span.twitter-typeahead .tt-suggestion:hover,
span.twitter-typeahead .tt-suggestion:focus {
color: #ffffff;
text-decoration: none;
outline: 0;
background-color: #337ab7;
}
span.twitter-typeahead {
width: 100%;
}
.input-group span.twitter-typeahead {
display: block !important;
}
.input-group span.twitter-typeahead .tt-menu,
.input-group span.twitter-typeahead .tt-dropdown-menu {
top: 32px !important;
}
.input-group.input-group-sm span.twitter-typeahead .tt-menu,
.input-group.input-group-sm span.twitter-typeahead .tt-dropdown-menu {
top: 28px !important;
}
.input-group.input-group-lg span.twitter-typeahead .tt-menu,
.input-group.input-group-lg span.twitter-typeahead .tt-dropdown-menu {
top: 44px !important;
}

View File

@ -4,7 +4,7 @@ class IngredientsController < ApplicationController
# GET /ingredients
# GET /ingredients.json
def index
@ingredients = Ingredient.all
@ingredients = Ingredient.all.order(:name)
end
# GET /ingredients/1
@ -28,7 +28,7 @@ class IngredientsController < ApplicationController
respond_to do |format|
if @ingredient.save
format.html { redirect_to @ingredient, notice: 'Ingredient was successfully created.' }
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully created.' }
format.json { render :show, status: :created, location: @ingredient }
else
format.html { render :new }
@ -61,6 +61,16 @@ class IngredientsController < ApplicationController
end
end
def prefetch
@ingredients = Ingredient.all.order(:name)
render :search
end
def search
query = params[:query] + '%'
@ingredients = Ingredient.where("name LIKE ?", query).order(:name)
end
private
# Use callbacks to share common setup or constraints between actions.
def set_ingredient

View File

@ -0,0 +1,18 @@
class DensityValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
valid = false
msg = 'is not a unit of density'
begin
unit = UnitConversion::parse(value)
valid = UnitConversion::density? unit
rescue UnitConversion::UnparseableUnitError => e
valid = false
msg = e.message
end
unless valid
record.errors[attribute] << (options[:message] || msg)
end
end
end

View File

@ -1,5 +1,6 @@
class Ingredient < ActiveRecord::Base
validates :name, presence: true
validates :density, density: true, allow_blank: true
end

View File

@ -4,5 +4,6 @@ class RecipeIngredient < ActiveRecord::Base
belongs_to :recipe, inverse_of: :recipe_ingredients
validates :sort_order, presence: true
validates :custom_density, density: true, allow_blank: true
end

View File

@ -0,0 +1,32 @@
module UnitConversion
UNIT_PARSING_REGEX = /^(?<value>(?:-?[0-9]+)?(?:\.?[0-9]*)?)\s*(?<unit>[a-z\/.\-()0-9]+)$/
class UnparseableUnitError < StandardError
end
class UnknownUnitError < UnparseableUnitError
end
class << self
def parse(unit_string)
match = UNIT_PARSING_REGEX.match(unit_string.to_s.strip)
if match && match[:value].present? && match[:unit].present?
begin
Unitwise(match[:value].to_f, match[:unit])
rescue Unitwise::ExpressionError => err
raise UnknownUnitError, err.message
end
else
raise UnparseableUnitError, "'#{unit_string}' does not appear to be a valid measurement of the form <value> <units> (ie '5 cup' or '223 gram/cup')"
end
end
def density?(unit)
unit.compatible_with? Unitwise(1, 'g/ml')
end
end
end

View File

@ -6,7 +6,7 @@
<div class="form-group">
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.text_field :name, class: 'form-control', autofocus: true %>
</div>
<div class="form-group">

View File

@ -1,6 +1,8 @@
<div class="row">
<div class="col-xs-12">
<h1>Listing Ingredients</h1>
<div class="page-header">
<h1>Listing Ingredients</h1>
</div>
<table class="table">
<thead>

View File

@ -0,0 +1,6 @@
json.array! @ingredients do |i|
json.extract! i, :id, :name, :density, :notes
end

View File

@ -6,11 +6,11 @@
<div class="form-group form-group-sm">
<%= f.label :custom_name, "Name" %>
<%= f.text_field :custom_name, class: 'form-control' %>
<%= f.text_field :custom_name, class: 'form-control ingredient-typeahead' %>
</div>
<div class="row">
<div class="col-xs-8">
<div class="col-xs-4">
<div class="form-group form-group-sm">
<%= f.label :quantity %>
<%= f.text_field :quantity, class: 'form-control' %>
@ -23,6 +23,13 @@
<%= f.text_field :units, class: 'form-control' %>
</div>
</div>
<div class="col-xs-4">
<div class="form-group form-group-sm">
<%= f.label :custom_density, "Density" %>
<%= f.text_field :custom_density, class: 'form-control' %>
</div>
</div>
</div>
</div>

View File

@ -8,7 +8,6 @@
<div class="col-xs-8">
<div class="form-group form-group-sm">
<%= f.label :step %>
<%= f.text_area :step, class: 'form-control' %>
</div>
</div>

View File

@ -1,13 +1,15 @@
<div class="row">
<div class="col-xs-12">
<h1>Listing Recipes</h1>
<div class="page-header">
<h1>Listing Recipes</h1>
</div>
<% if @recipes.empty? %>
<p>No Recipes</p>
<% else %>
<table>
<table class="table">
<thead>
<tr>
<th>Name</th>

View File

@ -1,7 +1,14 @@
Rails.application.routes.draw do
resources :recipes
resources :ingredients
resources :ingredients do
collection do
constraints format: 'json' do
get :search
get :prefetch
end
end
end
root 'recipes#index'

View File

@ -5,3 +5,25 @@
#
# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }])
# Mayor.create(name: 'Emanuel', city: cities.first)
puts "Seeding..."
Ingredient.create!([
{name: 'Butter, Salted', density: '226 gram/cup'},
{name: 'Butter, Unsalted', density: '226 gram/cup'},
{name: 'Flour, Bleached All Purpose', density: '130 gram/cup'},
{name: 'Flour, Cake', density: '120 gram/cup'},
{name: 'Flour, Whole Wheat', density: '130 gram/cup'},
{name: 'Cornstarch', density: '10 gram/tablespoon'},
{name: 'Cornmeal', density: '120 gram/cup'},
{name: 'Sugar, Granulated', density: '200 gram/cup'},
{name: 'Sugar, Brown, Lightly Packed', density: '210 gram/cup'},
{name: 'Sugar, Powdered', density: '120 gram/cup'},
{name: 'Chocolate Chips', density: '170 gram/cup'},
{name: 'Cocoa Powder', density: '100 gram/cup'}
])
puts "Seeds planted."

View File

@ -1,6 +1,8 @@
FactoryGirl.define do
factory :ingredient do
name 'Ingredient'
density nil
notes 'note note note'
end
end

View File

@ -1,6 +1,6 @@
FactoryGirl.define do
factory :recipe_ingredient do
sort_order 1
end
end

View File

@ -1,7 +1,7 @@
FactoryGirl.define do
factory :recipe_step do
number 1
step "MyText"
sort_order 1
step "MyText"
end
end

View File

@ -1,6 +1,11 @@
FactoryGirl.define do
factory :recipe do
name "Recipe"
description "desc"
source "source"
yields 4
total_time 20
active_time 10
end
end

View File

@ -1,5 +1,26 @@
require 'rails_helper'
RSpec.describe Ingredient, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
describe 'validation' do
it 'validates density' do
i = build(:ingredient)
expect(i).to be_valid
i.density = '5'
expect(i).not_to be_valid
i.density = '5 cup'
expect(i).not_to be_valid
i.density = '5 gram/cup'
expect(i).to be_valid
i.density = '5 mile/hour'
expect(i).not_to be_valid
end
end
end

View File

@ -27,13 +27,24 @@ require 'rspec/rails'
ActiveRecord::Migration.maintain_test_schema!
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_path = "#{::Rails.root}/spec/fixtures"
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
config.include FactoryGirl::Syntax::Methods
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
DatabaseCleaner.cleaning do
FactoryGirl.lint
end
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
# RSpec Rails can automatically mix in different behaviours to your tests
# based on their file location, for example enabling you to call `get` and

2451
vendor/assets/javascripts/typeahead.js vendored Normal file

File diff suppressed because it is too large Load Diff