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 @@
+
\ 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 %>
+
+
+
+
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
<%= 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 %> +