diff --git a/Gemfile b/Gemfile index fe0094f..4a47f3d 100644 --- a/Gemfile +++ b/Gemfile @@ -12,7 +12,7 @@ gem 'therubyracer', platforms: :ruby # Use jquery as the JavaScript library gem 'jquery-rails', '~> 4.1.1' gem 'bootstrap-sass', '~> 3.3.6' -# Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks +gem 'kaminari', '~> 0.17.0' gem 'turbolinks', '~> 5.0.0' gem 'jbuilder', '~> 2.5' gem 'cocoon', '~> 1.2.9' diff --git a/Gemfile.lock b/Gemfile.lock index ce8cd8c..a9d7abd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,7 @@ GEM minitest (~> 5.1) tzinfo (~> 1.1) arel (7.1.2) - autoprefixer-rails (6.4.1.1) + autoprefixer-rails (6.5.0) execjs bcrypt (3.1.11) blankslate (3.1.3) @@ -87,6 +87,9 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) + kaminari (0.17.0) + actionpack (>= 3.0.0) + activesupport (>= 3.0.0) libv8 (3.16.14.15) liner (0.2.4) listen (3.1.5) @@ -105,7 +108,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mini_portile2 (2.1.0) - minitest (5.9.0) + minitest (5.9.1) multi_json (1.12.1) nenv (0.3.0) nio4r (1.2.1) @@ -149,7 +152,7 @@ GEM method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rake (11.2.2) + rake (11.3.0) rb-fsevent (0.9.7) rb-inotify (0.9.7) ffi (>= 0.5.0) @@ -235,6 +238,7 @@ DEPENDENCIES guard-rspec jbuilder (~> 2.5) jquery-rails (~> 4.1.1) + kaminari (~> 0.17.0) pg (~> 0.18.4) rails (= 5.0.0) rspec-rails (~> 3.5.0) diff --git a/app/assets/javascripts/ingredients.js b/app/assets/javascripts/ingredients.js index d9165d5..d32c047 100644 --- a/app/assets/javascripts/ingredients.js +++ b/app/assets/javascripts/ingredients.js @@ -14,7 +14,7 @@ window.INGREDIENT_API = {}; },{ name: 'usdaFoods', source: usdaFoodSearchEngine, - limit: 10, + limit: 20, display: function(datum) { return datum.name; } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index caa005f..dfe4a43 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -72,4 +72,32 @@ body { height: $footer_height; background-color: $gray-lighter; border-top: solid 1px $gray-light; +} + +a.sorted { + position: relative; +} + +a.sorted.asc:after { + content: " "; + position: absolute; + margin: 8px 0 0 6px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + + border-top: 5px solid black; +} + +a.sorted.desc:after { + content: " "; + position: absolute; + margin: 8px 0 0 6px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + + border-bottom: 5px solid black; } \ No newline at end of file diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb index 117975b..62ceaff 100644 --- a/app/controllers/recipes_controller.rb +++ b/app/controllers/recipes_controller.rb @@ -6,7 +6,8 @@ class RecipesController < ApplicationController # GET /recipes def index - @recipes = Recipe.active + @criteria = ViewModels::RecipeCriteria.new(params[:criteria]) + @recipes = Recipe.for_criteria(@criteria) end # GET /recipes/1 diff --git a/app/decorators/recipe_decorator.rb b/app/decorators/recipe_decorator.rb index 4224500..6b38bba 100644 --- a/app/decorators/recipe_decorator.rb +++ b/app/decorators/recipe_decorator.rb @@ -25,7 +25,7 @@ class RecipeDecorator < BaseDecorator end def average_rating - @average_rating ||= (Log.for_recipe(wrapped).for_user(self.user).where('rating IS NOT NULL').average(:rating) || 0) + rating end end \ No newline at end of file diff --git a/app/helpers/recipes_helper.rb b/app/helpers/recipes_helper.rb index c9ec10e..a9417fc 100644 --- a/app/helpers/recipes_helper.rb +++ b/app/helpers/recipes_helper.rb @@ -32,4 +32,40 @@ module RecipesHelper ].compact.join("\n".html_safe).html_safe end end + + def index_sort_header(text, field, criteria) + uri = URI(request.original_fullpath) + query = Rack::Utils.parse_query(uri.query) + + directions = [:asc, :desc] + + current_field = criteria.sort_column + current_direction = criteria.sort_direction + field_param = 'criteria[sort_column]' + direction_param = 'criteria[sort_direction]' + + if request.get? + is_sorted = current_field == field.to_sym + + if is_sorted && directions.include?(current_direction) + direction = (directions.reject { |d| d == current_direction }).first + else + direction = directions.first + end + + if is_sorted && direction == :asc + link_class = 'sorted desc' + elsif is_sorted && direction == :desc + link_class = 'sorted asc' + else + link_class = 'sorted' + end + + query[field_param.to_s] = field.to_s + query[direction_param.to_s] = direction.to_s + link_to text, "#{uri.path}?#{query.to_query}", class: link_class + else + text + end + end end diff --git a/app/models/log.rb b/app/models/log.rb index 2649f47..938c291 100644 --- a/app/models/log.rb +++ b/app/models/log.rb @@ -8,9 +8,17 @@ class Log < ActiveRecord::Base validates :user_id, presence: true validates :rating, numericality: { only_integer: true, allow_blank: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5, message: 'must be an integer between 1 and 5, inclusive' } - scope :for_user, ->(user) { where(user: user) } - scope :for_recipe, ->(recipe) { where(source_recipe: recipe) } + scope :for_user, ->(user) { where(user_id: user) } + scope :for_recipe, ->(recipe) { where(source_recipe_id: recipe) } accepts_nested_attributes_for :recipe, update_only: true, allow_destroy: false + after_save :update_rating + + def update_rating + if self.source_recipe + self.source_recipe.update_rating! + end + end + end diff --git a/app/models/recipe.rb b/app/models/recipe.rb index ddabc59..2409903 100644 --- a/app/models/recipe.rb +++ b/app/models/recipe.rb @@ -7,6 +7,7 @@ class Recipe < ActiveRecord::Base scope :undeleted, -> { where('deleted <> ? OR deleted IS NULL', true) } scope :not_log, -> { where('is_log <> ? OR is_log IS NULL', true) } scope :active, -> { undeleted.not_log } + scope :for_criteria, ->(criteria) { active.order(criteria.sort_column => criteria.sort_direction).page(criteria.page).per(criteria.per) } accepts_nested_attributes_for :recipe_ingredients, allow_destroy: true accepts_nested_attributes_for :recipe_steps, allow_destroy: true @@ -59,6 +60,11 @@ class Recipe < ActiveRecord::Base @parsed_yield end + def update_rating! + self.rating = Log.for_recipe(self).for_user(self.user_id).where('rating IS NOT NULL').average(:rating) + save(validate: false) + end + # Creates a copy of this recipe suitable for associating to a log def log_copy(user) copy = Recipe.new diff --git a/app/models/view_models/recipe_criteria.rb b/app/models/view_models/recipe_criteria.rb new file mode 100644 index 0000000..76c98be --- /dev/null +++ b/app/models/view_models/recipe_criteria.rb @@ -0,0 +1,48 @@ +module ViewModels + class RecipeCriteria + + SORT_COLUMNS = :created_at, :name, :rating, :total_time + + attr_writer :sort_column, :sort_direction + attr_writer :page, :per + + def initialize(params = {}) + params ||= {} + ([:sort_column, :sort_direction, :page, :per]).each do |attr| + setter = "#{attr}=" + if params[attr] + self.send(setter, params[attr]) + end + end + end + + def sort_column + @sort_column ||= SORT_COLUMNS.first + @sort_column = @sort_column.to_sym + unless SORT_COLUMNS.include? @sort_column + @sort_column = SORT_COLUMNS.first + end + + @sort_column + end + + def sort_direction + @sort_direction ||= :asc + @sort_direction = @sort_direction.to_sym + unless [:asc, :desc].include? @sort_direction + @sort_direction = :asc + end + + @sort_direction + end + + def page + @page.to_i || 1 + end + + def per + @per.to_i || 50 + end + + end +end \ No newline at end of file diff --git a/app/views/recipes/index.html.erb b/app/views/recipes/index.html.erb index 5f4c6e8..75ffbc8 100644 --- a/app/views/recipes/index.html.erb +++ b/app/views/recipes/index.html.erb @@ -9,15 +9,17 @@

