Merge pull request #1026 from Retrospring/feature/pinned-answers

This commit is contained in:
Karina Kwiatek 2023-02-12 21:25:02 +01:00 committed by GitHub
commit 3122d31f55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 325 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")

View File

@ -0,0 +1,2 @@
= turbo_stream.update("ab-pin-#{answer.id}") do
= render "actions/pin", answer:

View File

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

View File

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

View File

@ -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."

View File

@ -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…"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}
]
}