units
This commit is contained in:
parent
8743b32a49
commit
3b4134f684
1
Gemfile
1
Gemfile
@ -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'
|
||||
|
16
Gemfile.lock
16
Gemfile.lock
@ -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
|
||||
|
@ -15,4 +15,5 @@
|
||||
//= require turbolinks
|
||||
//= require bootstrap-sprockets
|
||||
//= require cocoon
|
||||
//= require typeahead
|
||||
//= require_tree .
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
@import "bootstrap";
|
||||
@import "spacelab/_bootswatch";
|
||||
|
||||
@import "typeahead-bootstrap";
|
||||
@import "recipes";
|
||||
|
||||
$footer_height: 40px;
|
||||
|
64
app/assets/stylesheets/typeahead-bootstrap.scss
Normal file
64
app/assets/stylesheets/typeahead-bootstrap.scss
Normal 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;
|
||||
}
|
@ -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
|
||||
|
18
app/models/concerns/density_validator.rb
Normal file
18
app/models/concerns/density_validator.rb
Normal 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
|
@ -1,5 +1,6 @@
|
||||
class Ingredient < ActiveRecord::Base
|
||||
|
||||
validates :name, presence: true
|
||||
validates :density, density: true, allow_blank: true
|
||||
|
||||
end
|
||||
|
@ -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
|
||||
|
32
app/models/unit_conversion.rb
Normal file
32
app/models/unit_conversion.rb
Normal 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
|
@ -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">
|
||||
|
@ -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>
|
||||
|
6
app/views/ingredients/search.json.jbuilder
Normal file
6
app/views/ingredients/search.json.jbuilder
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
json.array! @ingredients do |i|
|
||||
|
||||
json.extract! i, :id, :name, :density, :notes
|
||||
|
||||
end
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
||||
|
22
db/seeds.rb
22
db/seeds.rb
@ -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."
|
@ -1,6 +1,8 @@
|
||||
FactoryGirl.define do
|
||||
factory :ingredient do
|
||||
|
||||
name 'Ingredient'
|
||||
density nil
|
||||
notes 'note note note'
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -1,6 +1,6 @@
|
||||
FactoryGirl.define do
|
||||
factory :recipe_ingredient do
|
||||
|
||||
sort_order 1
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -1,7 +1,7 @@
|
||||
FactoryGirl.define do
|
||||
factory :recipe_step do
|
||||
number 1
|
||||
step "MyText"
|
||||
sort_order 1
|
||||
step "MyText"
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
2451
vendor/assets/javascripts/typeahead.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user