diff --git a/app/assets/javascripts/stories.js b/app/assets/javascripts/stories.js index 909018e0..5e7da51b 100644 --- a/app/assets/javascripts/stories.js +++ b/app/assets/javascripts/stories.js @@ -26,3 +26,20 @@ document.addEventListener("DOMContentLoaded", () => { }); }); }); + +function updateStatusButton(color, status) { + const button = document.querySelector(".story-title .dropdown-wrapper > button"); + button.className = `button ${color}`; + + const span = button.querySelector("span"); + span.textContent = status; + + document.querySelector(":focus").blur(); +} + +function updateStatusLabel(status, storyId) { + let row = document.getElementById(`story_${storyId}`) + status_label = row.querySelector(".status > .story-status-badge") + status_label.textContent = status + status_label.classList.value = `story-status-badge ${status}` +} diff --git a/app/assets/stylesheets/3-atoms/_badges.scss b/app/assets/stylesheets/3-atoms/_badges.scss index 2e170082..8a7ef17f 100644 --- a/app/assets/stylesheets/3-atoms/_badges.scss +++ b/app/assets/stylesheets/3-atoms/_badges.scss @@ -23,3 +23,29 @@ background-color: #57ce81; } } + +.story-status-badge { + display: inline-block; + padding: 7px 15px; + font-weight: 600; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 20px; + font-size: 11px; + background-color: $orange; + + &.approved { + background-color: $green; + border-color: #1d4ed8; + color: $white; + } + + &.rejected { + background-color: $magenta; + border-color: #d77e72; + color: $white; + } + +} diff --git a/app/assets/stylesheets/4-molecules/_tables.scss b/app/assets/stylesheets/4-molecules/_tables.scss index fcf3c6d8..6f0b852e 100644 --- a/app/assets/stylesheets/4-molecules/_tables.scss +++ b/app/assets/stylesheets/4-molecules/_tables.scss @@ -11,7 +11,7 @@ } .project-table__row { display: grid; - grid-template-columns: 1fr 70px 70px 260px; + grid-template-columns: 1fr 100px 70px 70px 260px; align-items: center; padding: 10px 0; &.project-table__row--reports { diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss index c9dc1431..3ace7f06 100644 --- a/app/assets/stylesheets/stories.scss +++ b/app/assets/stylesheets/stories.scss @@ -10,10 +10,12 @@ word-break: break-all; } -.story-description, .extra-info, .story_preview { +.story-description, +.extra-info, +.story_preview { margin-bottom: 25px; font-size: 15px; - p{ + p { margin-top: 1em; } em { @@ -55,7 +57,6 @@ margin-bottom: 1.5em; } - .modal p { padding-bottom: 1.3em; } @@ -72,6 +73,7 @@ "title preview" "description preview" "extra extra-preview" + "status ." "submit ."; grid-template-columns: repeat(2, minmax(50%, 1fr)); grid-column-gap: 10px; @@ -94,6 +96,10 @@ &.story_extra_info { grid-area: extra; } + + &.story_status { + grid-area: status; + } } .story_preview { @@ -104,7 +110,8 @@ grid-area: extra-preview; } - .extra_info_preview .content, .story_preview .content { + .extra_info_preview .content, + .story_preview .content { overflow: auto; max-height: min(50vh, 700px); // prevent long links from overflowing @@ -131,6 +138,12 @@ padding: 5px; } +.story-title { + display: flex; + align-items: center; + gap: 1rem; +} + .comments-section { margin: 16px 0; diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index a810161d..521dbee8 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -1,8 +1,8 @@ require "csv" class StoriesController < ApplicationController before_action :authenticate_user! - before_action :find_project, except: [:bulk_destroy, :render_markdown, :edit, :update, :destroy, :show, :move] - before_action :find_story, only: [:edit, :update, :destroy, :show, :move] + before_action :find_project, except: [:bulk_destroy, :render_markdown, :edit, :update, :destroy, :show, :move, :approve, :reject, :pending] + before_action :find_story, only: [:edit, :update, :destroy, :show, :move, :approve, :reject, :pending] before_action :validate_url_product_id, only: [:edit, :update, :destroy, :show, :move] before_action :ensure_unarchived!, except: [:show, :bulk_destroy, :render_markdown, :move] @@ -127,6 +127,27 @@ def move redirect_to @project end + def approve + @story.approved! + respond_to do |format| + format.js { render "shared/update_status" } + end + end + + def reject + @story.rejected! + respond_to do |format| + format.js { render "shared/update_status" } + end + end + + def pending + @story.pending! + respond_to do |format| + format.js { render "shared/update_status" } + end + end + private def find_project @@ -143,7 +164,7 @@ def validate_url_product_id end def stories_params - params.require(:story).permit(:title, :description, :extra_info, :project_id) + params.require(:story).permit(:title, :description, :extra_info, :project_id, :status) end def expected_csv_headers?(file) diff --git a/app/helpers/stories_helper.rb b/app/helpers/stories_helper.rb index 43e5cd8f..126f0ba8 100644 --- a/app/helpers/stories_helper.rb +++ b/app/helpers/stories_helper.rb @@ -1,2 +1,18 @@ module StoriesHelper + def status_label(story) + "#{story.status}".html_safe + end + + def status_color(story) + return "green" if @story.approved? + return "magenta" if @story.rejected? + + "orange" + end + + def options_for_status_select(story, action) + return options_for_select({"Pending" => "pending", "Approved" => "approved"}, selected: story.status) if action == "new" + + options_for_select({"Pending" => "pending", "Approved" => "approved", "Rejected" => "rejected"}, selected: story.status) + end end diff --git a/app/models/story.rb b/app/models/story.rb index 07c0f439..572832b0 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -8,6 +8,8 @@ class Story < ApplicationRecord before_create :add_position + enum :status, [:pending, :approved, :rejected] + scope :by_position, -> { order("stories.position ASC NULLS FIRST, stories.created_at ASC") } def best_estimate_average diff --git a/app/views/estimates/_update_row.js.erb b/app/views/estimates/_update_row.js.erb index 6d7e1622..01260184 100644 --- a/app/views/estimates/_update_row.js.erb +++ b/app/views/estimates/_update_row.js.erb @@ -1,7 +1,7 @@ -let row = document.getElementById("story_<%= estimate.story_id %>") -row.children[1].innerText = "<%= j(estimate.best_case_points.to_s) %>" -row.children[2].innerText = "<%= j(estimate.worst_case_points.to_s) %>" +updateStatusLabel("<%= estimate.story.status %>", "<%= estimate.story_id %>") -let totals_row = document.querySelector('.project-table tfoot tr') -totals_row.children[1].innerText = "<%= j @project.best_estimate_sum_per_user(current_user) %>" -totals_row.children[2].innerText = "<%= j @project.worst_estimate_sum_per_user(current_user) %>" \ No newline at end of file +document.getElementById("best_estimate_<%= estimate.story_id %>").innerText = "<%= j(estimate.best_case_points.to_s) %>" +document.getElementById("worst_estimate_<%= estimate.story_id %>").innerText = "<%= j(estimate.worst_case_points.to_s) %>" + +document.querySelector('.project-table tfoot tr > .best_estimates_total').innerText = "<%= j @project.best_estimate_sum_per_user(current_user) %>" +document.querySelector('.project-table tfoot tr > .worst_estimates_total').innerText = "<%= j @project.worst_estimate_sum_per_user(current_user) %>" diff --git a/app/views/estimates/create.js.erb b/app/views/estimates/create.js.erb index 913e3a54..1f3aaf4c 100644 --- a/app/views/estimates/create.js.erb +++ b/app/views/estimates/create.js.erb @@ -1,11 +1,11 @@ (function(){ <% if @estimate.persisted? %> <%= render partial: 'update_row', locals: {estimate: @estimate} %> - const addEstimate = row.querySelector('.add-estimate') + const addEstimate = document.getElementById("story_<%= @estimate.story_id %>").querySelector('.add-estimate') addEstimate.insertAdjacentHTML('afterend', "<%= j(link_to 'Edit Estimate', edit_project_story_estimate_path(@project.id, @estimate.story, @estimate.id), class: "button edit-estimate", remote: true) %>") addEstimate.remove() closeModal() <% else %> updateModal("New estimate", "<%= j(render partial: 'modal_body') %>") <% end %> -})() \ No newline at end of file +})() diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index 9620c314..da676373 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -14,6 +14,7 @@ Story Title + Status Best
Estimate Worst
Estimate @@ -25,14 +26,15 @@ <% if @stories.present? %> <% @stories.each do | story | %> - + Copied to clipboard <%= link_to "#{story.id} - #{story.title}", [story.project, story] %> - <%= story.estimate_for(current_user)&.best_case_points %> - <%= story.estimate_for(current_user)&.worst_case_points %> + <%= status_label(story) %> + <%= story.estimate_for(current_user)&.best_case_points %> + <%= story.estimate_for(current_user)&.worst_case_points %> <% if is_unlocked?(@project) %> <% if estimated(story) %> @@ -81,8 +83,9 @@ Total estimates - <%= @project.best_estimate_sum_per_user(current_user) %> - <%= @project.worst_estimate_sum_per_user(current_user) %> + + <%= @project.best_estimate_sum_per_user(current_user) %> + <%= @project.worst_estimate_sum_per_user(current_user) %> diff --git a/app/views/shared/_story.html.erb b/app/views/shared/_story.html.erb index 99dbdcf2..301492f7 100644 --- a/app/views/shared/_story.html.erb +++ b/app/views/shared/_story.html.erb @@ -1,4 +1,17 @@ -

