diff --git a/Gemfile b/Gemfile
index 931a1abb..5751d452 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "bootstrap-sass", "3.4.1"
# Use sqlite3 as the database for Active Record
# gem 'sqlite3'
# Use Puma as the app server
-gem "puma", "~> 6.2"
+gem "puma", "~> 6.3"
# Use SCSS for stylesheets
gem "sass-rails", "~> 5.0"
# Use Uglifier as compressor for JavaScript assets
@@ -54,7 +54,7 @@ gem "jquery-rails"
gem "pg"
-gem "jquery-ui-rails", "~> 5.0", ">= 5.0.5"
+gem "jquery-ui-rails", "~> 6.0"
gem "acts_as_list"
gem "mimemagic", "~> 0.3.8"
diff --git a/Gemfile.lock b/Gemfile.lock
index 91b99d55..a732409c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,7 +77,7 @@ GEM
tzinfo (~> 2.0)
acts_as_list (1.1.0)
activerecord (>= 4.2)
- addressable (2.8.1)
+ addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
autoprefixer-rails (10.4.13.0)
@@ -146,7 +146,7 @@ GEM
globalid (1.1.0)
activesupport (>= 5.0)
hashie (5.0.0)
- i18n (1.12.0)
+ i18n (1.14.1)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
actionview (>= 5.0.0)
@@ -155,7 +155,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
- jquery-ui-rails (5.0.5)
+ jquery-ui-rails (6.0.1)
railties (>= 3.2.16)
json (2.6.3)
jwt (2.7.0)
@@ -165,9 +165,9 @@ GEM
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- loofah (2.19.1)
+ loofah (2.21.3)
crass (~> 1.0.2)
- nokogiri (>= 1.5.9)
+ nokogiri (>= 1.12.0)
madmin (1.2.7)
pagy (>= 3.5, < 6.0)
rails (>= 6.0.3)
@@ -182,8 +182,8 @@ GEM
mimemagic (0.3.10)
nokogiri (~> 1)
rake
- mini_mime (1.1.2)
- minitest (5.18.0)
+ mini_mime (1.1.5)
+ minitest (5.20.0)
multi_xml (0.6.0)
net-imap (0.3.4)
date
@@ -197,12 +197,12 @@ GEM
newrelic_rpm (9.1.0)
next_rails (1.2.2)
colorize (>= 0.8.1)
- nio4r (2.5.8)
- nokogiri (1.14.3-arm64-darwin)
+ nio4r (2.5.9)
+ nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.14.3-x86_64-darwin)
+ nokogiri (1.15.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.14.3-x86_64-linux)
+ nokogiri (1.15.4-x86_64-linux)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
@@ -237,13 +237,13 @@ GEM
parser (3.2.1.1)
ast (~> 2.4.1)
pg (1.4.6)
- public_suffix (5.0.1)
- puma (6.2.1)
+ public_suffix (5.0.3)
+ puma (6.3.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
- racc (1.6.2)
- rack (2.2.6.4)
+ racc (1.7.1)
+ rack (2.2.8)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-protection (3.0.5)
@@ -268,11 +268,13 @@ GEM
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
- rails-dom-testing (2.0.3)
- activesupport (>= 4.2.0)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.5.0)
- loofah (~> 2.19, >= 2.19.1)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
railties (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
@@ -287,7 +289,7 @@ GEM
ffi (~> 1.0)
recursive-open-struct (1.1.3)
redcarpet (3.5.1)
- regexp_parser (2.7.0)
+ regexp_parser (2.8.1)
responders (3.1.0)
actionpack (>= 5.2)
railties (>= 5.2)
@@ -369,7 +371,7 @@ GEM
rubocop-performance (~> 1.16.0)
standardrb (1.0.1)
standard
- thor (1.2.1)
+ thor (1.2.2)
tilt (2.1.0)
timeout (0.3.2)
turbolinks (5.2.1)
@@ -393,15 +395,15 @@ GEM
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0)
websocket (1.2.9)
- websocket-driver (0.7.5)
+ websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.6.7)
+ zeitwerk (2.6.12)
PLATFORMS
- arm64-darwin-21
+ arm64-darwin-22
x86_64-darwin-21
x86_64-linux
@@ -420,7 +422,7 @@ DEPENDENCIES
faker
jbuilder (~> 2.5)
jquery-rails
- jquery-ui-rails (~> 5.0, >= 5.0.5)
+ jquery-ui-rails (~> 6.0)
listen (~> 3.7)
madmin (~> 1.2)
matrix
@@ -429,7 +431,7 @@ DEPENDENCIES
next_rails
ombu_labs-auth
pg
- puma (~> 6.2)
+ puma (~> 6.3)
pundit (~> 2.2)
rack-mini-profiler
rails (~> 7.0.2)
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 6ededb59..b8ed8646 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -14,7 +14,7 @@
//= require jquery
//= require bootstrap-sprockets
//= require jquery-ui/widget
-//= require jquery-ui/sortable
+//= require jquery-ui/widgets/sortable
//= require turbolinks
//= require clipboard.min
//= require sortable_list
diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss
index a484e138..8f94a7b1 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 %> +