diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss index a37e7e97..0a18c5c2 100644 --- a/app/assets/stylesheets/stories.scss +++ b/app/assets/stylesheets/stories.scss @@ -126,3 +126,36 @@ border: 1px solid #9b054d; padding: 5px; } + +.comments-section { + margin: 16px 0; + + .comment-card { + margin: 8px 0; + + .bold { + font-weight: bold; + } + + .link-blue { + color: blue; + } + } +} + +.comment-form-container { + margin: 8px 0; + width: 50%; + + .bold { + font-weight: bold; + } +} + +#comment_body { + margin: 10px 0; +} + +input.button.green { + width: auto; +} diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb new file mode 100644 index 00000000..41ed8892 --- /dev/null +++ b/app/controllers/comments_controller.rb @@ -0,0 +1,59 @@ +class CommentsController < ApplicationController + before_action :authenticate_user! + before_action :load_story_and_project + before_action :find_comment, only: [:edit, :update, :destroy] + + def edit + end + + def create + @comment = current_user.comments.build(story: @story) + @comment.attributes = comment_params + saved = @comment.save + if saved + flash[:success] = "Comment created!" + else + flash[:error] = @comment.errors.full_messages + end + + redirect_to project_story_path(@comment.story.project_id, @comment.story_id) + end + + def update + updated = @comment.update(comment_params) + if updated + flash[:success] = "Comment updated!" + redirect_to project_story_path(@comment.story.project_id, @comment.story_id) + else + flash[:error] = @comment.errors.full_messages + render :edit + end + end + + def destroy + @comment.destroy + flash[:success] = "Comment deleted!" + redirect_to project_story_path(@comment.story.project_id, @comment.story_id) + end + + private + + def find_comment + @comment = current_user.comments.where(story_id: params[:story_id]).find(params[:id]) + rescue ActiveRecord::RecordNotFound + flash[:error] = "Comment not found" + redirect_to project_story_path(params[:project_id], params[:story_id]) + end + + def load_story_and_project + @project = Project.find(params[:project_id]) + @story = Story.find(params[:story_id]) + rescue ActiveRecord::RecordNotFound + flash[:error] = "Project or Story not found" + redirect_to projects_path + end + + def comment_params + params.require(:comment).permit(:body) + end +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 38470d63..a810161d 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -46,6 +46,8 @@ def bulk_destroy def show @estimate = Estimate.find_by(story: @story, user: current_user) + @comments = @story.comments.includes(:user).order(:created_at) + @comment = Comment.new end def update @@ -85,12 +87,25 @@ def import end def export - csv = CSV.generate(headers: true) { |csv| - csv << CSV_HEADERS - @project.stories.by_position.each do |story| - csv << story.attributes.slice(*CSV_HEADERS) + csv = if params[:export_with_comments] == "1" + CSV.generate(headers: true) do |csv| + csv << CSV_HEADERS + ["comment"] + @project.stories.includes(:comments).by_position.each do |story| + comments = [] + story.comments.each do |comment| + comments << "#{comment.user.name}: #{comment.body}" + end + csv << [story.id, story.title, story.description, story.position] + comments + end end - } + else + CSV.generate(headers: true) do |csv| + csv << CSV_HEADERS + @project.stories.by_position.each do |story| + csv << story.attributes.slice(*CSV_HEADERS) + end + end + end filename = "#{@project.title.gsub(/[^\w]/, "_")}-#{Time.now.to_formatted_s(:short).tr(" ", "_")}.csv" send_data csv, filename: filename end diff --git a/app/models/comment.rb b/app/models/comment.rb new file mode 100644 index 00000000..c9d92d11 --- /dev/null +++ b/app/models/comment.rb @@ -0,0 +1,5 @@ +class Comment < ApplicationRecord + belongs_to :story + belongs_to :user + validates :body, presence: true +end diff --git a/app/models/story.rb b/app/models/story.rb index cf2607c5..07c0f439 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -4,6 +4,7 @@ class Story < ApplicationRecord belongs_to :project has_many :estimates has_many :users, through: :estimates + has_many :comments before_create :add_position diff --git a/app/models/user.rb b/app/models/user.rb index ef8c13a9..4289fb3e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,4 +2,5 @@ class User < ApplicationRecord include OmbuLabsAuthenticable has_many :estimates + has_many :comments end diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb new file mode 100644 index 00000000..617fa2d0 --- /dev/null +++ b/app/views/comments/_comment.html.erb @@ -0,0 +1,7 @@ +
+

<%= comment.user.name %>: <%= markdown(comment.body) %> <%= comment.created_at %>

+ <% if current_user == comment.user %> + <%= link_to "Edit Comment", edit_project_story_comment_path(project, story, comment), class: "link-blue" %> | + <%= link_to "Delete", project_story_comment_path(project, story, comment), method: :delete, data: { confirm: "Are you sure?" }, title: "Delete" %> + <% end %> +

