diff --git a/.gitignore b/.gitignore index 4f30042..f01e63d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ # Ignore database settings /config/database.yml + +# Ignore secret configs +/config/secrets/* diff --git a/Gemfile b/Gemfile index 259c4f2..5e4ae75 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,10 @@ gem 'uglifier', '>= 1.3.0' gem 'devise' # JS gem 'jquery-rails' +# UI +gem 'bootstrap', '~> 4.3.1' +# Google Cloud Storage +gem "google-cloud-storage", "~> 1.11", require: false # Use CoffeeScript for .coffee assets and views gem 'coffee-rails', '~> 4.2' diff --git a/Gemfile.lock b/Gemfile.lock index b398e95..b90fbe4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -45,13 +45,19 @@ GEM addressable (2.6.0) public_suffix (>= 2.0.2, < 4.0) arel (9.0.0) + autoprefixer-rails (9.6.0) + execjs bcrypt (3.1.13) bindex (0.7.0) bootsnap (1.4.4) msgpack (~> 1.0) + bootstrap (4.3.1) + autoprefixer-rails (>= 9.1.0) + popper_js (>= 1.14.3, < 2) + sassc-rails (>= 2.0.0) builder (3.2.3) byebug (11.0.1) - capybara (3.24.0) + capybara (3.25.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -70,6 +76,8 @@ GEM coffee-script-source (1.12.2) concurrent-ruby (1.1.5) crass (1.0.4) + declarative (0.0.10) + declarative-option (0.1.0) devise (4.6.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -77,6 +85,7 @@ GEM responders warden (~> 1.2.3) diff-lcs (1.3) + digest-crc (0.4.1) erubi (1.8.0) execjs (2.7.0) factory_bot (5.0.2) @@ -84,9 +93,38 @@ GEM factory_bot_rails (5.0.2) factory_bot (~> 5.0.2) railties (>= 4.2.0) + faraday (0.15.4) + multipart-post (>= 1.2, < 3) ffi (1.11.1) globalid (0.4.2) activesupport (>= 4.2.0) + google-api-client (0.30.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.5, < 0.10.0) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + signet (~> 0.10) + google-cloud-core (1.3.0) + google-cloud-env (~> 1.0) + google-cloud-env (1.2.0) + faraday (~> 0.11) + google-cloud-storage (1.18.2) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-api-client (~> 0.26) + google-cloud-core (~> 1.2) + googleauth (>= 0.6.2, < 0.10.0) + mime-types (~> 3.0) + googleauth (0.8.1) + faraday (~> 0.12) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (~> 0.7) + httpclient (2.8.3) i18n (1.6.0) concurrent-ruby (~> 1.0) jbuilder (2.9.1) @@ -95,6 +133,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) + jwt (2.2.1) launchy (2.4.3) addressable (~> 2.3) listen (3.1.5) @@ -108,18 +147,26 @@ GEM mini_mime (>= 0.1.1) marcel (0.3.3) mimemagic (~> 0.3.2) + memoist (0.16.0) method_source (0.9.2) + mime-types (3.2.2) + mime-types-data (~> 3.2015) + mime-types-data (3.2019.0331) mimemagic (0.3.3) mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) msgpack (1.3.0) + multi_json (1.13.1) + multipart-post (2.1.1) nio4r (2.3.1) nokogiri (1.10.3) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) + os (1.0.1) pg (1.1.4) - public_suffix (3.1.0) + popper_js (1.14.5) + public_suffix (3.1.1) puma (3.12.1) rack (2.0.7) rack-test (1.1.0) @@ -157,10 +204,15 @@ GEM rb-inotify (0.10.0) ffi (~> 1.0) regexp_parser (1.5.1) - responders (2.4.1) - actionpack (>= 4.2.0, < 6.0) - railties (>= 4.2.0, < 6.0) - rspec-core (3.8.1) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) + responders (3.0.0) + actionpack (>= 5.0) + railties (>= 5.0) + retriable (3.1.2) + rspec-core (3.8.2) rspec-support (~> 3.8.0) rspec-expectations (3.8.4) diff-lcs (>= 1.2.0, < 2.0) @@ -190,11 +242,25 @@ GEM sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) + sassc (2.0.1) + ffi (~> 1.9) + rake + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt selenium-webdriver (3.142.3) childprocess (>= 0.5, < 2.0) rubyzip (~> 1.2, >= 1.2.2) shoulda-matchers (4.1.0) activesupport (>= 4.2.0) + signet (0.11.0) + addressable (~> 2.3) + faraday (~> 0.9) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) slim (4.0.1) temple (>= 0.7.6, < 0.9) tilt (>= 2.0.6, < 2.1) @@ -222,6 +288,7 @@ GEM turbolinks-source (5.2.0) tzinfo (1.2.5) thread_safe (~> 0.1) + uber (0.1.0) uglifier (4.1.20) execjs (>= 0.3.0, < 3) warden (1.2.8) @@ -246,11 +313,13 @@ PLATFORMS DEPENDENCIES bootsnap (>= 1.1.0) + bootstrap (~> 4.3.1) byebug capybara (>= 2.15) coffee-rails (~> 4.2) devise factory_bot_rails + google-cloud-storage (~> 1.11) jbuilder (~> 2.5) jquery-rails launchy diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index a55d271..48d768d 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -14,4 +14,7 @@ //= require activestorage //= require turbolinks //= require jquery3 +//= require popper +//= require bootstrap-sprockets +//= require activestorage //= require_tree . diff --git a/app/assets/javascripts/files.coffee b/app/assets/javascripts/files.coffee new file mode 100644 index 0000000..24f83d1 --- /dev/null +++ b/app/assets/javascripts/files.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/questions.js b/app/assets/javascripts/questions.js index 44b3cdb..9fb60c8 100644 --- a/app/assets/javascripts/questions.js +++ b/app/assets/javascripts/questions.js @@ -1,5 +1,5 @@ $(document).on('turbolinks:load', function() { - $('.edit-question-link').on('click', function(e) { + $('.question').on('click', '.edit-question-link', function(e) { e.preventDefault() $(this).hide() var questionId = $(this).data('questionId') diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.scss similarity index 88% rename from app/assets/stylesheets/application.css rename to app/assets/stylesheets/application.scss index d05ea0f..f8c1d73 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.scss @@ -13,3 +13,6 @@ *= require_tree . *= require_self */ + +// Custom bootstrap variables must be set or imported *before* bootstrap. +@import "bootstrap"; diff --git a/app/assets/stylesheets/files.scss b/app/assets/stylesheets/files.scss new file mode 100644 index 0000000..ee0acc7 --- /dev/null +++ b/app/assets/stylesheets/files.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Files controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 31aeb26..5a2cf55 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -33,7 +33,7 @@ def mark private def answer_params - params.require(:answer).permit(:body) + params.require(:answer).permit(:body, files: []) end def set_answer diff --git a/app/controllers/files_controller.rb b/app/controllers/files_controller.rb new file mode 100644 index 0000000..a37941d --- /dev/null +++ b/app/controllers/files_controller.rb @@ -0,0 +1,7 @@ +class FilesController < ApplicationController + def destroy + @file = ActiveStorage::Attachment.find(params[:id]) + return head :forbidden unless @file.record_type.in?(%w[Question Answer]) + @file.purge if current_user&.owner?(@file.record) + end +end diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index f23fa9d..671a466 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -42,10 +42,10 @@ def destroy private def set_question - @question = Question.find(params[:id]) + @question = Question.with_attached_files.find(params[:id]) end def question_params - params.require(:question).permit(:title, :body) + params.require(:question).permit(:title, :body, files: []) end end diff --git a/app/helpers/files_helper.rb b/app/helpers/files_helper.rb new file mode 100644 index 0000000..e9da4f6 --- /dev/null +++ b/app/helpers/files_helper.rb @@ -0,0 +1,2 @@ +module FilesHelper +end diff --git a/app/models/answer.rb b/app/models/answer.rb index 871d79c..42ddfa4 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -2,6 +2,8 @@ class Answer < ApplicationRecord belongs_to :user belongs_to :question + has_many_attached :files + default_scope { order(best: :desc, created_at: :asc) } scope :best, -> { where(best: true) } diff --git a/app/models/question.rb b/app/models/question.rb index 19dcf3b..b760a36 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -2,6 +2,10 @@ class Question < ApplicationRecord belongs_to :user has_many :answers, dependent: :destroy + has_many_attached :files + + default_scope { order(:created_at) } + validates :title, presence: true, length: { in: 15..75 }, uniqueness: { case_sensitive: false } diff --git a/app/views/answers/_answer.html.slim b/app/views/answers/_answer.html.slim index d1062ba..dac75ae 100644 --- a/app/views/answers/_answer.html.slim +++ b/app/views/answers/_answer.html.slim @@ -1,12 +1,24 @@ -div id="answer_#{answer.id}" +div id="answer_#{answer.id}" class="mb-3" - if answer.persisted? + - if answer.best? div id="best-answer" - h4 The best answer - p= answer.body - p= render(partial: 'shared/answer_links', locals: { answer: answer }) + h5 The best answer + + p class="mb-1"= answer.body + + - if answer.files.attached? + div class="answer-files mb-1" + - answer.files.each do |file| + p.mb-0 + = link_to file.filename.to_s, url_for(file) + =< link_to('Delete file', file_path(file), method: :delete, remote: true) if current_user&.owner?(answer) + + p class="mb-1"= render(partial: 'shared/answer_links', locals: { answer: answer }) = form_with model: answer, class: 'hidden', html: { id: "edit-answer-#{answer.id}" } do |f| - = f.label :body, 'Your answer' - = f.text_area :body - = f.submit 'Update' + div= f.label :body, 'Your answer', class: "mb-0" + div= f.text_area :body, cols: 32, rows: 3 + div= f.label :files, 'Files', class: "mb-0" + div= f.file_field :files, multiple: true, direct_upload: true, class: "mb-3" + div= f.submit 'Update' diff --git a/app/views/files/destroy.js.erb b/app/views/files/destroy.js.erb new file mode 100644 index 0000000..bf9edbb --- /dev/null +++ b/app/views/files/destroy.js.erb @@ -0,0 +1,7 @@ +<% if @file.record_type == "Question" %> + <% @question = @file.record %> + $("#question_" + <%= @question.id %>).replaceWith('<%= j render 'questions/question_for_show', resource: @question %>') +<% elsif @file.record_type == "Answer" %> + <% @answer = @file.record %> + $("#answer_" + <%= @answer.id %>).replaceWith('<%= j render 'answers/answer', answer: @answer %>') +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb deleted file mode 100644 index d6f50f6..0000000 --- a/app/views/layouts/application.html.erb +++ /dev/null @@ -1,18 +0,0 @@ - - - - QnA - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> - <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> - - - - <%= render 'shared/user_nav' %> -