Story #<%= story.id %>: <%= story.title %>

+
+

Story #<%= story.id %>: <%= story.title %>

+ +
<%= markdown(story.description) %> diff --git a/app/views/shared/update_status.js.erb b/app/views/shared/update_status.js.erb new file mode 100644 index 00000000..3c4d1caf --- /dev/null +++ b/app/views/shared/update_status.js.erb @@ -0,0 +1,2 @@ +updateStatusButton("<%= status_color(@story) %>", "<%= @story.status %>"); +updateStatusLabel("<%= @story.status %>", "<%= @story.id %>") diff --git a/app/views/stories/_form.html.erb b/app/views/stories/_form.html.erb index 7610f75f..2fa26d7b 100644 --- a/app/views/stories/_form.html.erb +++ b/app/views/stories/_form.html.erb @@ -38,6 +38,11 @@
<%= markdown(@story.extra_info) %>
+
+ <%= f.label :status, "Status" %> + <%= f.select :status, options_for_status_select(@story, action_name), class: "project-story-approved" %> +
+
<%= f.submit yield(:button_text), class: "button green", id: "edit" %> diff --git a/config/routes.rb b/config/routes.rb index b1565639..c54e79e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,9 +11,17 @@ get "home/index" get "reports/index" - resource :stories do - post :bulk_destroy, to: "stories#bulk_destroy" - post :render_markdown + resources :stories do + member do + patch :approve + patch :reject + patch :pending + end + + collection do + post :bulk_destroy, to: "stories#bulk_destroy" + post :render_markdown + end end resources :projects do diff --git a/db/migrate/20230829001347_add_status_to_stories.rb b/db/migrate/20230829001347_add_status_to_stories.rb new file mode 100644 index 00000000..39acb5fc --- /dev/null +++ b/db/migrate/20230829001347_add_status_to_stories.rb @@ -0,0 +1,5 @@ +class AddStatusToStories < ActiveRecord::Migration[7.0] + def change + add_column :stories, :status, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e88c118..7f61a904 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -52,6 +52,7 @@ t.integer "position" t.integer "real_score" t.string "extra_info" + t.integer "status", default: 0 end create_table "users", force: :cascade do |t| diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index d9eb729b..8412f837 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -145,6 +145,33 @@ end end + describe "#approve" do + it "updates the story status to approved" do + patch :approve, params: {id: story.id}, format: :js + + expect(story.reload.status).to eq("approved") + expect(response).to render_template("shared/update_status") + end + end + + describe "#reject" do + it "updates the story status to rejected" do + patch :reject, params: {id: story.id}, format: :js + + expect(story.reload.status).to eq("rejected") + expect(response).to render_template("shared/update_status") + end + end + + describe "#pending" do + it "updates the story status to pending" do + patch :pending, params: {id: story.id}, format: :js + + expect(story.reload.status).to eq("pending") + expect(response).to render_template("shared/update_status") + end + end + describe "#export" do it "exports a CSV file" do get :export, params: {project_id: project.id} diff --git a/spec/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb index a5e1a783..5048201f 100644 --- a/spec/features/stories_manage_spec.rb +++ b/spec/features/stories_manage_spec.rb @@ -15,8 +15,36 @@ fill_in "story[title]", with: "As a user, I want to add stories" fill_in "story[description]", with: "This story allows users to add stories." fill_in "story[extra_info]", with: "This story allows users to add extra details." + default_status = find(:option, "Pending") + expect(default_status).to be_selected click_button "Create" expect(Story.count).to eq 2 + + story = Story.last + expect(story.pending?).to be true + end + + context "within the new story page" do + it "allows me to select a status other than pending" do + visit project_path(id: project.id) + click_link "Add a Story" + fill_in "story[title]", with: "As a user, I want to add stories" + fill_in "story[description]", with: "This story allows users to add stories." + fill_in "story[extra_info]", with: "This story allows users to add extra details." + select "Approved", from: "Status" + status = find(:option, "Approved") + expect(status).to be_selected + click_button "Create" + + story = Story.last + expect(story.approved?).to be true + end + + it "doesn't have a Rejected option for the status" do + visit project_path(id: project.id) + click_link "Add a Story" + expect { find(:option, "Rejected") }.to raise_error(Capybara::ElementNotFound) + end end it "allows me to clone a story" do diff --git a/spec/features/story_show_spec.rb b/spec/features/story_show_spec.rb new file mode 100644 index 00000000..45679cf8 --- /dev/null +++ b/spec/features/story_show_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "story status", js: true do + let(:user) { FactoryBot.create(:user) } + let(:project) { FactoryBot.create(:project) } + let!(:story) { FactoryBot.create(:story, project: project) } + + before do + login_as(user, scope: :user) + end + + context "from the show page" do + it "allows me to change the story status" do + visit project_story_path(project_id: project.id, id: story.id) + + status_button = find(".dropdown-wrapper").find("button") + + status_button.click + click_button("Approve") + + status_button = find(".dropdown-wrapper").find("button") + + expect(status_button).to have_text("approved") + end + end + + context "from the estimation modal" do + it "allows me to change the story status" do + visit project_path(id: project.id) + click_link "Add Estimate" + + status_button = find(".story-title").find(".dropdown-wrapper").find("button") + + status_button.click + click_button("Approve") + + status_button = find(".story-title").find(".dropdown-wrapper").find("button") + + expect(status_button).to have_text("approved") + + click_button "Save changes" + + status_badge = find(".project-table").find(".status").find(".story-status-badge") + expect(status_badge).to have_text("approved") + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index c58adc97..7accd23a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -63,8 +63,8 @@ def expect_closed_modal def expect_story_estimates(story, best, worst) within_story_row(story) do - expect(find("td:nth-child(2)")).to have_text best.to_s - expect(find("td:nth-child(3)")).to have_text worst.to_s + expect(find("td:nth-child(3)")).to have_text best.to_s + expect(find("td:nth-child(4)")).to have_text worst.to_s end end end