\ No newline at end of file diff --git a/app/views/comments/_form.html.erb b/app/views/comments/_form.html.erb new file mode 100644 index 00000000..353235f2 --- /dev/null +++ b/app/views/comments/_form.html.erb @@ -0,0 +1,5 @@ +<%= form_with model: [project, story, comment] do |form| %> + <%= form.text_area :body, rows: 6 %> + <%= form.submit class: "button green"%> + <%= link_to "Back", project_story_path(project, story), id: "back", class: "button" if ["edit", "update"].include?(action_name) %> +<% end %> diff --git a/app/views/comments/edit.html.erb b/app/views/comments/edit.html.erb new file mode 100644 index 00000000..b59ac9d4 --- /dev/null +++ b/app/views/comments/edit.html.erb @@ -0,0 +1,4 @@ +
+

Edit Comment

+ <%= render partial: "form", locals: {story: @story, comment: @comment, project: @project} %> +
\ No newline at end of file diff --git a/app/views/projects/_import_export.html.erb b/app/views/projects/_import_export.html.erb index f220966e..d3549268 100644 --- a/app/views/projects/_import_export.html.erb +++ b/app/views/projects/_import_export.html.erb @@ -31,7 +31,14 @@

Export CSV

- <%= link_to 'Export', export_project_stories_path(@project), class: "button green" %> + <%= form_with url: export_project_stories_path(@project), method: :get do |f| %> + <%= f.submit "Export", class: "button green", data: { disable_with: false } %> +
+ <%= f.label :export_with_comments do %> + <%= f.check_box :export_with_comments %> + Export with comments + <% end %> + <% end %>
diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb index 6e64c761..6027b9ee 100644 --- a/app/views/stories/show.html.erb +++ b/app/views/stories/show.html.erb @@ -1,7 +1,7 @@

<%= render "shared/project_title", project: @project %>

- <%= render "shared/story", story: @story %> + <%= render partial: "shared/story", locals: { story: @story } %>
<%= link_to 'Back', project_path(@project), id: "back", class: "button" %> @@ -10,4 +10,16 @@ <%= link_to "Delete", project_story_path(@project.id, @story), method: :delete, data: { confirm: "Are you sure?", story_id: @story.id }, class: "button red", remote: true , title: "Delete" %> <% end %>
+ +
+

Comments

+ <% @comments.each do |comment| %> + <%= render partial: "comments/comment", locals: { story: @story, project: @project, comment: comment } %> + <% end %> +
+ +
+

Add a new comment

