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 @@ +
+

<%= 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