Ingredient editor
This commit is contained in:
parent
7f7a81d49a
commit
70e7a8b415
@ -1,9 +1,63 @@
|
||||
(function($) {
|
||||
|
||||
function initializeEditor($ingredientForm) {
|
||||
usdaFoodSearchEngine.initialize(false);
|
||||
|
||||
var $typeahead = $ingredientForm.find(".ndbn_typeahead");
|
||||
var $usdaModal = $("#link_ndbn_modal");
|
||||
var $name = $ingredientForm.find(".name");
|
||||
var $ndbn = $ingredientForm.find("input.ndbn");
|
||||
var $ndbn_group = $ingredientForm.find(".ndbn_group");
|
||||
|
||||
if ($ndbn.val()) {
|
||||
}
|
||||
|
||||
$typeahead.typeahead_search({
|
||||
searchUrl: '/ingredients/usda_food_search.html',
|
||||
resultsContainer: '#link_ndbn_modal .results'
|
||||
},{
|
||||
name: 'usdaFoods',
|
||||
source: usdaFoodSearchEngine,
|
||||
display: function(datum) {
|
||||
return datum.name;
|
||||
}
|
||||
});
|
||||
|
||||
$typeahead.on("typeahead_search:selected", function(evt, item) {
|
||||
selectNdbn(item.ndbn);
|
||||
});
|
||||
|
||||
$usdaModal.on("shown.bs.modal", function() {
|
||||
var $this = $(this);
|
||||
$typeahead.typeahead("val", $name.val());
|
||||
$typeahead.focus();
|
||||
$typeahead.select();
|
||||
});
|
||||
|
||||
$ingredientForm.on("click", "#link_ndbn_modal .results .food_result", function(evt) {
|
||||
var $item = $(evt.target);
|
||||
var ndbn = $item.data("ndbn");
|
||||
selectNdbn(ndbn);
|
||||
});
|
||||
}
|
||||
|
||||
function selectNdbn(ndbn) {
|
||||
var $ingredientForm = $("#ingredient_form");
|
||||
var id = $ingredientForm.find("input.id").val();
|
||||
|
||||
$ingredientForm.find("input.ndbn").val(ndbn);
|
||||
$ingredientForm.attr("action", "/ingredients/" + id + "/select_ndbn").attr("data-remote", "true");
|
||||
|
||||
$("#link_ndbn_modal").modal('hide').on('hidden.bs.modal', function() {
|
||||
$ingredientForm.submit();
|
||||
});
|
||||
}
|
||||
|
||||
var usdaFoodSearchEngine = new Bloodhound({
|
||||
initialize: false,
|
||||
datumTokenizer: function(datum) {
|
||||
return Bloodhound.tokenizers.whitespace(datum.name);
|
||||
var str = datum ? datum.name : null;
|
||||
return str ? str.split(/[\s,]+/) : [];
|
||||
},
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
||||
identify: function(datum) { return datum.ndbn; },
|
||||
@ -26,18 +80,8 @@
|
||||
var $ingredientForm = $("#ingredient_form");
|
||||
|
||||
if ($ingredientForm.length) {
|
||||
usdaFoodSearchEngine.initialize(false);
|
||||
initializeEditor($ingredientForm);
|
||||
}
|
||||
|
||||
$ingredientForm.find(".ndbn_typeahead").typeahead_selector({
|
||||
|
||||
},{
|
||||
name: 'usdaFoods',
|
||||
source: usdaFoodSearchEngine,
|
||||
display: function(datum) {
|
||||
return datum.name;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})(jQuery);
|
107
app/assets/javascripts/typeahead_search.js
Normal file
107
app/assets/javascripts/typeahead_search.js
Normal file
@ -0,0 +1,107 @@
|
||||
(function($) {
|
||||
|
||||
var pluginName = "typeahead_search";
|
||||
|
||||
var defaultOptions = {
|
||||
};
|
||||
|
||||
var methods = {
|
||||
initialize: function (opts, sources) {
|
||||
|
||||
return this.each(function() {
|
||||
var options = $.extend({}, defaultOptions, opts);
|
||||
var $this = $(this);
|
||||
$this.data(pluginName, {options: options});
|
||||
|
||||
var $inputGroup = $('<div class="input-group" />');
|
||||
var $btnSpan = $("<span class='input-group-btn' />");
|
||||
var $btn = $("<button class='btn btn-default' type='button' />").append($("<span />").addClass("glyphicon glyphicon-search"));
|
||||
|
||||
$btnSpan.append($btn);
|
||||
|
||||
$this.after($inputGroup);
|
||||
$this.detach();
|
||||
$inputGroup.append($this);
|
||||
$inputGroup.append($btnSpan);
|
||||
|
||||
$this.typeahead(opts, sources);
|
||||
|
||||
$btn.on("click", function(evt) {
|
||||
privateMethods.search($this);
|
||||
});
|
||||
|
||||
$this
|
||||
.on("typeahead:change", function(evt, value) {
|
||||
privateMethods.change($this, value);
|
||||
})
|
||||
.on("typeahead:select", function(evt, value) {
|
||||
privateMethods.select($this, value);
|
||||
})
|
||||
.on("typeahead:autocomplete", function(evt, value) {
|
||||
privateMethods.autocomplete($this, value);
|
||||
})
|
||||
.on("keydown", function(evt) {
|
||||
if (evt.keyCode == 13) {
|
||||
evt.preventDefault();
|
||||
privateMethods.search($this);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
val: function() {
|
||||
if (this.length) {
|
||||
return $(this[0]).typeahead("val");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var privateMethods = {
|
||||
change: function($this, value) {
|
||||
|
||||
},
|
||||
|
||||
select: function($this, item) {
|
||||
$this.trigger("typeahead_search:selected", item);
|
||||
},
|
||||
|
||||
autocomplete: function($this, item) {
|
||||
$this.trigger("typeahead_search:selected", item);
|
||||
},
|
||||
|
||||
search: function($this) {
|
||||
var options = privateMethods.options($this);
|
||||
var input = $this.typeahead("val");
|
||||
|
||||
if (input.length && options.searchUrl && options.searchUrl.length) {
|
||||
$.get({
|
||||
url: options.searchUrl,
|
||||
data: {query: input},
|
||||
dataType: 'html',
|
||||
success: function(data) {
|
||||
$(options.resultsContainer).empty().append(data);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$this.typeahead("close");
|
||||
},
|
||||
|
||||
options: function($this) {
|
||||
return $this.data(pluginName).options;
|
||||
}
|
||||
};
|
||||
|
||||
$.fn[pluginName] = function (method) {
|
||||
if (methods[method]) {
|
||||
return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
|
||||
} else if (typeof method === 'object' || ! method) {
|
||||
return methods.initialize.apply(this, arguments);
|
||||
} else {
|
||||
$.error('Method ' + method + ' does not exist on jQuery.' + pluginName);
|
||||
}
|
||||
};
|
||||
|
||||
})(jQuery);
|
@ -1,6 +1,6 @@
|
||||
class IngredientsController < ApplicationController
|
||||
|
||||
before_action :set_ingredient, only: [:edit, :update, :destroy]
|
||||
before_action :set_ingredient, only: [:edit, :update, :destroy, :select_ndbn]
|
||||
|
||||
before_filter :ensure_valid_user, only: [:new, :edit, :create, :update, :destroy]
|
||||
|
||||
@ -36,11 +36,15 @@ class IngredientsController < ApplicationController
|
||||
end
|
||||
|
||||
# PATCH/PUT /ingredients/1
|
||||
# PATCH/PUT /ingredients/1.json
|
||||
def update
|
||||
@ingredient.assign_attributes(ingredient_params)
|
||||
if @ingredient.ndbn.present?
|
||||
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
if @ingredient.update(ingredient_params)
|
||||
format.html { redirect_to @ingredient, notice: 'Ingredient was successfully updated.' }
|
||||
if @ingredient.save
|
||||
format.html { redirect_to ingredients_path, notice: 'Ingredient was successfully updated.' }
|
||||
format.json { render :show, status: :ok, location: @ingredient }
|
||||
else
|
||||
format.html { render :edit }
|
||||
@ -59,14 +63,24 @@ class IngredientsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def select_ndbn
|
||||
@ingredient.assign_attributes(ingredient_params)
|
||||
if @ingredient.ndbn.present?
|
||||
@ingredient.set_usda_food(UsdaFood.find_by_ndbn(@ingredient.ndbn))
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.js {}
|
||||
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)
|
||||
@ingredients = Ingredient.search(params[:query]).order(:name)
|
||||
end
|
||||
|
||||
def convert
|
||||
@ -81,7 +95,12 @@ class IngredientsController < ApplicationController
|
||||
end
|
||||
|
||||
def usda_food_search
|
||||
@foods = UsdaFood.where("short_description LIKE ?", "%#{params[:query]}%").limit(50)
|
||||
@foods = UsdaFood.search(params[:query]).limit(50)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render :layout => false }
|
||||
format.json { }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@ -92,7 +111,7 @@ class IngredientsController < ApplicationController
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def ingredient_params
|
||||
params.require(:ingredient).permit(:name, :density, :notes)
|
||||
params.require(:ingredient).permit(:name, :notes, :ndbn, :density, :water, :protein, :lipids, :kcal, :fiber, :sugar)
|
||||
end
|
||||
|
||||
def conversion_params
|
||||
|
26
app/models/concerns/tokenized_like.rb
Normal file
26
app/models/concerns/tokenized_like.rb
Normal file
@ -0,0 +1,26 @@
|
||||
module TokenizedLike
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
module ClassMethods
|
||||
|
||||
def matches_tokens(attribute, tokens)
|
||||
table = self.arel_table
|
||||
query = self.all
|
||||
|
||||
tokens.each do |t|
|
||||
match1 = "#{t}%"
|
||||
match2 = "% #{t}%"
|
||||
match3 = "%,#{t}%"
|
||||
|
||||
matcher = ->(m) { table[attribute.to_sym].matches(m) }
|
||||
|
||||
cond = matcher.call(match1).or(matcher.call(match2)).or(matcher.call(match3))
|
||||
|
||||
query = query.where(cond)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
end
|
||||
end
|
@ -1,9 +1,22 @@
|
||||
class Ingredient < ActiveRecord::Base
|
||||
include TokenizedLike
|
||||
|
||||
validates :name, presence: true
|
||||
validates :density, density: true, allow_blank: true
|
||||
|
||||
def self.search(query)
|
||||
tokens = query.to_s.split(' ')
|
||||
|
||||
if tokens.empty?
|
||||
Ingredient.none
|
||||
else
|
||||
Ingredient.matches_tokens(:name, tokens)
|
||||
end
|
||||
end
|
||||
|
||||
def set_usda_food(food)
|
||||
return unless food
|
||||
|
||||
self.ndbn = food.ndbn
|
||||
self.water = food.water
|
||||
self.protein = food.protein
|
||||
|
@ -1,3 +1,14 @@
|
||||
class UsdaFood < ActiveRecord::Base
|
||||
include TokenizedLike
|
||||
|
||||
def self.search(query)
|
||||
tokens = query.to_s.split(' ')
|
||||
|
||||
if tokens.empty?
|
||||
UsdaFood.none
|
||||
else
|
||||
UsdaFood.matches_tokens(:short_description, tokens)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
@ -1,19 +1,38 @@
|
||||
|
||||
<% has_ndbn = @ingredient.ndbn.present? %>
|
||||
|
||||
<%= form_for(@ingredient, html: {id: 'ingredient_form'}) do |f| %>
|
||||
|
||||
<%= render partial: 'shared/error_list', locals: {model: @ingredient} %>
|
||||
|
||||
<%= f.hidden_field :ndbn, class: 'ndbn' %>
|
||||
<%= f.hidden_field :id, class: 'id', disabled: true %>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :name, class: 'control-label' %>
|
||||
<%= f.text_field :name, class: 'form-control', autofocus: true %>
|
||||
<%= f.text_field :name, class: 'form-control name', autofocus: true %>
|
||||
</div>
|
||||
|
||||
<div class="form-group ndbn_group">
|
||||
<%= f.label :ndbn, "Nutrient Databank Number", class: 'control-label' %>
|
||||
<div class="input-group">
|
||||
<div class="input-group-btn">
|
||||
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#link_ndbn_modal">
|
||||
<span class="glyphicon glyphicon-link"></span><span class="ndbn"><%= @ingredient.ndbn %></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-control-static" style="padding-left: 7px;"><%= @ingredient.ndbn ? UsdaFood.find_by_ndbn(@ingredient.ndbn).short_description : '' %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :ndbn, "Nutrient Databank Number", class: 'control-label' %>
|
||||
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#link_ndbn_modal">
|
||||
<span class="glyphicon glyphicon-link"></span><span class="ndbn"><%= @ingredient.ndbn %></span>
|
||||
</button>
|
||||
<%= f.label :density, class: 'control-label' %>
|
||||
<%= f.text_field :density, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :notes, class: 'control-label' %>
|
||||
<%= f.text_area :notes, class: 'form-control' %>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
@ -21,45 +40,35 @@
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :water, "Grams of Water", class: 'control-label' %>
|
||||
<%= f.text_field :water, class: 'form-control' %>
|
||||
<%= f.text_field :water, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :protein, "Grams of Protein", class: 'control-label' %>
|
||||
<%= f.text_field :protein, class: 'form-control' %>
|
||||
<%= f.text_field :protein, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :lipids, "Grams of Fat", class: 'control-label' %>
|
||||
<%= f.text_field :lipids, class: 'form-control' %>
|
||||
<%= f.text_field :lipids, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :kcal, "Calories", class: 'control-label' %>
|
||||
<%= f.text_field :kcal, class: 'form-control' %>
|
||||
<%= f.text_field :kcal, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :fiber, "Grams of Fiber", class: 'control-label' %>
|
||||
<%= f.text_field :fiber, class: 'form-control' %>
|
||||
<%= f.text_field :fiber, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :sugar, "Grams of Sugar", class: 'control-label' %>
|
||||
<%= f.text_field :sugar, class: 'form-control' %>
|
||||
<%= f.text_field :sugar, class: 'form-control', disabled: has_ndbn %>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :density, class: 'control-label' %>
|
||||
<%= f.text_field :density, class: 'form-control' %>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<%= f.label :notes, class: 'control-label' %>
|
||||
<%= f.text_area :notes, class: 'form-control' %>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<%= f.submit class: 'btn btn-primary' %>
|
||||
</div>
|
||||
@ -75,10 +84,13 @@
|
||||
<div class="form-group">
|
||||
<%= f.label :ndbn, "", class: 'control-label' %>
|
||||
<input type="text" class="ndbn_typeahead form-control" />
|
||||
<%= f.hidden_field :ndbn %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
|
2
app/views/ingredients/select_ndbn.js.erb
Normal file
2
app/views/ingredients/select_ndbn.js.erb
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$("#ingredient_form").replaceWith($("<%= escape_javascript(render(partial: 'ingredients/form')) %>"));
|
20
app/views/ingredients/usda_food_search.html.erb
Normal file
20
app/views/ingredients/usda_food_search.html.erb
Normal file
@ -0,0 +1,20 @@
|
||||
|
||||
<table class="table">
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NDBN</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<% @foods.each do |f| %>
|
||||
|
||||
<tr>
|
||||
<th><%= f.ndbn %></th>
|
||||
<th><%= link_to f.short_description, '#', class: 'food_result', data: {ndbn: f.ndbn} %></th>
|
||||
</tr>
|
||||
|
||||
<% end %>
|
||||
|
||||
</table>
|
@ -8,13 +8,18 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :ingredients, except: [:show] do
|
||||
collection do
|
||||
get :usda_food_search
|
||||
|
||||
constraints format: 'json' do
|
||||
get :search
|
||||
get :prefetch
|
||||
get :convert
|
||||
get :usda_food_search
|
||||
end
|
||||
end
|
||||
|
||||
member do
|
||||
match :select_ndbn, via: [:post, :patch, :put]
|
||||
end
|
||||
end
|
||||
|
||||
resource :user, only: [:new, :create, :edit, :update]
|
||||
|
@ -2,9 +2,9 @@ class CreateUsdaFood < ActiveRecord::Migration
|
||||
def change
|
||||
create_table :usda_foods do |t|
|
||||
|
||||
t.string :ndbn, limit: 5, index: true, null: false
|
||||
t.string :ndbn, limit: 5, index: :unique, null: false
|
||||
|
||||
t.string :short_description
|
||||
t.string :short_description, index: true
|
||||
t.decimal :water, precision: 10, scale: 2
|
||||
t.integer :kcal
|
||||
t.decimal :protein, precision: 10, scale: 2
|
||||
|
@ -88,6 +88,7 @@ ActiveRecord::Schema.define(version: 20160124231837) do
|
||||
end
|
||||
|
||||
add_index "usda_foods", ["ndbn"], name: "index_usda_foods_on_ndbn"
|
||||
add_index "usda_foods", ["short_description"], name: "index_usda_foods_on_short_description"
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "username"
|
||||
|
@ -31,6 +31,8 @@ Ingredient.create!([
|
||||
{name: 'Milk, 2%', density: '1.03 gram/ml'}
|
||||
])
|
||||
|
||||
User.create!({username: 'dan', full_name: 'Dan', email: 'dan.elbert@gmail.com', password: 'qwerty', password_confirmation: 'qwerty'})
|
||||
|
||||
importer = UsdaImporter.new(Rails.root.join('vendor', 'data', 'usda', 'ABBREV.txt'))
|
||||
importer.import
|
||||
|
||||
|
@ -0,0 +1,8 @@
|
||||
namespace :usda do
|
||||
|
||||
desc 'Empties usda_foods table, imports all data, and then updates any linked ingredients'
|
||||
task import: :environment do
|
||||
importer = UsdaImporter.new(Rails.root.join('vendor', 'data', 'usda', 'ABBREV.txt'))
|
||||
importer.import
|
||||
end
|
||||
end
|
25
spec/factories/usda_foods.rb
Normal file
25
spec/factories/usda_foods.rb
Normal file
@ -0,0 +1,25 @@
|
||||
FactoryGirl.define do
|
||||
|
||||
sequence :unique_ndbn do |n|
|
||||
n.to_s.rjust(5, '0')
|
||||
end
|
||||
|
||||
factory :usda_food do
|
||||
short_description 'Food'
|
||||
ndbn '01234'
|
||||
water 1.0
|
||||
kcal 101
|
||||
protein 1.2
|
||||
lipid 3.4
|
||||
ash 0
|
||||
carbohydrates 3.3
|
||||
fiber 1.2
|
||||
sugar 10
|
||||
gram_weight_1 200
|
||||
gram_weight_2 100
|
||||
gram_weight_desc_1 '1 cup'
|
||||
gram_weight_desc_2 '1/2 cup'
|
||||
refuse_percent 3
|
||||
end
|
||||
|
||||
end
|
41
spec/models/usda_food_spec.rb
Normal file
41
spec/models/usda_food_spec.rb
Normal file
@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UsdaFood do
|
||||
|
||||
let!(:data) do
|
||||
{
|
||||
salted_butter: create(:usda_food, short_description: 'Salted Butter'),
|
||||
unsalted_butter: create(:usda_food, short_description: 'Unsalted Butter'),
|
||||
flour: create(:usda_food, short_description: 'Flour'),
|
||||
bread_flour: create(:usda_food, short_description: 'Bread Flour'),
|
||||
sugar: create(:usda_food, short_description: 'Sugar,Granulated')
|
||||
}
|
||||
end
|
||||
|
||||
def items(*syms)
|
||||
Array.wrap(syms).map { |s| data[s] }
|
||||
end
|
||||
|
||||
it 'can be found by single tokens' do
|
||||
r = UsdaFood.matches_tokens(:short_description, ['sal'])
|
||||
expect(r.length).to eq 1
|
||||
expect(r).to contain_exactly *items(:salted_butter)
|
||||
|
||||
r = UsdaFood.matches_tokens(:short_description, ['flour'])
|
||||
expect(r.length).to eq 2
|
||||
expect(r).to contain_exactly *items(:flour, :bread_flour)
|
||||
end
|
||||
|
||||
it 'can be found by multiple tokens' do
|
||||
r = UsdaFood.matches_tokens(:short_description, ['sal', 'butter'])
|
||||
expect(r.length).to eq 1
|
||||
expect(r).to contain_exactly *items(:salted_butter)
|
||||
end
|
||||
|
||||
it 'treats commas like spaces' do
|
||||
r = UsdaFood.matches_tokens(:short_description, ['gran', 'sugar'])
|
||||
expect(r.length).to eq 1
|
||||
expect(r).to contain_exactly *items(:sugar)
|
||||
end
|
||||
|
||||
end
|
Loading…
Reference in New Issue
Block a user