diff --git a/Gemfile b/Gemfile
index 931a1abb..5a5afc4a 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
diff --git a/Gemfile.lock b/Gemfile.lock
index c5087d34..7f8802e9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -197,7 +197,7 @@ GEM
newrelic_rpm (9.1.0)
next_rails (1.2.2)
colorize (>= 0.8.1)
- nio4r (2.5.8)
+ nio4r (2.5.9)
nokogiri (1.14.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-darwin)
@@ -238,7 +238,7 @@ GEM
ast (~> 2.4.1)
pg (1.4.6)
public_suffix (5.0.1)
- puma (6.2.1)
+ puma (6.3.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -429,7 +429,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/project.js b/app/assets/javascripts/project.js
index bdfe2dbe..e8eae375 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -105,7 +105,7 @@ const filterStories = () => {
const storyTitle = element
.querySelector("td:first-child")
.innerText.toLowerCase();
- if (storyTitle.includes(searchTerm)) {
+ if (storyTitle.includes(searchTerm) || element.id.replace(/\D/g, '').includes(searchTerm)) {
cl.remove("hidden");
} else {
cl.add("hidden");
diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss
index bc1854d6..3527e8c5 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;
}
@@ -100,7 +101,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
@@ -132,3 +134,36 @@
align-items: center;
gap: 1rem;
}
+
+.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 ea3dcb37..531abbe3 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 4520aa3f..572832b0 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/projects/show.html.erb b/app/views/projects/show.html.erb
index 7ca962ad..e1500b9b 100644
--- a/app/views/projects/show.html.erb
+++ b/app/views/projects/show.html.erb
@@ -3,7 +3,7 @@
<%= render partial: "shared/project_title", locals: {allow_edit: true, project: @project} %>
- <%= label_tag 'title_contains', "Filter by title" %>
+ <%= label_tag 'title_contains', "Filter by title or ID" %>
<%= search_field_tag 'title_contains', nil, onkeyup: "filterStories()" %>
@@ -29,7 +29,7 @@
- <%= link_to story.title, [story.project, story] %>
+ <%= link_to "#{story.id} - #{story.title}", [story.project, story] %>
<%= status_label(story) %>
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 15ce300a..733a014e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -42,6 +42,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 5c207f06..7f61a904 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 4aa5ff6b..8412f837 100644
--- a/spec/controllers/stories_controller_spec.rb
+++ b/spec/controllers/stories_controller_spec.rb
@@ -171,5 +171,47 @@
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}
+ 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/features/stories_manage_spec.rb b/spec/features/stories_manage_spec.rb
index 6a327295..a5e1a783 100644
--- a/spec/features/stories_manage_spec.rb
+++ b/spec/features/stories_manage_spec.rb
@@ -318,4 +318,25 @@
expect(page).not_to have_selector(".project-table.sorting")
end
+
+ it "filter stories by title or ID", js: true do
+ story4 = FactoryBot.create(:story, project: project, title: "Deprecation warning XYZ")
+ story5 = FactoryBot.create(:story, project: project, title: "Dangerous query method")
+
+ visit project_path(id: project.id)
+
+ fill_in "title_contains", with: "XYZ"
+
+ within("#stories") do
+ expect(find("td:nth-child(1)")).to have_text story4.title
+ expect(all("#stories > tr").count).to eq(1)
+ end
+
+ fill_in "title_contains", with: story5.id
+
+ within("#stories") do
+ expect(find("td:nth-child(1)")).to have_text story5.title
+ expect(all("#stories > tr").count).to eq(1)
+ end
+ 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 %> +