<%= notice %>

-

<%= alert %>

- <%= yield %> - - diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim new file mode 100644 index 0000000..4319e1b --- /dev/null +++ b/app/views/layouts/application.html.slim @@ -0,0 +1,19 @@ +doctype html +html + head + title QnA + = csrf_meta_tags + = csp_meta_tag + = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' + = javascript_include_tag 'application', 'data-turbolinks-track': 'reload' + body + div class="navbar navbar-expand-sm navbar-light bg-light" + = render 'shared/user_nav' + div class="container" + div class="mt-2 row justify-content-center" + div class="col col-sm-6" + - if flash[:notice].present? + p.notice= notice + - if flash[:alert].present? + p.alert= alert + = yield diff --git a/app/views/questions/_question.html.slim b/app/views/questions/_question.html.slim index deb0b83..2f7e2b7 100644 --- a/app/views/questions/_question.html.slim +++ b/app/views/questions/_question.html.slim @@ -1 +1,2 @@ -p= question.title +p + = link_to question.title, question_path(question) diff --git a/app/views/questions/_question_for_show.html.slim b/app/views/questions/_question_for_show.html.slim index fe7272f..3f90be7 100644 --- a/app/views/questions/_question_for_show.html.slim +++ b/app/views/questions/_question_for_show.html.slim @@ -1,11 +1,24 @@ -h4= @question.title -p= @question.body -=render(partial: 'shared/question_links', locals: { question: @question }) if current_user&.owner?(@question) -.question-errors - =render 'shared/errors', resource: @question -= form_with model: @question, class: 'hidden', html: { id: "edit-question-#{@question.id}" } do |f| - = f.label :title - = f.text_field :title - = f.label :body, 'Body' - = f.text_area :body - = f.submit 'Update' +div id="question_#{@question.id}" + h4 class="mt-2"= @question.title + p class="mb-1"= @question.body + + - if @question.files.attached? + div class="question-files mb-1" + - @question.files.each do |file| + p.mb-0 + = link_to file.filename.to_s, url_for(file) + =< link_to('Delete file', file_path(file), method: :delete, remote: true) if current_user&.owner?(@question) + + p class="mb-1"= render(partial: 'shared/question_links', locals: { question: @question }) if current_user&.owner?(@question) + + .question-errors + =render 'shared/errors', resource: @question + + = form_with model: @question, class: 'hidden', html: { id: "edit-question-#{@question.id}" } do |f| + div= f.label :title, class: "mb-0" + div= f.text_field :title, size: 29, class: "mb-2" + div= f.label :body, 'Body', class: "mb-0" + div= f.text_area :body, cols: 32, rows: 3 + div= f.label :files, 'Files', class: "mb-0" + div= f.file_field :files, multiple: true, direct_upload: true, class: "mb-3" + div= f.submit 'Update' diff --git a/app/views/questions/index.html.slim b/app/views/questions/index.html.slim index c7fcf13..d0c9d6b 100644 --- a/app/views/questions/index.html.slim +++ b/app/views/questions/index.html.slim @@ -1,2 +1,2 @@ -p= render @questions +p.questions= render @questions p= link_to 'Ask a question', new_question_path diff --git a/app/views/questions/new.html.slim b/app/views/questions/new.html.slim index 6469bfe..d422231 100644 --- a/app/views/questions/new.html.slim +++ b/app/views/questions/new.html.slim @@ -1,8 +1,11 @@ = render 'shared/errors', resource: @question = form_with model: @question, local: true do |f| - = f.label :title - = f.text_field :title - = f.label :body - = f.text_area :body - = f.submit 'Ask' + div class="form-group" + div= f.label :title, class: "mb-0" + div= f.text_field :title, size: 29, class: "mb-2" + div= f.label :body, 'Body', class: "mb-0" + div= f.text_area :body, cols: 32, rows: 3 + div= f.label :files, 'Files', class: "mb-0" + div= f.file_field :files, multiple: true, class: "mb-3" + div= f.submit 'Ask' diff --git a/app/views/questions/show.html.slim b/app/views/questions/show.html.slim index 05a8929..eec6eb8 100644 --- a/app/views/questions/show.html.slim +++ b/app/views/questions/show.html.slim @@ -1,12 +1,17 @@ -div id="question_#{@question.id}" +.question = render 'question_for_show', resource: @question -h4 Answers + +h5 class="mt-3" Answers + div.answers = render @question.answers -p Your answer + .answer-errors = render 'shared/errors', resource: @answer + = form_with model: [@question, @answer], class: 'new-answer' do |f| - = f.label :body - = f.text_area :body - = f.submit 'Leave' + div= f.label :body, 'Your answer', class: "mb-0" + div= f.text_area :body, cols: 32, rows: 3 + div= f.label :files, 'Files', class: "mb-0" + div= f.file_field :files, multiple: true, direct_upload: true, class: "mb-3" + div= f.submit 'Leave' diff --git a/app/views/questions/update.js.erb b/app/views/questions/update.js.erb index a085def..02dec75 100644 --- a/app/views/questions/update.js.erb +++ b/app/views/questions/update.js.erb @@ -1,4 +1,4 @@ $('.question-errors').html('<%= render 'shared/errors', resource: @question %>') <% if !@question.errors.present? %> - $("#question_" + <%= @question.id %>).html('<%= j render 'question_for_show', resource: @question %>') + $("#question_" + <%= @question.id %>).replaceWith('<%= j render 'question_for_show', resource: @question %>') <% end %> diff --git a/app/views/shared/_answer_links.html.slim b/app/views/shared/_answer_links.html.slim index 6d20456..4cc1dda 100644 --- a/app/views/shared/_answer_links.html.slim +++ b/app/views/shared/_answer_links.html.slim @@ -1,7 +1,6 @@ -p - => link_to('Mark as the best', mark_answer_path(answer), method: :post, remote: true) \ +=> link_to('Mark as the best', mark_answer_path(answer), method: :post, remote: true) \ if current_user&.owner?(answer.question) && !answer.best? - => link_to('Edit', '#', class: 'edit-answer-links', data: { answer_id: answer.id }) \ +=> link_to('Edit', '#', class: 'edit-answer-links', data: { answer_id: answer.id }) \ if current_user&.owner?(answer) - = link_to('Delete', answer_path(answer), method: :delete, remote: true) \ += link_to('Delete', answer_path(answer), method: :delete, remote: true) \ if current_user&.owner?(answer) diff --git a/app/views/shared/_question_links.html.slim b/app/views/shared/_question_links.html.slim index 881b8bf..b45c98a 100644 --- a/app/views/shared/_question_links.html.slim +++ b/app/views/shared/_question_links.html.slim @@ -1,3 +1,2 @@ -p - => link_to 'Edit', '#', class: 'edit-question-link', data: { question_id: question.id } - = link_to 'Delete', question_path(question), method: :delete +=> link_to 'Edit', '#', class: 'edit-question-link', data: { question_id: question.id } += link_to 'Delete', question_path(question), method: :delete diff --git a/app/views/shared/_user_nav.html.slim b/app/views/shared/_user_nav.html.slim index 074341a..d3aa0ab 100644 --- a/app/views/shared/_user_nav.html.slim +++ b/app/views/shared/_user_nav.html.slim @@ -1,7 +1,6 @@ -- if current_user - p= link_to 'Log out', destroy_user_session_path, method: :delete -- else - p - = link_to 'Log in', new_user_session_path - | |  - = link_to 'Sign up', new_user_registration_path +p class="mb-0" + - if current_user + => link_to 'Log out', destroy_user_session_path, method: :delete + - else + => link_to 'Log in', new_user_session_path + = link_to 'Sign up', new_user_registration_path diff --git a/config/environments/development.rb b/config/environments/development.rb index 7e6af8d..c56a585 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -28,7 +28,7 @@ end # Store uploaded files on the local file system (see config/storage.yml for options) - config.active_storage.service = :local + config.active_storage.service = :google # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false diff --git a/config/routes.rb b/config/routes.rb index 4175820..6edefeb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,5 +8,7 @@ post :mark end end + + resources :files, shallow: true, only: %i[destroy] end end diff --git a/config/storage.yml b/config/storage.yml index d32f76e..dc031fc 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -15,11 +15,11 @@ local: # bucket: your_own_bucket # Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket +google: + service: GCS + project: questions-and-answers-190629 + credentials: <%= Rails.root.join("config/secrets/questions-and-answers-190629.json") %> + bucket: questions_and_answers_files # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) # microsoft: diff --git a/db/migrate/20190627131211_create_active_storage_tables.active_storage.rb b/db/migrate/20190627131211_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..0b2ce25 --- /dev/null +++ b/db/migrate/20190627131211_create_active_storage_tables.active_storage.rb @@ -0,0 +1,27 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 199c4e9..06a8ee7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,32 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_06_24_142356) do +ActiveRecord::Schema.define(version: 2019_06_27_131211) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + create_table "answers", force: :cascade do |t| t.text "body" t.datetime "created_at", null: false @@ -47,6 +68,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "answers", "questions" add_foreign_key "answers", "users" add_foreign_key "questions", "users" diff --git a/spec/controllers/files_controller_spec.rb b/spec/controllers/files_controller_spec.rb new file mode 100644 index 0000000..3d4b420 --- /dev/null +++ b/spec/controllers/files_controller_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe FilesController, type: :controller do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:question1) { create(:question, :with_attachments, user: user1) } + let(:question2) { create(:question, :with_attachments, user: user2) } + let(:answer1) { create(:answer, :with_attachments, question: question1, user: user2) } + let(:answer2) { create(:answer, :with_attachments, question: question2, user: user1) } + let!(:question_attachment1) { question1.files.first } + let!(:question_attachment2) { question2.files.first } + let!(:answer_attachment1) { answer1.files.first } + let!(:answer_attachment2) { answer2.files.first } + + describe "DELETE #destroy" do + context "authenticated user" do + before { login(user1) } + + it "deletes users's question attachment" do + expect { + delete :destroy, params: { id: question_attachment1 }, format: :js + }.to change(question1.files.attachments, :count).by(-1) + expect { question_attachment1.reload }.to raise_error ActiveRecord::RecordNotFound + end + + it "deletes users's answer attachment" do + expect { + delete :destroy, params: { id: answer_attachment2 }, format: :js + }.to change(answer2.files.attachments, :count).by(-1) + expect { answer_attachment2.reload }.to raise_error ActiveRecord::RecordNotFound + end + + it "tries to delete another user's question attachment" do + expect { + delete :destroy, params: { id: question_attachment2 }, format: :js + }.to_not change(question2.files.attachments, :count) + end + + it "tries to delete another user's answer attachment" do + expect { + delete :destroy, params: { id: answer_attachment1 }, format: :js + }.to_not change(answer1.files.attachments, :count) + end + + it "render destroy template after deleting of question attachment" do + delete :destroy, params: { id: question_attachment1 }, format: :js + + expect(response).to render_template :destroy + end + + it "render destroy template after deleting of answer attachment" do + delete :destroy, params: { id: answer_attachment2 }, format: :js + + expect(response).to render_template :destroy + end + end + + it "unauthenticated user tries to delete question attachment" do + expect { + delete :destroy, params: { id: question_attachment1 }, format: :js + }.to_not change(question1.files.attachments, :count) + end + + it "unauthenticated user tries to delete answer attachment" do + expect { + delete :destroy, params: { id: answer_attachment1 }, format: :js + }.to_not change(answer1.files.attachments, :count) + end + end +end diff --git a/spec/factories/answers.rb b/spec/factories/answers.rb index 6ea985c..0a80840 100644 --- a/spec/factories/answers.rb +++ b/spec/factories/answers.rb @@ -7,5 +7,9 @@ trait :invalid do body { "#{"b" * 49}" } end + + trait :with_attachments do + files { [Rack::Test::UploadedFile.new(Rails.root.join('spec/rails_helper.rb'), 'text/plain')] } + end end end diff --git a/spec/factories/questions.rb b/spec/factories/questions.rb index 525f50e..ba2910e 100644 --- a/spec/factories/questions.rb +++ b/spec/factories/questions.rb @@ -10,5 +10,9 @@ trait :invalid do title { "Title" } end + + trait :with_attachments do + files { [Rack::Test::UploadedFile.new(Rails.root.join('spec/rails_helper.rb'), 'text/plain')] } + end end end diff --git a/spec/features/answer/create_spec.rb b/spec/features/answer/create_spec.rb index e1d76a0..e6729f9 100644 --- a/spec/features/answer/create_spec.rb +++ b/spec/features/answer/create_spec.rb @@ -14,7 +14,7 @@ end scenario 'answers to a question', js: true do - fill_in 'Body', with: "#{"body" * 25}" + fill_in 'answer_body', with: "#{"body" * 25}" click_on 'Leave' expect(page).to have_content "#{"body" * 25}" @@ -26,6 +26,15 @@ expect(page).to have_content "Body can't be blank" expect(page).to have_content 'Body is too short (minimum is 50 characters)' end + + scenario 'answers to a question with attached files', js: true do + fill_in 'answer_body', with: "#{"body" * 25}" + attach_file 'Files', ["#{Rails.root}/spec/rails_helper.rb", "#{Rails.root}/spec/spec_helper.rb"] + click_on 'Leave' + + expect(page).to have_link "rails_helper.rb" + expect(page).to have_link "spec_helper.rb" + end end feature 'only authenticated user can create answers', %q{ @@ -38,10 +47,8 @@ scenario 'unauthenticated user tries to get an answer' do visit question_path(question) - within ".new-answer" do - fill_in 'Body', with: "#{"body" * 25}" - click_on 'Leave' - end + fill_in 'answer_body', with: "#{"body" * 25}" + click_on 'Leave' expect(page).to have_content 'You need to sign in or sign up before continuing.' end diff --git a/spec/features/answer/delete_attachments_spec.rb b/spec/features/answer/delete_attachments_spec.rb new file mode 100644 index 0000000..029ab3f --- /dev/null +++ b/spec/features/answer/delete_attachments_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +feature "user can delete attachments of his answer", %q{ + as an authenticated user + i'd like to be able to delete only my own answers attachments +} do + given(:user1) { create(:user) } + given(:user2) { create(:user) } + given!(:question1) { create(:question, user: user1) } + given!(:question2) { create(:question, user: user2) } + given!(:answer1) { create(:answer, + :with_attachments, + question: question1, + user: user2) } + given!(:answer2) { create(:answer, + :with_attachments, + question: question2, + user: user1) } + + scenario "unauthenticated user tries to delete answer attachments" do + visit question_path(question1) + + expect(page).to_not have_link 'Delete file' + end + + context "authenticated user" do + background do + login(user1) + end + + scenario "deletes attached files of his answer", js: true do + visit question_path(question2) + + within "#answer_#{answer2.id}" do + click_on 'Delete file' + + expect(page).to_not have_link "rails_helper.rb" + end + end + + scenario "tries to delete attached files of another user's answer" do + visit question_path(question1) + + within "#answer_#{answer1.id}" do + expect(page).to_not have_link "Delete file" + end + end + end +end diff --git a/spec/features/answer/edit_spec.rb b/spec/features/answer/edit_spec.rb index 0e5c5a8..f071a44 100644 --- a/spec/features/answer/edit_spec.rb +++ b/spec/features/answer/edit_spec.rb @@ -38,6 +38,17 @@ end end + scenario 'edits his answer with adding attached files', js: true do + within "#answer_#{answer.id}" do + click_on 'Edit' + attach_file 'Files', ["#{Rails.root}/spec/rails_helper.rb", "#{Rails.root}/spec/spec_helper.rb"] + click_on 'Update' + + expect(page).to have_link "rails_helper.rb" + expect(page).to have_link "spec_helper.rb" + end + end + scenario 'tries to edit his answer with errors', js: true do within "#answer_#{answer.id}" do click_on 'Edit' diff --git a/spec/features/question/create_spec.rb b/spec/features/question/create_spec.rb index 3708d26..df949e0 100644 --- a/spec/features/question/create_spec.rb +++ b/spec/features/question/create_spec.rb @@ -30,6 +30,16 @@ expect(page).to have_content "Title can't be blank" end + + scenario 'asks a question with attached files' do + fill_in 'Title', with: 'Test question title' + fill_in 'Body', with: "#{"body" * 25}" + attach_file 'Files', ["#{Rails.root}/spec/rails_helper.rb", "#{Rails.root}/spec/spec_helper.rb"] + click_on 'Ask' + + expect(page).to have_link "rails_helper.rb" + expect(page).to have_link "spec_helper.rb" + end end scenario 'unauthenticated user tries to ask a question' do diff --git a/spec/features/question/delete_attacments_spec.rb b/spec/features/question/delete_attacments_spec.rb new file mode 100644 index 0000000..a405442 --- /dev/null +++ b/spec/features/question/delete_attacments_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +feature "user can delete attachments of his question", %q{ + as an authenticated user + i'd like to be able to delete only my own questions attachments +} do + given(:user1) { create(:user) } + given(:user2) { create(:user) } + given!(:question1) { create(:question, :with_attachments, user: user1) } + given!(:question2) { create(:question, :with_attachments, user: user2) } + + scenario "unauthenticated user tries to delete question attachments" do + visit question_path(question1) + + expect(page).to_not have_link 'Delete file' + end + + context "authenticated user" do + background do + login(user1) + end + + scenario "deletes attached files of his question", js: true do + visit question_path(question1) + + within "#question_#{question1.id}" do + click_on 'Delete file' + + expect(page).to_not have_link "rails_helper.rb" + end + end + + scenario "tries to delete attached files of another user's question" do + visit question_path(question2) + + within "#question_#{question2.id}" do + expect(page).to_not have_link "Delete file" + end + end + end +end diff --git a/spec/features/question/edit_spec.rb b/spec/features/question/edit_spec.rb index 6537234..270561c 100644 --- a/spec/features/question/edit_spec.rb +++ b/spec/features/question/edit_spec.rb @@ -40,6 +40,19 @@ end end + scenario 'edits his question with adding attached files', js: true do + visit question_path(question1) + + within "#question_#{question1.id}" do + click_on 'Edit' + attach_file 'Files', ["#{Rails.root}/spec/rails_helper.rb", "#{Rails.root}/spec/spec_helper.rb"] + click_on 'Update' + + expect(page).to have_link "rails_helper.rb" + expect(page).to have_link "spec_helper.rb" + end + end + scenario 'tries to edit his question with errors', js: true do visit question_path(question1) diff --git a/spec/models/answer_spec.rb b/spec/models/answer_spec.rb index 348157e..2ce8639 100644 --- a/spec/models/answer_spec.rb +++ b/spec/models/answer_spec.rb @@ -3,9 +3,12 @@ RSpec.describe Answer, type: :model do it { should belong_to :question } it { should belong_to :user } + it { should validate_presence_of :body } it { should validate_length_of(:body).is_at_least(50) } + it { should have_many(:files_attachments) } + let(:user) { create(:user) } let(:question) { create(:question, user: user) } let(:answer1) { create(:answer, question: question, user: user) } diff --git a/spec/models/question_spec.rb b/spec/models/question_spec.rb index fc42938..bb00cd2 100644 --- a/spec/models/question_spec.rb +++ b/spec/models/question_spec.rb @@ -12,4 +12,6 @@ it { should validate_length_of(:body).is_at_least(50) } it { should validate_uniqueness_of(:title).case_insensitive } + + it { should have_many(:files_attachments) } end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a56e492..4842a3f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,9 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + config.after(:all) do + FileUtils.rm_rf("#{Rails.root}/tmp/storage") + end end Shoulda::Matchers.configure do |config|