Merge pull request #1026 from Retrospring/feature/pinned-answers
This commit is contained in:
commit
3122d31f55
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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")
|
|
@ -0,0 +1,2 @@
|
|||
= turbo_stream.update("ab-pin-#{answer.id}") do
|
||||
= render "actions/pin", answer:
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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…"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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|
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue