diff --git a/app/assets/stylesheets/components/_answerbox.scss b/app/assets/stylesheets/components/_answerbox.scss index 2eae714c..adb1b732 100644 --- a/app/assets/stylesheets/components/_answerbox.scss +++ b/app/assets/stylesheets/components/_answerbox.scss @@ -82,11 +82,22 @@ } } + &__pinned { + display: none; + } + .card-body { padding-bottom: .6rem; } } +#pinned-answers { + + .answerbox__pinned { + display: inline; + } +} + body:not(.cap-web-share) { [name="ab-share"] { display: none; diff --git a/app/controllers/answer_controller.rb b/app/controllers/answer_controller.rb index b63be49e..d5e70881 100644 --- a/app/controllers/answer_controller.rb +++ b/app/controllers/answer_controller.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class AnswerController < ApplicationController + before_action :authenticate_user!, only: %i[pin unpin] + + include TurboStreamable + + turbo_stream_actions :pin, :unpin + def show @answer = Answer.includes(comments: %i[user smiles], question: [:user], smiles: [:user]).find(params[:id]) @display_all = true @@ -16,4 +22,34 @@ class AnswerController < ApplicationController notif.update_all(new: false) unless notif.empty? end end + + def pin + answer = Answer.includes(:user).find(params[:id]) + UseCase::Answer::Pin.call(user: current_user, answer:) + + respond_to do |format| + format.html { redirect_to(user_path(username: current_user.screen_name)) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }), + render_toast(t(".success")) + ] + end + end + end + + def unpin + answer = Answer.includes(:user).find(params[:id]) + UseCase::Answer::Unpin.call(user: current_user, answer:) + + respond_to do |format| + format.html { redirect_to(user_path(username: current_user.screen_name)) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }), + render_toast(t(".success")) + ] + end + end + end end diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index 17926429..1963b496 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -7,6 +7,7 @@ class UserController < ApplicationController def show @answers = @user.cursored_answers(last_id: params[:last_id]) + @pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10) @answers_last_id = @answers.map(&:id).min @more_data_available = !@user.cursored_answers(last_id: @answers_last_id, size: 1).count.zero? diff --git a/app/models/answer.rb b/app/models/answer.rb index 91a6c956..d1e3e645 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -14,6 +14,8 @@ class Answer < ApplicationRecord validates :question_id, uniqueness: { scope: :user_id } # rubocop:enable Rails/UniqueValidationWithoutIndex + scope :pinned, -> { where.not(pinned_at: nil) } + SHORT_ANSWER_MAX_LENGTH = 640 # rubocop:disable Rails/SkipsModelValidations @@ -56,4 +58,6 @@ class Answer < ApplicationRecord end def long? = content.length > SHORT_ANSWER_MAX_LENGTH + + def pinned? = pinned_at.present? end diff --git a/app/policies/answer_policy.rb b/app/policies/answer_policy.rb new file mode 100644 index 00000000..c012c920 --- /dev/null +++ b/app/policies/answer_policy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AnswerPolicy + attr_reader :user, :answer + + def initialize(user, answer) + @user = user + @answer = answer + end + + def pin? = answer.user == user + + def unpin? = answer.user == user +end diff --git a/app/views/actions/_answer.html.haml b/app/views/actions/_answer.html.haml index 1fcfdca8..de81c031 100644 --- a/app/views/actions/_answer.html.haml +++ b/app/views/actions/_answer.html.haml @@ -16,6 +16,8 @@ %a.dropdown-item{ href: "#", data: { a_id: answer.id, action: "ab-report" } } %i.fa.fa-fw.fa-exclamation-triangle = t("voc.report") + - else + = render "actions/pin", answer: - if current_user.admin? %a.dropdown-item{ href: rails_admin_path_for_resource(answer), target: "_blank" } %i.fa.fa-fw.fa-gears diff --git a/app/views/actions/_pin.html.haml b/app/views/actions/_pin.html.haml new file mode 100644 index 00000000..41a1fa59 --- /dev/null +++ b/app/views/actions/_pin.html.haml @@ -0,0 +1,14 @@ +- if answer.pinned? + = button_to unpin_answer_path(username: current_user.screen_name, id: answer.id), + class: "dropdown-item", + method: :delete, + form: { id: "ab-pin-#{answer.id}", data: { turbo_stream: true } } do + %i.fa.fa-fw.fa-thumbtack + = t(".unpin") +- else + = button_to pin_answer_path(username: current_user.screen_name, id: answer.id), + class: "dropdown-item", + method: :post, + form: { id: "ab-pin-#{answer.id}", data: { turbo_stream: true } } do + %i.fa.fa-fw.fa-thumbtack + = t(".pin") diff --git a/app/views/answer/pin.turbo_stream.haml b/app/views/answer/pin.turbo_stream.haml new file mode 100644 index 00000000..bc2c107a --- /dev/null +++ b/app/views/answer/pin.turbo_stream.haml @@ -0,0 +1,2 @@ += turbo_stream.update("ab-pin-#{answer.id}") do + = render "actions/pin", answer: diff --git a/app/views/application/_answerbox.html.haml b/app/views/application/_answerbox.html.haml index fe27f953..20b88963 100644 --- a/app/views/application/_answerbox.html.haml +++ b/app/views/application/_answerbox.html.haml @@ -27,6 +27,11 @@ .col-md-6.text-start.text-muted %i.fa.fa-clock-o = link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id), class: "answerbox__permalink") + - if a.pinned_at.present? + %span.answerbox__pinned + · + %i.fa.fa-thumbtack + = t(".pinned") .col-md-6.d-md-flex.answerbox__actions = render "answerbox/actions", a: a, display_all: display_all .card-footer{ id: "ab-comments-section-#{a.id}", class: display_all.nil? ? "d-none" : nil } diff --git a/app/views/user/show.html.haml b/app/views/user/show.html.haml index ac6452a0..25240a33 100644 --- a/app/views/user/show.html.haml +++ b/app/views/user/show.html.haml @@ -1,7 +1,11 @@ - unless @user.banned? + #pinned-answers + - @pinned_answers.each do |a| + = render "answerbox", a: + #answers - @answers.each do |a| - = render 'answerbox', a: a + = render "answerbox", a: - if @more_data_available .d-flex.justify-content-center.justify-content-sm-start#paginator diff --git a/config/locales/controllers.en.yml b/config/locales/controllers.en.yml index d94570f8..ff41c0c6 100644 --- a/config/locales/controllers.en.yml +++ b/config/locales/controllers.en.yml @@ -26,6 +26,8 @@ en: destroy: nopriv: "You cannot delete other people's answers." success: "Successfully deleted answer." + pin: + success: "Successfully pinned answer." comment: create: invalid: "Your comment is too long." @@ -210,3 +212,8 @@ en: timeline: public: title: "Public Timeline" + answer: + pin: + success: "This answer will now appear at the top of your profile." + unpin: + success: "This answer will no longer be pinned to the top of your profile." diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index c944b72c..351e2c03 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -75,6 +75,9 @@ en: return: "Return to Inbox" comment: view_smiles: "View comment smiles" + pin: + pin: "Pin to Profile" + unpin: "Unpin from Profile" share: twitter: "Share on Twitter" tumblr: "Share on Tumblr" @@ -123,6 +126,7 @@ en: read: "Read the entire answer" answered: "%{hide} %{user}" # resolves into "Answered by %{user}" hide: "Answered by" + pinned: "Pinned" questionbox: title: "Ask something!" placeholder: "Type your question here…" diff --git a/config/routes.rb b/config/routes.rb index 74ebc5b0..744a8d83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -146,6 +146,8 @@ Rails.application.routes.draw do get "/user/:username", to: "user#show" get "/@:username", to: "user#show", as: :user get "/@:username/a/:id", to: "answer#show", as: :answer + post "/@:username/a/:id/pin", to: "answer#pin", as: :pin_answer + delete "/@:username/a/:id/pin", to: "answer#unpin", as: :unpin_answer get "/@:username/q/:id", to: "question#show", as: :question get "/@:username/followers", to: "user#followers", as: :show_user_followers get "/@:username/followings", to: "user#followings", as: :show_user_followings diff --git a/db/migrate/20230128233136_add_pinned_at_to_answers.rb b/db/migrate/20230128233136_add_pinned_at_to_answers.rb new file mode 100644 index 00000000..8501ba1e --- /dev/null +++ b/db/migrate/20230128233136_add_pinned_at_to_answers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddPinnedAtToAnswers < ActiveRecord::Migration[6.1] + def change + add_column :answers, :pinned_at, :timestamp + add_index :answers, %i[user_id pinned_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index 0d70a9eb..33418a33 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -48,8 +48,10 @@ ActiveRecord::Schema.define(version: 2023_02_12_181044) do t.datetime "created_at" t.datetime "updated_at" t.integer "smile_count", default: 0, null: false + t.datetime "pinned_at" t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id", "created_at"], name: "index_answers_on_user_id_and_created_at" + t.index ["user_id", "pinned_at"], name: "index_answers_on_user_id_and_pinned_at" end create_table "appendables", force: :cascade do |t| diff --git a/lib/use_case/answer/pin.rb b/lib/use_case/answer/pin.rb new file mode 100644 index 00000000..c790663c --- /dev/null +++ b/lib/use_case/answer/pin.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module UseCase + module Answer + class Pin < UseCase::Base + option :user, type: Types.Instance(::User) + option :answer, type: Types.Instance(::Answer) + + def call + authorize!(:pin, user, answer) + check_unpinned! + + answer.pinned_at = Time.now.utc + answer.save! + + { + status: 200, + resource: answer, + } + end + + private + + def check_unpinned! + raise ::Errors::BadRequest if answer.pinned_at.present? + end + end + end +end diff --git a/lib/use_case/answer/unpin.rb b/lib/use_case/answer/unpin.rb new file mode 100644 index 00000000..8f12742e --- /dev/null +++ b/lib/use_case/answer/unpin.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module UseCase + module Answer + class Unpin < UseCase::Base + option :user, type: Types.Instance(::User) + option :answer, type: Types.Instance(::Answer) + + def call + authorize!(:unpin, user, answer) + check_pinned! + + answer.pinned_at = nil + answer.save! + + { + status: 200, + resource: nil, + } + end + + private + + def check_pinned! + raise ::Errors::BadRequest if answer.pinned_at.nil? + end + end + end +end diff --git a/lib/use_case/base.rb b/lib/use_case/base.rb index e35585c2..7ff23bbf 100644 --- a/lib/use_case/base.rb +++ b/lib/use_case/base.rb @@ -9,5 +9,11 @@ module UseCase def self.call(...) = new(...).call def call = raise NotImplementedError + + private + + def authorize!(verb, user, record, error_class: Errors::NotAuthorized) + raise error_class unless Pundit.policy!(user, record).public_send("#{verb}?") + end end end diff --git a/spec/controllers/answer_controller_spec.rb b/spec/controllers/answer_controller_spec.rb index b2c39fd5..108b3969 100644 --- a/spec/controllers/answer_controller_spec.rb +++ b/spec/controllers/answer_controller_spec.rb @@ -2,7 +2,9 @@ require "rails_helper" -describe AnswerController do +describe AnswerController, type: :controller do + include ActiveSupport::Testing::TimeHelpers + let(:user) do FactoryBot.create :user, otp_module: :disabled, @@ -39,4 +41,60 @@ describe AnswerController do end end end + + describe "#pin" do + subject { post :pin, params: { username: user.screen_name, id: answer.id }, format: :turbo_stream } + + context "user signed in" do + before(:each) { sign_in user } + + it "pins the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.to change { answer.reload.pinned_at }.from(nil).to(Time.at(1603290950).utc) + expect(response.body).to include("turbo-stream action=\"update\" target=\"ab-pin-#{answer.id}\"") + end + end + end + + context "other user signed in" do + let(:other_user) { FactoryBot.create(:user) } + + before(:each) { sign_in other_user } + + it "does not pin the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.not_to(change { answer.reload.pinned_at }) + end + end + end + end + + describe "#unpin" do + subject { delete :unpin, params: { username: user.screen_name, id: answer.id }, format: :turbo_stream } + + context "user signed in" do + before(:each) do + sign_in user + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "unpins the answer" do + expect { subject }.to change { answer.reload.pinned_at }.from(Time.at(1603290950).utc).to(nil) + expect(response.body).to include("turbo-stream action=\"update\" target=\"ab-pin-#{answer.id}\"") + end + end + + context "other user signed in" do + let(:other_user) { FactoryBot.create(:user) } + + before(:each) do + sign_in other_user + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "does not unpin the answer" do + expect { subject }.not_to(change { answer.reload.pinned_at }) + end + end + end end diff --git a/spec/lib/use_case/answer/pin_spec.rb b/spec/lib/use_case/answer/pin_spec.rb new file mode 100644 index 00000000..26fa29d4 --- /dev/null +++ b/spec/lib/use_case/answer/pin_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UseCase::Answer::Pin do + include ActiveSupport::Testing::TimeHelpers + + subject { UseCase::Answer::Pin.call(user:, answer:) } + + context "answer exists" do + let(:answer) { FactoryBot.create(:answer, user: FactoryBot.create(:user)) } + + context "as answer owner" do + let(:user) { answer.user } + + it "pins the answer" do + travel_to(Time.at(1603290950).utc) do + expect { subject }.to change { answer.pinned_at }.from(nil).to(Time.at(1603290950).utc) + end + end + + context "answer is already pinned" do + before do + answer.update!(pinned_at: Time.at(1603290950).utc) + end + + it "raises an error" do + expect { subject }.to raise_error(Errors::BadRequest) + expect(answer.reload.pinned_at).to eq(Time.at(1603290950).utc) + end + end + end + + context "as other user" do + let(:user) { FactoryBot.create(:user) } + + it "does not pin the answer" do + expect { subject }.to raise_error(Errors::NotAuthorized) + expect(answer.reload.pinned_at).to eq(nil) + end + end + end +end diff --git a/spec/lib/use_case/answer/unpin_spec.rb b/spec/lib/use_case/answer/unpin_spec.rb new file mode 100644 index 00000000..f4d7a3cb --- /dev/null +++ b/spec/lib/use_case/answer/unpin_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UseCase::Answer::Unpin do + include ActiveSupport::Testing::TimeHelpers + + subject { UseCase::Answer::Unpin.call(user:, answer:) } + + context "answer exists" do + let(:pinned_at) { Time.at(1603290950).utc } + let(:answer) { FactoryBot.create(:answer, user: FactoryBot.create(:user), pinned_at:) } + + context "as answer owner" do + let(:user) { answer.user } + + it "unpins the answer" do + expect { subject }.to change { answer.pinned_at }.from(pinned_at).to(nil) + end + + context "answer is already unpinned" do + let(:pinned_at) { nil } + + it "raises an error" do + expect { subject }.to raise_error(Errors::BadRequest) + expect(answer.reload.pinned_at).to eq(nil) + end + end + end + + context "as other user" do + let(:user) { FactoryBot.create(:user) } + + it "does not unpin the answer" do + expect { subject }.to raise_error(Errors::NotAuthorized) + expect(answer.reload.pinned_at).to eq(pinned_at) + end + end + end +end diff --git a/spec/lib/use_case/data_export/answers_spec.rb b/spec/lib/use_case/data_export/answers_spec.rb index 3fa24f8e..99335a91 100644 --- a/spec/lib/use_case/data_export/answers_spec.rb +++ b/spec/lib/use_case/data_export/answers_spec.rb @@ -32,7 +32,8 @@ describe UseCase::DataExport::Answers, :data_export do user_id: user.id, created_at: "2022-12-10T13:37:42.000Z", updated_at: "2022-12-10T13:37:42.000Z", - smile_count: 0 + smile_count: 0, + pinned_at: nil, } ] }