+ <%= render partial: "comments/form", locals: { story: @story, project: @project, comment: @comment } %> +
diff --git a/config/routes.rb b/config/routes.rb index 86c0cca9..42297c54 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ get :export, on: :collection resources :estimates, except: [:index, :show] put :move + resources :comments, only: [:create, :edit, :update, :destroy] end resource :action_plan, only: [:show] end diff --git a/db/migrate/20230908142819_create_comments.rb b/db/migrate/20230908142819_create_comments.rb new file mode 100644 index 00000000..7f1210e5 --- /dev/null +++ b/db/migrate/20230908142819_create_comments.rb @@ -0,0 +1,10 @@ +class CreateComments < ActiveRecord::Migration[7.0] + def change + create_table :comments do |t| + t.text :body + t.integer :story_id + t.integer :user_id + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 22f3da62..7e88c118 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,18 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_08_31_175732) do +ActiveRecord::Schema[7.0].define(version: 2023_09_08_142819) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "comments", force: :cascade do |t| + t.text "body" + t.integer "story_id" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "estimates", force: :cascade do |t| t.integer "best_case_points" t.integer "worst_case_points" diff --git a/spec/controllers/comments_controller_spec.rb b/spec/controllers/comments_controller_spec.rb new file mode 100644 index 00000000..30fe4c2a --- /dev/null +++ b/spec/controllers/comments_controller_spec.rb @@ -0,0 +1,113 @@ +require "rails_helper" + +RSpec.describe CommentsController, type: :controller do + render_views + + let!(:user) { FactoryBot.create(:user) } + let!(:project) { FactoryBot.create(:project) } + let!(:story) { FactoryBot.create(:story, project: project) } + let!(:comment) { FactoryBot.create(:comment, story: story, user: user) } + + before do + @request.env["devise.mapping"] = Devise.mappings[:user] + sign_in user + end + + describe "#create" do + context "with valid attributes" do + let(:valid_params) { FactoryBot.attributes_for(:comment) } + + it "creates a new comment" do + expect { + post :create, params: {project_id: project.id, story_id: story.id, comment: valid_params} + }.to change(Comment, :count).by(1) + end + + it "redirects to the story path" do + post :create, params: {project_id: project.id, story_id: story.id, comment: valid_params} + + expect(response).to redirect_to project_story_path(project.id, story.id) + end + end + + context "with invalid attributes" do + let(:invalid_params) { {body: ""} } + + it "redirects back to the story page" do + post :create, params: {project_id: project.id, story_id: story.id, comment: invalid_params} + expect(response).to redirect_to project_story_path(project.id, story.id) + end + end + end + + describe "#destroy" do + it "deletes the comment" do + delete :destroy, params: {project_id: project.id, story_id: story.id, id: comment.id} + expect(Comment.exists?(comment.id)).to be_falsey + expect(response).to redirect_to project_story_path(project.id, story.id) + end + + it "disallows destroying another users' comment" do + user2 = FactoryBot.create(:user) + comment2 = FactoryBot.create(:comment, story: story, user: user2) + + delete :destroy, params: {id: comment2.id, story_id: story.id, project_id: project.id} + + expect(response).to redirect_to project_story_path(project.id, story.id) + expect(flash[:error]).to eq "Comment not found" + end + end + + describe "#edit" do + before do + get :edit, params: {id: comment.id, story_id: story.id, project_id: project.id} + end + + it "redirects to the edit page" do + expect(response).to render_template :edit + end + + it "shows the fields for the comment" do + expect(assigns(:comment)).to eq comment + end + end + + describe "#edit as other user" do + it "disallows editing another users' comment" do + user2 = FactoryBot.create(:user) + comment2 = FactoryBot.create(:comment, story: story, user: user2) + + get :edit, params: {id: comment2.id, story_id: story.id, project_id: project.id} + + expect(response).to redirect_to project_story_path(project.id, story.id) + expect(flash[:error]).to eq "Comment not found" + end + end + + describe "#update" do + it "updates the body for the comment" do + put :update, params: { + id: comment.id, + story_id: story.id, + project_id: project.id, + comment: { + body: "test123" + } + } + expect(comment.reload.body).to eq "test123" + end + + it "disallows updating another users' comment" do + user2 = FactoryBot.create(:user) + comment2 = FactoryBot.create(:comment, story: story, user: user2) + + put :update, params: {id: comment2.id, + story_id: story.id, + project_id: project.id, + comment: {body: "test123"}} + + expect(response).to redirect_to project_story_path(project.id, story.id) + expect(flash[:error]).to eq "Comment not found" + end + end +end diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb index 137d4a92..d9eb729b 100644 --- a/spec/controllers/stories_controller_spec.rb +++ b/spec/controllers/stories_controller_spec.rb @@ -144,5 +144,47 @@ expect(response).to redirect_to project2 end end + + describe "#export" do + it "exports a CSV file" do + get :export, params: {project_id: project.id} + expect(response).to have_http_status(:ok) + + csv_data = CSV.parse(response.body) + expected_csv_content = [ + ["id", "title", "description", "position"], + [story.id.to_s, story.title, story.description, story.position.to_s] + ] + expect(csv_data).to eq(expected_csv_content) + end + + context "with comments" do + it "exports a CSV file" do + user = FactoryBot.create(:user) + story2 = FactoryBot.create(:story, project: project) + story3 = FactoryBot.create(:story, project: project) + story4 = FactoryBot.create(:story, project: project) + comment1 = FactoryBot.create(:comment, user: user, story: story) + comment1_2 = FactoryBot.create(:comment, user: user, story: story) + comment2_1 = FactoryBot.create(:comment, user: user, story: story2) + comment2_2 = FactoryBot.create(:comment, user: user, story: story2) + comment3_1 = FactoryBot.create(:comment, user: user, story: story3) + get :export, params: {project_id: project.id, export_with_comments: "1"} + + expect(response).to have_http_status(:ok) + + csv_data = CSV.parse(response.body) + expected_csv_content = [ + ["id", "title", "description", "position", "comment"], + [story.id.to_s, story.title, story.description, story.position.to_s, "#{comment1.user.name}: #{comment1.body}", "#{comment1_2.user.name}: #{comment1_2.body}"], + [story2.id.to_s, story2.title, story2.description, story2.position.to_s, "#{comment2_1.user.name}: #{comment2_1.body}", "#{comment2_2.user.name}: #{comment2_2.body}"], + [story3.id.to_s, story3.title, story3.description, story3.position.to_s, "#{comment3_1.user.name}: #{comment3_1.body}"], + [story4.id.to_s, story4.title, story4.description, story4.position.to_s] + ] + + expect(csv_data).to eq(expected_csv_content) + end + end + end end end diff --git a/spec/factories/comments.rb b/spec/factories/comments.rb new file mode 100644 index 00000000..39556e6c --- /dev/null +++ b/spec/factories/comments.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :comment do + body { Faker::ChuckNorris.fact } + user + story + end +end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb new file mode 100644 index 00000000..edb0da0f --- /dev/null +++ b/spec/models/comment_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe Comment, type: :model do + subject { FactoryBot.create(:comment) } + + it { should belong_to(:user) } + it { should belong_to(:story) } + it { should validate_presence_of(:body) } +end