No Recipes

<% else %> + <%= paginate @recipes, :param_name => 'criteria[page]' %> +
- - + + - - + + <% if current_user? %> <% end %> @@ -57,6 +59,8 @@
NameRating<%= index_sort_header('Name', :name, @criteria) %><%= index_sort_header('Rating', :rating, @criteria) %> YieldsTimeCreated<%= index_sort_header('Time', :total_time, @criteria) %><%= index_sort_header('Created', :created_at, @criteria) %>
+ <%= paginate @recipes, :param_name => 'criteria[page]' %> + <% end %>
diff --git a/db/migrate/20160928212209_add_rating_to_recipe.rb b/db/migrate/20160928212209_add_rating_to_recipe.rb new file mode 100644 index 0000000..732593b --- /dev/null +++ b/db/migrate/20160928212209_add_rating_to_recipe.rb @@ -0,0 +1,19 @@ +class AddRatingToRecipe < ActiveRecord::Migration[5.0] + class Recipe < ActiveRecord::Base + end + + class Log < ActiveRecord::Base + end + + def change + add_column :recipes, :rating, :float + + Recipe.reset_column_information + Log.reset_column_information + + Recipe.all.each do |r| + r.rating = Log.where(user_id: r.user_id).where(source_recipe_id: r).where('rating IS NOT NULL').average(:rating) + r.save! + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 699d97b..2c274ce 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160812205919) do +ActiveRecord::Schema.define(version: 20160928212209) do create_table "ingredient_units", force: :cascade do |t| t.integer "ingredient_id", null: false @@ -99,6 +99,7 @@ ActiveRecord::Schema.define(version: 20160812205919) do t.boolean "deleted" t.integer "user_id" t.boolean "is_log" + t.float "rating" end create_table "usda_food_weights", force: :cascade do |t| diff --git a/spec/factories/recipes.rb b/spec/factories/recipes.rb index b7a20da..8334cfa 100644 --- a/spec/factories/recipes.rb +++ b/spec/factories/recipes.rb @@ -6,6 +6,7 @@ FactoryGirl.define do yields 4 total_time 20 active_time 10 + user end end diff --git a/spec/models/log_spec.rb b/spec/models/log_spec.rb index 6844e95..cf66ceb 100644 --- a/spec/models/log_spec.rb +++ b/spec/models/log_spec.rb @@ -1,4 +1,30 @@ require 'rails_helper' RSpec.describe Log, type: :model do + + describe 'Rating Update' do + it 'updates recipe rating on create' do + r = create(:recipe) + expect(r.rating).to be_nil + + l = build(:log, source_recipe: r, user: r.user) + l.save + + r.reload + expect(r.rating).to eq 1 + end + + it 'updates recipe rating on update' do + r = create(:recipe) + l = create(:log, source_recipe: r, user: r.user) + r.update_rating! + + l.rating = 5 + l.save + + r.reload + expect(r.rating).to eq 5 + end + end + end diff --git a/spec/models/recipe_spec.rb b/spec/models/recipe_spec.rb index 0418f6c..d8fb105 100644 --- a/spec/models/recipe_spec.rb +++ b/spec/models/recipe_spec.rb @@ -1,4 +1,31 @@ require 'rails_helper' RSpec.describe Recipe, type: :model do + describe '#update_rating!' do + + it 'should set rating to nil with no ratings' do + r = create(:recipe) + r.update_rating! + expect(r.rating).to be_nil + + create(:log, rating: nil, source_recipe: r) + + r.update_rating! + expect(r.rating).to be_nil + end + + it 'should set rating based on user logs' do + user = create(:user) + other_user = create(:user) + r = create(:recipe, user: user) + create(:log, rating: 2, source_recipe: r, user: user) + create(:log, rating: 4, source_recipe: r, user: user) + create(:log, rating: 5, source_recipe: r, user: other_user) + + r.update_rating! + + expect(r.rating).to eq 3 + end + + end end diff --git a/spec/models/usda_food_spec.rb b/spec/models/usda_food_spec.rb index ba73c97..71e57a7 100644 --- a/spec/models/usda_food_spec.rb +++ b/spec/models/usda_food_spec.rb @@ -8,7 +8,8 @@ RSpec.describe UsdaFood do unsalted_butter: create(:usda_food, long_description: 'Unsalted Butter'), flour: create(:usda_food, long_description: 'Flour'), bread_flour: create(:usda_food, long_description: 'Bread Flour'), - sugar: create(:usda_food, long_description: 'Sugar,Granulated') + sugar: create(:usda_food, long_description: 'Sugar,Granulated'), + mustard: create(:usda_food, long_description: 'HONEY MUSTARD DIPPING SAUCE') } end @@ -30,6 +31,10 @@ RSpec.describe UsdaFood do r = UsdaFood.matches_tokens(:long_description, ['sal', 'butter']) expect(r.length).to eq 1 expect(r).to contain_exactly *items(:salted_butter) + + r = UsdaFood.matches_tokens(:long_description, ['butter', 'sal']) + expect(r.length).to eq 1 + expect(r).to contain_exactly *items(:salted_butter) end it 'treats commas like spaces' do