Ingredient editor

This commit is contained in:
Dan Elbert 2016-01-28 14:19:51 -06:00
parent 7f7a81d49a
commit 70e7a8b415
16 changed files with 382 additions and 46 deletions

View File

@ -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);

View 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);

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,2 @@
$("#ingredient_form").replaceWith($("<%= escape_javascript(render(partial: 'ingredients/form')) %>"));

View 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>

View File

@ -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]

View File

@ -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

View File

@ -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"

View File

@ -31,8 +31,10 @@ 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
puts "Seeds planted."
puts "Seeds planted."

View File

@ -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

View 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

View 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