diff options
author | Marius Peter <marius.peter@tutanota.com> | 2025-08-29 14:22:37 +0200 |
---|---|---|
committer | Marius Peter <marius.peter@tutanota.com> | 2025-08-29 14:22:37 +0200 |
commit | 075665c588989ed0decdfb20d83f32b33eed4639 (patch) | |
tree | d9ec78be7338bd51c895b7fb311f081a7c77c45f | |
parent | 52b044d6a4278c229992404ad5801769c2d13363 (diff) |
Properly implement bed and raft management logic.
23 files changed, 338 insertions, 206 deletions
diff --git a/app/controllers/beds_controller.rb b/app/controllers/beds_controller.rb new file mode 100644 index 0000000..c213978 --- /dev/null +++ b/app/controllers/beds_controller.rb @@ -0,0 +1,61 @@ +class BedsController < ApplicationController + before_action :set_bed, only: %i[ edit update ] + before_action :get_crops, only: %i[ index edit update ] + + def index + @beds = Bed.all + end + + def edit + end + + def update + if @bed.update(bed_params) + redirect_to beds_path, notice: "Bed #{@bed.id} successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def bulk_assign_crops + crop = Crop.find(params[:crop_id]) + Raft.update_all(crop_id: crop.id) + redirect_back fallback_location: root_path, notice: "All rafts set to #{crop.name}." + end + + + def reset_seed_crops + # mirrors seed logic + tomatoes = Crop.find_by!(name: :tomatoes) + chives = Crop.find_by!(name: :chives) + lettuce = Crop.find_by!(name: :lettuce) + + Bed.includes(:rafts).find_each do |bed| + default_crop = + case bed.location + when 1..3 then tomatoes + when 4..7 then chives + else lettuce + end + bed.rafts.update_all(crop_id: default_crop.id) + end + redirect_back fallback_location: root_path, notice: "Raft crops reset to default seed layout." + end + + private + + def set_bed + @bed = Bed.find(params[:id]) + end + + def get_crops + @crops = Crop.order(:name) + end + + def bed_params + params.require(:bed).permit( + :location, + rafts_attributes: %i[id crop_id] + ) + end +end diff --git a/app/controllers/crops_controller.rb b/app/controllers/crops_controller.rb index 9619852..951d380 100644 --- a/app/controllers/crops_controller.rb +++ b/app/controllers/crops_controller.rb @@ -1,40 +1,30 @@ class CropsController < ApplicationController before_action :set_crop, only: %i[ show edit update destroy ] - # GET /crops or /crops.json def index @crops = Crop.all end - # GET /crops/1 or /crops/1.json def show end - # GET /crops/new def new @crop = Crop.new end - # GET /crops/1/edit def edit end - # POST /crops or /crops.json def create @crop = Crop.new(crop_params) - respond_to do |format| - if @crop.save - format.html { redirect_to @crop, notice: "Crop was successfully created." } - format.json { render :show, status: :created, location: @crop } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @crop.errors, status: :unprocessable_entity } - end + if @crop.save + redirect_to @crop, notice: "Crop was successfully created." + else + render :new, status: :unprocessable_entity end end - # PATCH/PUT /crops/1 or /crops/1.json def update respond_to do |format| if @crop.update(crop_params) @@ -47,7 +37,6 @@ class CropsController < ApplicationController end end - # DELETE /crops/1 or /crops/1.json def destroy @crop.destroy! @@ -58,13 +47,12 @@ class CropsController < ApplicationController end private - # Use callbacks to share common setup or constraints between actions. - def set_crop - @crop = Crop.find(params.expect(:id)) - end - # Only allow a list of trusted parameters through. - def crop_params - params.expect(crop: [ :name, :crop_type, :nno3, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo, :nnh4 ]) - end + def set_crop + @crop = Crop.find(params.expect(:id)) + end + + def crop_params + params.expect(crop: [ :name, :crop_type, :nno3, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo, :nnh4 ]) + end end diff --git a/app/controllers/rafts_controller.rb b/app/controllers/rafts_controller.rb index 96d99fd..c1c8a72 100644 --- a/app/controllers/rafts_controller.rb +++ b/app/controllers/rafts_controller.rb @@ -1,54 +1,29 @@ class RaftsController < ApplicationController - before_action :set_collections, only: [ :editor ] + before_action :set_raft, only: %i[ edit update ] + before_action :set_crop, only: %i[ edit update ] - def index - redirect_to editor_rafts_path + def edit end - def editor - # @beds, @crops set in before_action - end - - # 1) Assign ALL rafts - def assign_all - crop_id = normalized_crop_id(params[:crop_id]) - Raft.update_all(crop_id: crop_id) # nil ok - redirect_to editor_rafts_path, notice: "All rafts updated." - end - - # 2) Assign all rafts in ONE bed - def assign_bed - bed = Bed.find(params.require(:bed_id)) - crop_id = normalized_crop_id(params[:crop_id]) - bed.rafts.update_all(crop_id: crop_id) - redirect_to editor_rafts_path, notice: "Bed ##{bed.location} updated." - end - - # 3) Assign ONE raft - def assign_one - raft = Raft.find(params[:id]) - val = params[:crop_id].to_s - - if val.blank? || val.casecmp("null").zero? - # Skip validations; write NULL directly - raft.update_column(:crop_id, nil) + def update + if @raft.update(raft_params) + redirect_to beds_path, notice: "Raft #{@raft.id} successfully updated." else - raft.update!(crop_id: val) + render :edit, status: :unprocessable_entity end - - redirect_to editor_rafts_path(anchor: "bed-#{raft.bed_id}"), notice: "Raft updated." end private - def set_collections - @beds = Bed.order(:location) + def set_raft + @raft = Raft.find(params[:id]) + end + + def set_crop @crops = Crop.order(:name) end - # Accept "", "null" → nil to allow clearing - def normalized_crop_id(val) - return nil if val.blank? || val.to_s.downcase == "null" - val + def raft_params + params.require(:raft).permit(:location, :bed_id, :crop_id) end end diff --git a/app/helpers/beds_helper.rb b/app/helpers/beds_helper.rb new file mode 100644 index 0000000..2f3e2cb --- /dev/null +++ b/app/helpers/beds_helper.rb @@ -0,0 +1,2 @@ +module BedsHelper +end diff --git a/app/models/bed.rb b/app/models/bed.rb index 33eafd2..d41afe6 100644 --- a/app/models/bed.rb +++ b/app/models/bed.rb @@ -1,4 +1,5 @@ class Bed < ApplicationRecord has_many :rafts, -> { order(:location) }, dependent: :destroy + accepts_nested_attributes_for :rafts validates :location, presence: true, uniqueness: true end diff --git a/app/models/raft.rb b/app/models/raft.rb index af52700..3fe5928 100644 --- a/app/models/raft.rb +++ b/app/models/raft.rb @@ -1,5 +1,5 @@ class Raft < ApplicationRecord belongs_to :bed - belongs_to :crop + belongs_to :crop, optional: true validates :location, presence: true, uniqueness: { scope: :bed_id } end diff --git a/app/views/application/_navbar.html.erb b/app/views/application/_navbar.html.erb index 9fa250a..89dfe3d 100644 --- a/app/views/application/_navbar.html.erb +++ b/app/views/application/_navbar.html.erb @@ -3,12 +3,12 @@ <span class="navbar-brand">FAPG</span> <button class="navbar-toggler" - type="button" - data-bs-toggle="collapse" - data-bs-target="#navbarSupportedContent" - aria-controls="navbarSupportedContent" - aria-expanded="false" - aria-label="Toggle navigation"> + type="button" + data-bs-toggle="collapse" + data-bs-target="#navbarSupportedContent" + aria-controls="navbarSupportedContent" + aria-expanded="false" + aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> diff --git a/app/views/beds/_bed.html.erb b/app/views/beds/_bed.html.erb new file mode 100644 index 0000000..6961f9b --- /dev/null +++ b/app/views/beds/_bed.html.erb @@ -0,0 +1,2 @@ +<div id="<%= dom_id bed %>"> +</div> diff --git a/app/views/beds/_form.html.erb b/app/views/beds/_form.html.erb new file mode 100644 index 0000000..e3a5c35 --- /dev/null +++ b/app/views/beds/_form.html.erb @@ -0,0 +1,28 @@ +<%= form_with(model: bed) do |form| %> + <% if bed.errors.any? %> + <div style="color: red"> + <h2><%= pluralize(bed.errors.count, "error") %> prohibited this bed from being saved:</h2> + <ul> + <% bed.errors.each do |error| %> + <li><%= error.full_message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="my-3"> + <%= form.fields_for :rafts do |raft| %> + <div class="input-group input-group-sm my-2"> + <span class="input-group-text">Raft <%= raft.object.location %></span> + <%= raft.collection_select :crop_id, @crops, :id, :name, + { include_blank: "Unassigned" }, + { class: "form-select" } %> + </div> + <%= raft.hidden_field :id %> + <% end %> + </div> + + <div> + <%= form.submit class: "btn btn-primary" %> + </div> +<% end %> diff --git a/app/views/beds/edit.html.erb b/app/views/beds/edit.html.erb new file mode 100644 index 0000000..6887586 --- /dev/null +++ b/app/views/beds/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "Editing bed #{@bed.location}" %> + +<h1 class="display-1">Editing bed <%= @bed.location %></h1> + +<%= render "form", bed: @bed %> + +<br> + +<div> + <%= link_to "Back to beds", beds_path, class: "btn btn-secondary" %> +</div> diff --git a/app/views/beds/index.html.erb b/app/views/beds/index.html.erb new file mode 100644 index 0000000..e645dc5 --- /dev/null +++ b/app/views/beds/index.html.erb @@ -0,0 +1,54 @@ +<% content_for :title, "Crop Allocation" %> + +<h1 class="display-1">Beds and Rafts</h1> + +<%= link_to "Back to dashboard", root_path, class: "btn btn-outline-secondary my-3" %> + + +<p> + Click on a bed row or an individual raft to update the corresponding crop. +</p> + +<%= form_with url: bulk_assign_crops_beds_path, + method: :patch, + local: true do %> + <div class="btn-group" role="group" aria-label="Raft crop bulk actions"> + <select class="form-select" name="crop_id"> + <% @crops.each do |crop| %> + <option value="<%= crop.id %>"><%= crop.name.titleize %></option> + <% end %> + </select> + <button type="submit" class="btn btn-outline-primary">Apply to all</button> + </div> +<% end %> + +<div class="table-responsive"> + <table class="table table-sm small text-center align-middle my-3"> + <thead> + <tr> + <th>Bed</th> + <% max_cols = @beds.map { |b| b.rafts.count }.max %> + <% (1..max_cols).each do |i| %> + <th>R<%= i %></th> + <% end %> + </tr> + </thead> + <tbody> + <% @beds.each do |bed| %> + <tr> + <th><%= link_to bed.location, edit_bed_path(bed), class: "btn btn-outline-secondary" %></th> + <% bed.rafts.each do |raft| %> + <td> + <%= link_to (raft&.crop&.name || "—"), edit_raft_path(raft), class: "btn btn-sm" %> + </td> + <% end %> + </tr> + <% end %> + </tbody> + </table> +</div> + +<%= button_to "Reset crop allocation", reset_seed_crops_beds_path, + method: :post, + form: { data: { turbo: false } }, + class: "btn btn-outline-danger" %> diff --git a/app/views/dashboard/_recent_measurements.html.erb b/app/views/dashboard/_nutrient_measurements.html.erb index bc63a60..bc63a60 100644 --- a/app/views/dashboard/_recent_measurements.html.erb +++ b/app/views/dashboard/_nutrient_measurements.html.erb diff --git a/app/views/dashboard/_raft_allocation.html.erb b/app/views/dashboard/_raft_allocation.html.erb index ef95cdd..a2128ea 100644 --- a/app/views/dashboard/_raft_allocation.html.erb +++ b/app/views/dashboard/_raft_allocation.html.erb @@ -1,7 +1,7 @@ <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> - <h5 class="mb-0">Raft Allocation</h5> - <%= link_to "Edit raft allocation", editor_rafts_path, class: "btn btn-sm btn-primary" %> + <h5 class="mb-0">Crop Allocation</h5> + <%= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> </div> <%= bar_chart @raft_data, stacked: true %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 2902ada..b1b2d87 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -4,4 +4,4 @@ <%= render "target_table" %> -<%= render "recent_measurements" %> +<%= render "nutrient_measurements" %> diff --git a/app/views/rafts/_form.html.erb b/app/views/rafts/_form.html.erb new file mode 100644 index 0000000..7b5d382 --- /dev/null +++ b/app/views/rafts/_form.html.erb @@ -0,0 +1,23 @@ +<%= form_with(model: raft) do |form| %> + <% if raft.errors.any? %> + <div style="color: red"> + <h2><%= pluralize(bed.errors.count, "error") %> prohibited this raft from being saved:</h2> + + <ul> + <% raft.errors.each do |error| %> + <li><%= error.full_message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="my-3"> + <label class="form-label">Crop</label> + <%= form.collection_select :crop_id, @crops, :id, :name, + { include_blank: "Unassigned" }, { class: "form-select" } %> + </div> + + <div> + <%= form.submit class: "btn btn-primary" %> + </div> +<% end %> diff --git a/app/views/rafts/edit.html.erb b/app/views/rafts/edit.html.erb new file mode 100644 index 0000000..bf975dc --- /dev/null +++ b/app/views/rafts/edit.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, "Editing bed #{@raft.bed.location}, raft #{@raft.location}" %> + +<h1 class="display-1">Editing bed <%= @raft.bed.location %>, raft <%= @raft.location %></h1> + +<%= render "form", raft: @raft %> diff --git a/app/views/rafts/editor.html.erb b/app/views/rafts/editor.html.erb deleted file mode 100644 index cde56d8..0000000 --- a/app/views/rafts/editor.html.erb +++ /dev/null @@ -1,108 +0,0 @@ -<div class="d-flex justify-content-between align-items-center mb-3"> - <h1 class="display-1">Rafts editor</h1> - <%= link_to "Back to dashboard", root_path, class: "btn btn-outline-secondary" %> -</div> - -<!-- Bulk assign all rafts --> -<div class="card"> - <div class="card-header">Assign ALL rafts</div> - <div class="card-body"> - <%= form_with url: assign_all_rafts_path, method: :patch, class: "row g-2", data: { turbo: false } do %> - <div class="col-12 col-md-6"> - <select class="form-select" name="crop_id"> - <option value="">— Unassigned (clear) —</option> - <% @crops.each do |crop| %> - <option value="<%= crop.id %>"><%= crop.name %></option> - <% end %> - </select> - </div> - <div class="col-12 col-md-auto"> - <button class="btn btn-primary">Apply to all</button> - </div> - <% end %> - </div> -</div> - -<div class="table-responsive"> - <table class="table table-sm table-striped text-center align-middle my-3"> - <thead> - <tr> - <th class="text-start">Bed</th> - <% max_cols = @beds.map { |b| b.rafts.count }.max %> - <% (1..max_cols).each do |i| %> - <th>R<%= i %></th> - <% end %> - </tr> - </thead> - <tbody> - <% @beds.each do |bed| %> - <tr> - <th><%= bed.location %></th> - <% bed.rafts.order(:location).each do |raft| %> - <td> - <% if raft.crop %> - <%= raft.crop.name %> - <% else %> - — - <% end %> - </td> - <% end %> - </tr> - <% end %> - </tbody> - </table> -</div> - -<!-- Per-bed tables --> -<% @beds.each do |bed| %> - <div class="card mb-3" id="bed-<%= bed.id %>"> - <div class="card-header d-flex flex-wrap gap-2 align-items-center"> - <span class="me-auto">Bed <strong>#<%= bed.location %></strong></span> - <%= form_with url: assign_bed_rafts_path, method: :patch, class: "d-flex gap-2 align-items-center", data: { turbo: false } do %> - <input type="hidden" name="bed_id" value="<%= bed.id %>"> - <select class="form-select" name="crop_id"> - <option value="">— Unassigned (clear) —</option> - <% @crops.each do |crop| %> - <option value="<%= crop.id %>"><%= crop.name %></option> - <% end %> - </select> - <button class="btn btn-primary">Apply</button> - <% end %> - </div> - <div class="card-body p-0"> - <div class="table-responsive"> - <table class="table table-sm mb-0 align-middle"> - <thead class="table-light"> - <tr> - <th class="text-nowrap text-center">Raft</th> - <th>Crop</th> - <th class="text-end">Actions</th> - </tr> - </thead> - <tbody> - <% bed.rafts.order(:location).each do |raft| %> - <tr> - <td class="text-center"><strong><%= raft.location %></strong></td> - <td style="max-width: 280px;"> - <%= form_with url: assign_one_raft_path(raft), method: :patch, class: "d-flex gap-2", data: { turbo: false } do %> - <select class="form-select" name="crop_id"> - <option value="">— Unassigned (clear) —</option> - <% @crops.each do |crop| %> - <option value="<%= crop.id %>" <%= "selected" if raft.crop_id == crop.id %>><%= crop.name %></option> - <% end %> - </select> - <button class="btn btn-outline-primary">Save</button> - <% end %> - </td> - <td class="text-end"> - <%= button_to "Clear", assign_one_raft_path(raft, crop_id: ""), method: :patch, - form: { data: { turbo: false } }, class: "btn btn-outline-secondary btn-sm" %> - </td> - </tr> - <% end %> - </tbody> - </table> - </div> - </div> - </div> -<% end %> diff --git a/config/routes.rb b/config/routes.rb index 1b24155..1dd57b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,18 +2,17 @@ Rails.application.routes.draw do root "dashboard#index" get "ferti_recipe", to: "recipes#show" - resources :fertilizer_products - resources :raft_crops, only: [ :update ] - resources :rafts, only: [ :index ] do + + resources :beds, only: [ :index, :edit, :update ] do collection do - get :editor # rafts/editor - patch :assign_all # bulk assign all rafts - patch :assign_bed # bulk assign per bed - end - member do - patch :assign_one # per raft + patch :bulk_assign_crops + post :reset_seed_crops end + + resources :rafts, only: [ :index, :edit, :update ], shallow: true end + + resources :fertilizer_products resources :crops resources :nutrient_measurements diff --git a/db/seeds/3_crops.rb b/db/seeds/3_crops.rb index 2957188..6a5c10f 100644 --- a/db/seeds/3_crops.rb +++ b/db/seeds/3_crops.rb @@ -104,17 +104,17 @@ RASPBERRY = { } [ - [ "lettuce", 0, LEAFY ], - [ "kale", 0, LEAFY ], - [ "chinese cabbage", 0, LEAFY ], - [ "tomatoes", 1, TOMATO ], - [ "raspberries", 1, RASPBERRY ], - [ "strawberries", 1, STRAWBERRY ], - [ "hot peppers", 1, HOT_PEPPER ], - [ "parsley", 2, HERB ], - [ "chives", 2, HERB ], - [ "italian basil", 2, HERB ], - [ "dill", 2, HERB ] + [ "lettuce", 0, LEAFY ], + [ "kale", 0, LEAFY ], + [ "cabbage, chinese", 0, LEAFY ], + [ "tomatoes", 1, TOMATO ], + [ "raspberries", 1, RASPBERRY ], + [ "strawberries", 1, STRAWBERRY ], + [ "hot peppers", 1, HOT_PEPPER ], + [ "parsley", 2, HERB ], + [ "chives", 2, HERB ], + [ "italian basil", 2, HERB ], + [ "dill", 2, HERB ] ].each do |name, type, nutrient_requirements| Crop.find_or_create_by!(name: name) do |c| c.crop_type = type diff --git a/db/seeds/4_beds_and_rafts.rb b/db/seeds/4_beds_and_rafts.rb index 0105260..1cd95dc 100644 --- a/db/seeds/4_beds_and_rafts.rb +++ b/db/seeds/4_beds_and_rafts.rb @@ -9,7 +9,7 @@ RAFTS = 10 when 3 then "hot peppers" when 4 then "chives" when 5 then "italian basil" - when 6..7 then "chinese cabbage" + when 6..7 then "cabbage, chinese" else "lettuce" end diff --git a/test/controllers/beds_controller_test.rb b/test/controllers/beds_controller_test.rb new file mode 100644 index 0000000..7afd166 --- /dev/null +++ b/test/controllers/beds_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class BedsControllerTest < ActionDispatch::IntegrationTest + setup do + @bed = beds(:one) + end + + test "should get index" do + get beds_url + assert_response :success + end + + test "should get new" do + get new_bed_url + assert_response :success + end + + test "should create bed" do + assert_difference("Bed.count") do + post beds_url, params: { bed: {} } + end + + assert_redirected_to bed_url(Bed.last) + end + + test "should show bed" do + get bed_url(@bed) + assert_response :success + end + + test "should get edit" do + get edit_bed_url(@bed) + assert_response :success + end + + test "should update bed" do + patch bed_url(@bed), params: { bed: {} } + assert_redirected_to bed_url(@bed) + end + + test "should destroy bed" do + assert_difference("Bed.count", -1) do + delete bed_url(@bed) + end + + assert_redirected_to beds_url + end +end diff --git a/test/fixtures/beds.yml b/test/fixtures/beds.yml index 330db85..d7a3329 100644 --- a/test/fixtures/beds.yml +++ b/test/fixtures/beds.yml @@ -1,7 +1,11 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -one: - number: 1 - -two: - number: 1 +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +one: {} +# column: value +# +two: {} +# column: value diff --git a/test/system/beds_test.rb b/test/system/beds_test.rb new file mode 100644 index 0000000..b5e6a86 --- /dev/null +++ b/test/system/beds_test.rb @@ -0,0 +1,39 @@ +require "application_system_test_case" + +class BedsTest < ApplicationSystemTestCase + setup do + @bed = beds(:one) + end + + test "visiting the index" do + visit beds_url + assert_selector "h1", text: "Beds" + end + + test "should create bed" do + visit beds_url + click_on "New bed" + + click_on "Create Bed" + + assert_text "Bed was successfully created" + click_on "Back" + end + + test "should update Bed" do + visit bed_url(@bed) + click_on "Edit this bed", match: :first + + click_on "Update Bed" + + assert_text "Bed was successfully updated" + click_on "Back" + end + + test "should destroy Bed" do + visit bed_url(@bed) + click_on "Destroy this bed", match: :first + + assert_text "Bed was successfully destroyed" + end +end |