diff --git a/app/controllers/question_answer/votes_controller.rb b/app/controllers/question_answer/votes_controller.rb
new file mode 100644
index 0000000..1a604f9
--- /dev/null
+++ b/app/controllers/question_answer/votes_controller.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ class VotesController < ApplicationController
+ before_action :ensure_logged_in
+ before_action :find_vote_post
+ before_action :find_vote_user, only: [:create, :destroy]
+ before_action :ensure_qa_enabled, only: [:create, :destroy]
+ before_action :ensure_can_act, only: [:create, :destroy]
+
+ def create
+ unless Topic.qa_can_vote(@post.topic, @user)
+ raise Discourse::InvalidAccess.new(
+ nil,
+ nil,
+ custom_message: 'vote.error.user_over_limit'
+ )
+ end
+
+ unless @post.qa_can_vote(@user.id)
+ raise Discourse::InvalidAccess.new(
+ nil,
+ nil,
+ custom_message: 'vote.error.one_vote_per_post'
+ )
+ end
+
+ if QuestionAnswer::Vote.vote(@post, @user, vote_args)
+ render json: success_json.merge(
+ qa_votes: Topic.qa_votes(@post.topic, @user),
+ qa_can_vote: Topic.qa_can_vote(@post.topic, @user)
+ )
+ else
+ render json: failed_json, status: 422
+ end
+ end
+
+ def destroy
+ if Topic.qa_votes(@post.topic, @user).length.zero?
+ raise(
+ Discourse::InvalidAccess.new,
+ I18n.t('vote.error.user_has_not_voted')
+ )
+ end
+
+ if QuestionAnswer::Vote.vote(@post, @user, vote_args)
+ render json: success_json.merge(
+ qa_votes: Topic.qa_votes(@post.topic, @user),
+ qa_can_vote: Topic.qa_can_vote(@post.topic, @user)
+ )
+ else
+ render json: failed_json, status: 422
+ end
+ end
+
+ def voters
+ voters = []
+
+ if @post.qa_voted.any?
+ @post.qa_voted.each do |user_id|
+ if (user = User.find_by(id: user_id))
+ voters.push(QuestionAnswer::Voter.new(user))
+ end
+ end
+ end
+
+ render_json_dump(
+ voters: serialize_data(voters, QuestionAnswer::VoterSerializer)
+ )
+ end
+
+ private
+
+ def vote_params
+ params.require(:vote).permit(:post_id, :user_id, :direction)
+ end
+
+ def vote_args
+ {
+ direction: vote_params[:direction],
+ action: action_name
+ }
+ end
+
+ def find_vote_post
+ if params[:vote].present?
+ post_id = vote_params[:post_id]
+ else
+ params.require(:post_id)
+ post_id = params[:post_id]
+ end
+
+ @post = Post.find_by(id: post_id)
+
+ raise Discourse::NotFound unless @post
+ end
+
+ def find_vote_user
+ @user = User.find_by(id: vote_params[:user_id])
+
+ raise Discourse::NotFound unless @user
+ end
+
+ def ensure_qa_enabled
+ Topic.qa_enabled(@post.topic)
+ end
+
+ def ensure_can_act
+ if Topic.qa_votes(@post.topic, @user).present?
+ if action_name == QuestionAnswer::Vote::CREATE
+ raise(
+ Discourse::InvalidAccess.new,
+ I18n.t('vote.error.alread_voted')
+ )
+ end
+
+ can_undo = QuestionAnswer::Vote.can_undo(@post, @user)
+
+ if action_name == QuestionAnswer::Vote::DESTROY && !can_undo
+ window = SiteSetting.qa_undo_vote_action_window
+ msg = I18n.t('vote.error.undo_vote_action_window', minutes: window)
+
+ raise Discourse::InvalidAccess.new, msg
+ end
+ elsif action_name == QuestionAnswer::Vote::DESTROY
+ raise(
+ Discourse::InvalidAccess.new,
+ I18n.t('vote.error.user_has_not_voted')
+ )
+ end
+ end
+ end
+end
diff --git a/app/lib/question_answer/category_custom_field_extension.rb b/app/lib/question_answer/category_custom_field_extension.rb
new file mode 100644
index 0000000..e0d7098
--- /dev/null
+++ b/app/lib/question_answer/category_custom_field_extension.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module CategoryCustomFieldExtension
+ def self.included(base)
+ base.after_commit :update_post_order, if: :qa_enabled_changed
+ end
+
+ def qa_enabled_changed
+ name == 'qa_enabled'
+ end
+
+ def update_post_order
+ Jobs.enqueue(:update_post_order, category_id: category_id)
+ end
+ end
+end
diff --git a/app/lib/question_answer/category_extension.rb b/app/lib/question_answer/category_extension.rb
new file mode 100644
index 0000000..a96bb7a
--- /dev/null
+++ b/app/lib/question_answer/category_extension.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module CategoryExtension
+ def qa_cast(key)
+ ActiveModel::Type::Boolean.new.cast(custom_fields[key]) || false
+ end
+
+ %w[
+ qa_enabled
+ qa_one_to_many
+ qa_disable_like_on_answers
+ qa_disable_like_on_questions
+ qa_disable_like_on_comments
+ ].each do |key|
+ define_method(key.to_sym) { qa_cast(key) }
+ end
+ end
+end
diff --git a/app/lib/question_answer/guardian_extension.rb b/app/lib/question_answer/guardian_extension.rb
new file mode 100644
index 0000000..43f8c36
--- /dev/null
+++ b/app/lib/question_answer/guardian_extension.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module GuardianExtension
+ def can_create_post_on_topic?(topic)
+ post = self.try(:post_opts) || {}
+ category = topic.category
+
+ if category.present? &&
+ category.qa_enabled &&
+ category.qa_one_to_many &&
+ post.present? &&
+ !post[:reply_to_post_number]
+
+ return @user.id == topic.user_id
+ end
+
+ super(topic)
+ end
+ end
+end
diff --git a/app/lib/question_answer/post_action_type_extension.rb b/app/lib/question_answer/post_action_type_extension.rb
new file mode 100644
index 0000000..69d52df
--- /dev/null
+++ b/app/lib/question_answer/post_action_type_extension.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module PostActionTypeExtension
+ def public_types
+ @public_types ||= super.except(:vote)
+ end
+ end
+end
diff --git a/app/lib/question_answer/post_creator_extension.rb b/app/lib/question_answer/post_creator_extension.rb
new file mode 100644
index 0000000..167b887
--- /dev/null
+++ b/app/lib/question_answer/post_creator_extension.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module PostCreatorExtension
+ def valid?
+ guardian.post_opts = @opts
+ super
+ end
+ end
+end
diff --git a/app/lib/question_answer/post_extension.rb b/app/lib/question_answer/post_extension.rb
new file mode 100644
index 0000000..3f82ed5
--- /dev/null
+++ b/app/lib/question_answer/post_extension.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module PostExtension
+ def self.included(base)
+ base.ignored_columns = %w[vote_count]
+ base.after_create :qa_update_vote_order, if: :qa_enabled
+ end
+
+ def qa_vote_count
+ if vote_count = custom_fields['vote_count']
+ [*vote_count].first.to_i
+ else
+ 0
+ end
+ end
+
+ def qa_voted
+ if custom_fields['voted'].present?
+ [*custom_fields['voted']].map(&:to_i)
+ else
+ []
+ end
+ end
+
+ def qa_vote_history
+ if custom_fields['vote_history'].present?
+ [*custom_fields['vote_history']]
+ else
+ []
+ end
+ end
+
+ def qa_enabled
+ ::Topic.qa_enabled(topic)
+ end
+
+ def qa_update_vote_order
+ ::Topic.qa_update_vote_order(topic_id)
+ end
+
+ def qa_last_voted(user_id)
+ user_votes = qa_vote_history.select do |v|
+ v['user_id'].to_i == user_id && v['action'] == 'create'
+ end
+
+ return unless user_votes.any?
+
+ user_votes
+ .max_by { |v| v['created_at'].to_datetime.to_i }['created_at']
+ .to_datetime
+ end
+
+ def qa_can_vote(user_id)
+ SiteSetting.qa_tl_allow_multiple_votes_per_post ||
+ !qa_voted.include?(user_id)
+ end
+ end
+end
diff --git a/app/lib/question_answer/post_serializer_extension.rb b/app/lib/question_answer/post_serializer_extension.rb
new file mode 100644
index 0000000..da7ec04
--- /dev/null
+++ b/app/lib/question_answer/post_serializer_extension.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module PostSerializerExtension
+ def actions_summary
+ summaries = super.reject { |s| s[:id] == PostActionType.types[:vote] }
+
+ return summaries unless object.qa_enabled
+
+ user = scope.current_user
+ summary = {
+ id: PostActionType.types[:vote],
+ count: object.qa_vote_count
+ }
+
+ if user
+ voted = object.qa_voted.include?(user.id)
+
+ if voted
+ summary[:acted] = true
+ summary[:can_undo] = QuestionAnswer::Vote.can_undo(object, user)
+ else
+ summary[:can_act] = true
+ end
+ end
+
+ summary.delete(:count) if summary[:count].zero?
+
+ if summary[:can_act] || summary[:count]
+ summaries + [summary]
+ else
+ summaries
+ end
+ end
+
+ def qa_vote_count
+ object.qa_vote_count
+ end
+
+ def qa_voted
+ object.qa_voted
+ end
+
+ def qa_enabled
+ object.qa_enabled
+ end
+
+ def last_answerer
+ object.topic.last_answerer
+ end
+
+ def include_last_answerer?
+ object.qa_enabled
+ end
+
+ def last_answered_at
+ object.topic.last_answered_at
+ end
+
+ def include_last_answered_at?
+ object.qa_enabled
+ end
+
+ def answer_count
+ object.topic.answer_count
+ end
+
+ def include_answer_count?
+ object.qa_enabled
+ end
+
+ def last_answer_post_number
+ object.topic.last_answer_post_number
+ end
+
+ def include_last_answer_post_number?
+ object.qa_enabled
+ end
+ end
+end
diff --git a/app/lib/question_answer/topic_extension.rb b/app/lib/question_answer/topic_extension.rb
new file mode 100644
index 0000000..1d9f02e
--- /dev/null
+++ b/app/lib/question_answer/topic_extension.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module TopicExtension
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ def reload(options = nil)
+ @answers = nil
+ @comments = nil
+ @last_answerer = nil
+ super(options)
+ end
+
+ def answers
+ @answers ||= begin
+ posts
+ .where(reply_to_post_number: nil)
+ .order('created_at ASC')
+ end
+ end
+
+ def comments
+ @comments ||= begin
+ posts
+ .where.not(reply_to_post_number: nil)
+ .order('created_at ASC')
+ end
+ end
+
+ def answer_count
+ answers.count - 1 ## minus first post
+ end
+
+ def comment_count
+ comments.count
+ end
+
+ def last_answered_at
+ return unless answers.any?
+
+ answers.last[:created_at]
+ end
+
+ def last_commented_on
+ return unless comments.any?
+
+ comments.last[:created_at]
+ end
+
+ def last_answer_post_number
+ return unless answers.any?
+
+ answers.last[:post_number]
+ end
+
+ def last_answerer
+ return unless answers.any?
+
+ @last_answerer ||= User.find(answers.last[:user_id])
+ end
+
+ # class methods
+ module ClassMethods
+ def qa_can_vote(topic, user)
+ return false if user.blank? || !SiteSetting.qa_enabled
+
+ topic_vote_count = qa_votes(topic, user).length
+
+ if topic_vote_count.positive? && !SiteSetting.qa_trust_level_vote_limits
+ return false
+ end
+
+ trust_level = user.trust_level
+
+ return false if trust_level.zero?
+
+ topic_vote_limit = SiteSetting.send("qa_tl#{trust_level}_vote_limit")
+ topic_vote_limit.to_i > topic_vote_count
+ end
+
+ # rename to something like qa_user_votes?
+ def qa_votes(topic, user)
+ return nil if !user || !SiteSetting.qa_enabled
+
+ PostCustomField.where(post_id: topic.posts.map(&:id),
+ name: 'voted',
+ value: user.id).pluck(:post_id)
+ end
+
+ def qa_enabled(topic)
+ return false unless SiteSetting.qa_enabled
+
+ return false if !topic || topic&.is_category_topic?
+
+ tags = topic.tags.map(&:name)
+ has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty?
+ is_qa_category = topic.category.present? && topic.category.qa_enabled
+ is_qa_subtype = topic.subtype == 'question'
+
+ has_qa_tag || is_qa_category || is_qa_subtype
+ end
+
+ def qa_update_vote_order(topic_id)
+ return unless SiteSetting.qa_enabled
+
+ posts = Post.where(topic_id: topic_id)
+
+ posts.where(post_number: 1).update(sort_order: 1)
+
+ answers = begin
+ posts
+ .where(reply_to_post_number: nil)
+ .where.not(post_number: 1)
+ .order("(
+ SELECT COALESCE ((
+ SELECT value::integer FROM post_custom_fields
+ WHERE post_id = posts.id AND name = 'vote_count'
+ ), 0)
+ ) DESC, post_number ASC")
+ end
+
+ count = 2
+
+ answers.each do |a|
+ a.update(sort_order: count)
+
+ comments = begin
+ posts
+ .where(reply_to_post_number: a.post_number)
+ .order('post_number ASC')
+ end
+
+ if comments.any?
+ comments.each do |c|
+ count += 1
+ c.update(sort_order: count)
+ end
+ else
+ count += 1
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/lib/question_answer/topic_list_item_serializer_extension.rb b/app/lib/question_answer/topic_list_item_serializer_extension.rb
new file mode 100644
index 0000000..e9bb407
--- /dev/null
+++ b/app/lib/question_answer/topic_list_item_serializer_extension.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module TopicListItemSerializerExtension
+ def self.included(base)
+ base.attributes :qa_enabled,
+ :answer_count
+ end
+
+ def qa_enabled
+ true
+ end
+
+ def include_qa_enabled?
+ Topic.qa_enabled object
+ end
+
+ def answer_count
+ object.answer_count
+ end
+
+ def include_answer_count?
+ include_qa_enabled?
+ end
+ end
+end
diff --git a/app/lib/question_answer/topic_view_extension.rb b/app/lib/question_answer/topic_view_extension.rb
new file mode 100644
index 0000000..2e04599
--- /dev/null
+++ b/app/lib/question_answer/topic_view_extension.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module TopicViewExtension
+ def qa_enabled
+ Topic.qa_enabled(@topic)
+ end
+ end
+end
diff --git a/app/lib/question_answer/topic_view_serializer_extension.rb b/app/lib/question_answer/topic_view_serializer_extension.rb
new file mode 100644
index 0000000..3060455
--- /dev/null
+++ b/app/lib/question_answer/topic_view_serializer_extension.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ module TopicViewSerializerExtension
+ def self.included(base)
+ base.attributes(
+ :qa_enabled,
+ :qa_votes,
+ :qa_can_vote,
+ :last_answered_at,
+ :last_commented_on,
+ :answer_count,
+ :comment_count,
+ :last_answer_post_number,
+ :last_answerer
+ )
+ end
+
+ def qa_enabled
+ object.qa_enabled
+ end
+
+ def qa_votes
+ Topic.qa_votes(object.topic, scope.current_user)
+ end
+
+ def qa_can_vote
+ Topic.qa_can_vote(object.topic, scope.current_user)
+ end
+
+ def last_answered_at
+ object.topic.last_answered_at
+ end
+
+ def include_last_answered_at?
+ qa_enabled
+ end
+
+ def last_commented_on
+ object.topic.last_commented_on
+ end
+
+ def include_last_commented_on?
+ qa_enabled
+ end
+
+ def answer_count
+ object.topic.answer_count
+ end
+
+ def include_answer_count?
+ qa_enabled
+ end
+
+ def comment_count
+ object.topic.comment_count
+ end
+
+ def include_comment_count?
+ qa_enabled
+ end
+
+ def last_answer_post_number
+ object.topic.last_answer_post_number
+ end
+
+ def include_last_answer_post_number?
+ qa_enabled
+ end
+
+ def last_answerer
+ BasicUserSerializer.new(
+ object.topic.last_answerer,
+ scope: scope,
+ root: false
+ )
+ end
+
+ def include_last_answerer?
+ qa_enabled
+ end
+ end
+end
diff --git a/app/lib/question_answer/vote.rb b/app/lib/question_answer/vote.rb
new file mode 100644
index 0000000..989d62e
--- /dev/null
+++ b/app/lib/question_answer/vote.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ class Vote
+ CREATE = 'create'
+ DESTROY = 'destroy'
+ UP = 'up'
+ DOWN = 'down'
+
+ def self.vote(post, user, args)
+ modifier = 0
+
+ voted = post.qa_voted
+
+ if args[:direction] == UP
+ if args[:action] == CREATE
+ voted.push(user.id)
+ modifier = 1
+ elsif args[:action] == DESTROY
+ modifier = 0
+ voted.delete_if do |user_id|
+ if user_id == user.id
+ modifier -= 1
+ true
+ end
+ end
+ end
+ end
+
+ post.custom_fields['vote_count'] = post.qa_vote_count + modifier
+ post.custom_fields['voted'] = voted
+
+ votes = post.qa_vote_history
+
+ votes.push(
+ direction: args[:direction],
+ action: args[:action],
+ user_id: user.id,
+ created_at: Time.now
+ )
+
+ post.custom_fields['vote_history'] = votes
+
+ if post.save_custom_fields(true)
+ Topic.qa_update_vote_order(post.topic)
+ post.publish_change_to_clients! :acted
+ true
+ else
+ false
+ end
+ end
+
+ def self.can_undo(post, user)
+ window = SiteSetting.qa_undo_vote_action_window.to_i
+ window.zero? || post.qa_last_voted(user.id).to_i > window.minutes.ago.to_i
+ end
+ end
+end
diff --git a/app/lib/question_answer/voter.rb b/app/lib/question_answer/voter.rb
new file mode 100644
index 0000000..1f0355a
--- /dev/null
+++ b/app/lib/question_answer/voter.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ Voter = Struct.new(:user)
+end
diff --git a/app/serializers/question_answer/voter_serializer.rb b/app/serializers/question_answer/voter_serializer.rb
new file mode 100644
index 0000000..ed3b333
--- /dev/null
+++ b/app/serializers/question_answer/voter_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ class VoterSerializer < PostActionUserSerializer
+ def post_url
+ nil
+ end
+ end
+end
diff --git a/assets/javascripts/discourse/components/qa-topic-tip.js.es6 b/assets/javascripts/discourse/components/qa-topic-tip.js.es6
index f7e1297..01cab19 100644
--- a/assets/javascripts/discourse/components/qa-topic-tip.js.es6
+++ b/assets/javascripts/discourse/components/qa-topic-tip.js.es6
@@ -1,36 +1,35 @@
import { cookAsync } from "discourse/lib/text";
export default Ember.Component.extend({
- classNames: 'qa-topic-tip',
+ classNames: "qa-topic-tip",
didInsertElement() {
- Ember.$(document).on('click', Ember.run.bind(this, this.documentClick));
+ Ember.$(document).on("click", Ember.run.bind(this, this.documentClick));
- let rawDetails = I18n.t(this.get('details'), this.get('detailsOpts'));
+ let rawDetails = I18n.t(this.get("details"), this.get("detailsOpts"));
if (rawDetails) {
cookAsync(rawDetails).then(cooked => {
- this.set('cookedDetails', cooked);
+ this.set("cookedDetails", cooked);
});
}
},
willDestroyElement() {
- Ember.$(document).off('click', Ember.run.bind(this, this.documentClick));
+ Ember.$(document).off("click", Ember.run.bind(this, this.documentClick));
},
documentClick(e) {
let $element = this.$();
let $target = $(e.target);
- if ($target.closest($element).length < 1 &&
- this._state !== 'destroying') {
- this.set('showDetails', false);
+ if ($target.closest($element).length < 1 && this._state !== "destroying") {
+ this.set("showDetails", false);
}
},
actions: {
toggleDetails() {
- this.toggleProperty('showDetails');
+ this.toggleProperty("showDetails");
}
}
});
diff --git a/assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.js.es6 b/assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.js.es6
index db23bf8..1fffb9d 100644
--- a/assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.js.es6
+++ b/assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.js.es6
@@ -1,19 +1,22 @@
-import { getOwner } from 'discourse-common/lib/get-owner';
+import { getOwner } from "discourse-common/lib/get-owner";
export default {
setupComponent(attrs, component) {
- const currentUser = component.get('currentUser');
+ const currentUser = component.get("currentUser");
const topic = attrs.topic;
const oneToMany = topic.category && topic.category.qa_one_to_many;
const qaEnabled = topic.qa_enabled;
- const canCreatePost = topic.get('details.can_create_post');
+ const canCreatePost = topic.get("details.can_create_post");
- let showCreateAnswer = qaEnabled && canCreatePost && (!oneToMany || topic.user_id == currentUser.id);
+ let showCreateAnswer =
+ qaEnabled &&
+ canCreatePost &&
+ (!oneToMany || topic.user_id == currentUser.id);
let label;
let title;
if (showCreateAnswer) {
- let topicType = oneToMany ? 'one_to_many' : 'answer';
+ let topicType = oneToMany ? "one_to_many" : "answer";
label = `topic.${topicType}.title`;
title = `topic.${topicType}.help`;
}
@@ -27,8 +30,8 @@ export default {
actions: {
answerQuestion() {
- const controller = getOwner(this).lookup('controller:topic');
- controller.send('replyToPost');
+ const controller = getOwner(this).lookup("controller:topic");
+ controller.send("replyToPost");
}
}
};
diff --git a/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.hbs b/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.hbs
index 989b42a..a70d5a5 100644
--- a/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.hbs
+++ b/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.hbs
@@ -1,3 +1,5 @@
{{#if showTip}}
- {{qa-topic-tip label=label details=details detailsOpts=detailsOpts}}
+ {{qa-topic-tip label=label
+ details=details
+ detailsOpts=detailsOpts}}
{{/if}}
diff --git a/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.js.es6 b/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.js.es6
index 5db46ae..b058b48 100644
--- a/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.js.es6
+++ b/assets/javascripts/discourse/connectors/topic-title/qa-tip-container.js.es6
@@ -1,10 +1,11 @@
export default {
setupComponent(attrs, component) {
- const oneToMany = attrs.model.category && attrs.model.category.qa_one_to_many;
+ const oneToMany =
+ attrs.model.category && attrs.model.category.qa_one_to_many;
const siteSettings = attrs.model.siteSettings;
const showTip = attrs.model.showQaTip;
- let topicType = oneToMany ? 'qa_one_to_many' : 'qa';
+ let topicType = oneToMany ? "qa_one_to_many" : "qa";
let label = `topic.tip.${topicType}.title`;
let details = `topic.tip.${topicType}.details`;
let detailsOpts = {
diff --git a/assets/javascripts/discourse/initializers/qa-edits.js.es6 b/assets/javascripts/discourse/initializers/qa-edits.js.es6
index 783b9aa..68b779a 100644
--- a/assets/javascripts/discourse/initializers/qa-edits.js.es6
+++ b/assets/javascripts/discourse/initializers/qa-edits.js.es6
@@ -1,79 +1,91 @@
-import { withPluginApi } from 'discourse/lib/plugin-api';
-import discourseComputed, { on, observes } from "discourse-common/utils/decorators";
-import { h } from 'virtual-dom';
-import { avatarFor } from 'discourse/widgets/post';
-import { dateNode, numberNode } from 'discourse/helpers/node';
+import { withPluginApi } from "discourse/lib/plugin-api";
+import discourseComputed, {
+ on,
+ observes
+} from "discourse-common/utils/decorators";
+import { h } from "virtual-dom";
+import { avatarFor } from "discourse/widgets/post";
+import { dateNode, numberNode } from "discourse/helpers/node";
import { REPLY } from "discourse/models/composer";
-import { undoVote, whoVoted } from '../lib/qa-utilities';
-import { avatarAtts } from 'discourse/widgets/actions-summary';
-import PostsWithPlaceholders from 'discourse/lib/posts-with-placeholders';
+import { undoVote, whoVoted } from "../lib/qa-utilities";
+import { avatarAtts } from "discourse/widgets/actions-summary";
+import PostsWithPlaceholders from "discourse/lib/posts-with-placeholders";
export default {
- name: 'qa-edits',
- initialize(container){
- const store = container.lookup('store:main');
- const currentUser = container.lookup('current-user:main');
+ name: "qa-edits",
+ initialize(container) {
+ const store = container.lookup("store:main");
+ const currentUser = container.lookup("current-user:main");
const siteSettings = container.lookup("site-settings:main");
if (!siteSettings.qa_enabled) return;
- withPluginApi('0.8.12', api => {
-
- api.reopenWidget('post-menu', {
+ withPluginApi("0.8.12", api => {
+ api.reopenWidget("post-menu", {
menuItems() {
const attrs = this.attrs;
- let result = this.siteSettings.post_menu.split('|');
+ let result = this.siteSettings.post_menu.split("|");
if (attrs.qa_enabled) {
const post = this.findAncestorModel();
const category = post.topic.category;
-
- let type = attrs.firstPost ? 'questions' :
- (attrs.reply_to_post_number ? 'comments' : 'answers');
-
- let disableLikes = siteSettings.qa_disable_like_on_answers ||
- (category && category[`qa_disable_like_on_${type}`]);
-
+
+ let type = attrs.firstPost
+ ? "questions"
+ : attrs.reply_to_post_number
+ ? "comments"
+ : "answers";
+
+ let disableLikes =
+ siteSettings.qa_disable_like_on_answers ||
+ (category && category[`qa_disable_like_on_${type}`]);
+
if (disableLikes) {
- result = result.filter((b) => b !== 'like');
+ result = result.filter(b => b !== "like");
}
- result = result.filter((b) => b !== 'reply');
+ result = result.filter(b => b !== "reply");
}
return result;
}
});
- api.decorateWidget('post:before', function(helper) {
+ api.decorateWidget("post:before", function(helper) {
const model = helper.getModel();
- if (model && model.get('post_number') !== 1
- && !model.get('reply_to_post_number')
- && model.get('qa_enabled')) {
- return helper.attach('qa-post', {
- count: model.get('qa_vote_count'),
+ if (
+ model &&
+ model.get("post_number") !== 1 &&
+ !model.get("reply_to_post_number") &&
+ model.get("qa_enabled")
+ ) {
+ return helper.attach("qa-post", {
+ count: model.get("qa_vote_count"),
post: model
});
}
});
- api.decorateWidget('post:after', function(helper) {
+ api.decorateWidget("post:after", function(helper) {
const model = helper.getModel();
if (model.attachCommentToggle && model.hiddenComments > 0) {
- let type = Number(siteSettings.qa_comments_default) > 0 ? 'more' : 'all';
- return helper.attach('link', {
- action: 'showComments',
+ let type =
+ Number(siteSettings.qa_comments_default) > 0 ? "more" : "all";
+ return helper.attach("link", {
+ action: "showComments",
actionParam: model.answerId,
- rawLabel: I18n.t(`topic.comment.show_comments.${type}`, { count: model.hiddenComments }),
- className: 'show-comments'
+ rawLabel: I18n.t(`topic.comment.show_comments.${type}`, {
+ count: model.hiddenComments
+ }),
+ className: "show-comments"
});
}
});
- api.reopenWidget('post-stream', {
- buildKey: () => 'post-stream',
+ api.reopenWidget("post-stream", {
+ buildKey: () => "post-stream",
defaultState(attrs, state) {
let defaultState = this._super(attrs, state);
- defaultState['showComments'] = [];
+ defaultState["showComments"] = [];
return defaultState;
},
@@ -87,11 +99,8 @@ export default {
},
html(attrs, state) {
- console.log('post-stream')
let posts = attrs.posts || [];
let postArray = this.capabilities.isAndroid ? posts : posts.toArray();
-
- console.log('post-stream before if')
if (postArray[0] && postArray[0].qa_enabled) {
let answerId = null;
@@ -99,44 +108,39 @@ export default {
let defaultComments = Number(siteSettings.qa_comments_default);
let commentCount = 0;
let lastVisible = null;
-
- console.log('post-stream before for each')
postArray.forEach((p, i) => {
- console.log('post: ', p, i);
- p['oneToMany'] = p.topic.category.qa_one_to_many;
+ p["oneToMany"] = p.topic.category.qa_one_to_many;
if (p.reply_to_post_number) {
commentCount++;
- p['comment'] = true;
- p['showComment'] = (showComments.indexOf(answerId) > -1) || (commentCount <= defaultComments);
- p['answerId'] = answerId;
- p['attachCommentToggle'] = false;
-
- if (p['showComment']) lastVisible = i;
-
- if ((!postArray[i+1] ||
- !postArray[i+1].reply_to_post_number) &&
- !p['showComment']) {
-
- console.log('if first')
- console.log('lastVisible: ', lastVisible)
- console.log('hidden: ', commentCount, defaultComments)
- postArray[lastVisible]['answerId'] = answerId;
- postArray[lastVisible]['attachCommentToggle'] = true;
- postArray[lastVisible]['hiddenComments'] = commentCount - defaultComments;
+ p["comment"] = true;
+ p["showComment"] =
+ showComments.indexOf(answerId) > -1 ||
+ commentCount <= defaultComments;
+ p["answerId"] = answerId;
+ p["attachCommentToggle"] = false;
+
+ if (p["showComment"]) lastVisible = i;
+
+ if (
+ (!postArray[i + 1] ||
+ !postArray[i + 1].reply_to_post_number) &&
+ !p["showComment"]
+ ) {
+ postArray[lastVisible]["answerId"] = answerId;
+ postArray[lastVisible]["attachCommentToggle"] = true;
+ postArray[lastVisible]["hiddenComments"] =
+ commentCount - defaultComments;
}
} else {
- console.log('if second')
- p['attachCommentToggle'] = !p['oneToMany'];
- p['topicUserId'] = p.topic.user_id
+ p["attachCommentToggle"] = !p["oneToMany"];
+ p["topicUserId"] = p.topic.user_id;
answerId = p.id;
commentCount = 0;
lastVisible = i;
}
});
-
- console.log('after forEach')
if (this.capabilities.isAndroid) {
attrs.posts = postArray;
@@ -153,48 +157,49 @@ export default {
});
api.includePostAttributes(
- 'qa_enabled',
- 'reply_to_post_number',
- 'comment',
- 'showComment',
- 'answerId',
- 'lastComment',
- 'last_answerer',
- 'last_answered_at',
- 'answer_count',
- 'last_answer_post_number',
- 'last_answerer',
- 'topicUserId',
- 'oneToMany'
+ "qa_enabled",
+ "reply_to_post_number",
+ "comment",
+ "showComment",
+ "answerId",
+ "lastComment",
+ "last_answerer",
+ "last_answered_at",
+ "answer_count",
+ "last_answer_post_number",
+ "last_answerer",
+ "topicUserId",
+ "oneToMany"
);
- api.addPostClassesCallback((attrs) => {
+ api.addPostClassesCallback(attrs => {
if (attrs.qa_enabled && !attrs.firstPost) {
if (attrs.comment) {
let classes = ["comment"];
if (attrs.showComment) {
- classes.push('show');
+ classes.push("show");
}
return classes;
} else {
return ["answer"];
}
- };
+ }
});
- api.addPostMenuButton('answer', (attrs) => {
- if (attrs.canCreatePost &&
- attrs.qa_enabled &&
- attrs.firstPost &&
- (!attrs.oneToMany || attrs.topicUserId === currentUser.id)) {
-
- let postType = attrs.oneToMany ? 'one_to_many' : 'answer';
+ api.addPostMenuButton("answer", attrs => {
+ if (
+ attrs.canCreatePost &&
+ attrs.qa_enabled &&
+ attrs.firstPost &&
+ (!attrs.oneToMany || attrs.topicUserId === currentUser.id)
+ ) {
+ let postType = attrs.oneToMany ? "one_to_many" : "answer";
let args = {
- action: 'replyToPost',
+ action: "replyToPost",
title: `topic.${postType}.help`,
- icon: 'reply',
- className: 'answer create fade-out'
+ icon: "reply",
+ className: "answer create fade-out"
};
if (!attrs.mobileView) {
@@ -202,72 +207,89 @@ export default {
}
return args;
- };
+ }
});
- api.addPostMenuButton('comment', (attrs) => {
- if (attrs.canCreatePost &&
- attrs.qa_enabled &&
- !attrs.firstPost &&
- !attrs.reply_to_post_number) {
-
+ api.addPostMenuButton("comment", attrs => {
+ if (
+ attrs.canCreatePost &&
+ attrs.qa_enabled &&
+ !attrs.firstPost &&
+ !attrs.reply_to_post_number
+ ) {
let args = {
- action: 'openCommentCompose',
- title: 'topic.comment.help',
- icon: 'comment',
- className: 'comment create fade-out'
+ action: "openCommentCompose",
+ title: "topic.comment.help",
+ icon: "comment",
+ className: "comment create fade-out"
};
if (!attrs.mobileView) {
- args.label = 'topic.comment.title';
+ args.label = "topic.comment.title";
}
return args;
- };
+ }
});
- api.modifyClass('component:composer-actions', {
- @on('init')
+ api.modifyClass("component:composer-actions", {
+ @on("init")
setupPost() {
- const composerPost = this.get('composerModel.post');
+ const composerPost = this.get("composerModel.post");
if (composerPost) {
- this.set('pluginPostSnapshot', composerPost);
+ this.set("pluginPostSnapshot", composerPost);
}
},
- @discourseComputed('pluginPostSnapshot')
+ @discourseComputed("pluginPostSnapshot")
commenting(post) {
- return post && post.topic.qa_enabled && !post.get('firstPost') && !post.reply_to_post_number;
+ return (
+ post &&
+ post.topic.qa_enabled &&
+ !post.get("firstPost") &&
+ !post.reply_to_post_number
+ );
},
computeHeaderContent() {
let content = this._super();
- if (this.get('commenting') &&
- this.get("action") === REPLY &&
- this.get('options.userAvatar')) {
- content.icon = 'comment';
+ if (
+ this.get("commenting") &&
+ this.get("action") === REPLY &&
+ this.get("options.userAvatar")
+ ) {
+ content.icon = "comment";
}
return content;
},
- @discourseComputed("options", "canWhisper", "action", 'commenting')
+ @discourseComputed("options", "canWhisper", "action", "commenting")
content(options, canWhisper, action, commenting) {
let items = this._super(...arguments);
if (commenting) {
- items.forEach((item) => {
- if (item.id === 'reply_to_topic') {
- item.name = I18n.t('composer.composer_actions.reply_to_question.label');
- item.description = I18n.t('composer.composer_actions.reply_to_question.desc');
+ items.forEach(item => {
+ if (item.id === "reply_to_topic") {
+ item.name = I18n.t(
+ "composer.composer_actions.reply_to_question.label"
+ );
+ item.description = I18n.t(
+ "composer.composer_actions.reply_to_question.desc"
+ );
}
- if (item.id === 'reply_to_post') {
- item.icon = 'comment';
- item.name = I18n.t('composer.composer_actions.comment_on_answer.label', {
- postUsername: this.get('pluginPostSnapshot.username')
- });
- item.description = I18n.t('composer.composer_actions.comment_on_answer.desc');
+ if (item.id === "reply_to_post") {
+ item.icon = "comment";
+ item.name = I18n.t(
+ "composer.composer_actions.comment_on_answer.label",
+ {
+ postUsername: this.get("pluginPostSnapshot.username")
+ }
+ );
+ item.description = I18n.t(
+ "composer.composer_actions.comment_on_answer.desc"
+ );
}
});
}
@@ -276,7 +298,7 @@ export default {
}
});
- api.reopenWidget('post-body', {
+ api.reopenWidget("post-body", {
buildKey: attrs => `post-body-${attrs.id}`,
defaultState(attrs) {
@@ -290,35 +312,37 @@ export default {
html(attrs, state) {
let contents = this._super(attrs, state);
const model = this.findAncestorModel();
- let action = model.actionByName['vote'];
+ let action = model.actionByName["vote"];
if (action && attrs.qa_enabled) {
let voteLinks = [];
attrs.actionsSummary = attrs.actionsSummary.filter(
- as => as.action !== 'vote'
+ as => as.action !== "vote"
);
if (action.acted && action.can_undo) {
voteLinks.push(
- this.attach('link', {
- action: 'undoUserVote',
- rawLabel: I18n.t('post.actions.undo.vote')
+ this.attach("link", {
+ action: "undoUserVote",
+ rawLabel: I18n.t("post.actions.undo.vote")
})
);
}
if (action.count > 0) {
voteLinks.push(
- this.attach('link', {
- action: 'toggleWhoVoted',
- rawLabel: `${action.count} ${I18n.t('post.actions.people.vote')}`
+ this.attach("link", {
+ action: "toggleWhoVoted",
+ rawLabel: `${action.count} ${I18n.t(
+ "post.actions.people.vote"
+ )}`
})
);
}
if (voteLinks.length) {
- let voteContents = [ h('div.vote-links', voteLinks) ];
+ let voteContents = [h("div.vote-links", voteLinks)];
if (state.voters.length) {
voteContents.push(
@@ -329,10 +353,16 @@ export default {
);
}
- let actionSummaryIndex = contents.map(w => w && w.name).indexOf('actions-summary');
+ let actionSummaryIndex = contents
+ .map(w => w && w.name)
+ .indexOf("actions-summary");
let insertAt = actionSummaryIndex + 1;
- contents.splice(insertAt - 1, 0, h('div.vote-container', voteContents));
+ contents.splice(
+ insertAt - 1,
+ 0,
+ h("div.vote-container", voteContents)
+ );
}
}
@@ -345,12 +375,12 @@ export default {
const vote = {
user_id: user.id,
post_id: post.id,
- direction: 'up'
+ direction: "up"
};
undoVote({ vote }).then(result => {
if (result.success) {
- post.set('topic.voted', false);
+ post.set("topic.voted", false);
}
});
},
@@ -369,7 +399,7 @@ export default {
const post = {
post_id: attrs.id
};
-
+
whoVoted(post).then(result => {
if (result.voters) {
state.voters = result.voters.map(avatarAtts);
@@ -379,35 +409,41 @@ export default {
}
});
- api.modifyClass('model:topic', {
- @discourseComputed('qa_enabled')
+ api.modifyClass("model:topic", {
+ @discourseComputed("qa_enabled")
showQaTip(qaEnabled) {
return qaEnabled && this.siteSettings.qa_show_topic_tip;
}
});
- api.modifyClass('component:topic-footer-buttons', {
- @on('didInsertElement')
- @observes('topic.qa_enabled')
+ api.modifyClass("component:topic-footer-buttons", {
+ @on("didInsertElement")
+ @observes("topic.qa_enabled")
hideFooterReply() {
- const qaEnabled = this.get('topic.qa_enabled');
- Ember.run.scheduleOnce('afterRender', () => {
- this.$('.topic-footer-main-buttons > button.create:not(.answer)').toggle(!qaEnabled);
+ const qaEnabled = this.get("topic.qa_enabled");
+ Ember.run.scheduleOnce("afterRender", () => {
+ this.$(
+ ".topic-footer-main-buttons > button.create:not(.answer)"
+ ).toggle(!qaEnabled);
});
}
});
- api.modifyClass('model:post-stream', {
+ api.modifyClass("model:post-stream", {
prependPost(post) {
const stored = this.storePost(post);
if (stored) {
- const posts = this.get('posts');
+ const posts = this.get("posts");
let insertPost = () => posts.unshiftObject(stored);
- const qaEnabled = this.get('topic.qa_enabled');
- if (qaEnabled && post.post_number === 2 && posts[0].post_number === 1) {
+ const qaEnabled = this.get("topic.qa_enabled");
+ if (
+ qaEnabled &&
+ post.post_number === 2 &&
+ posts[0].post_number === 1
+ ) {
insertPost = () => posts.insertAt(1, stored);
- };
+ }
insertPost();
}
@@ -418,11 +454,11 @@ export default {
appendPost(post) {
const stored = this.storePost(post);
if (stored) {
- const posts = this.get('posts');
+ const posts = this.get("posts");
if (!posts.includes(stored)) {
- const replyingTo = post.get('reply_to_post_number');
- const qaEnabled = this.get('topic.qa_enabled');
+ const replyingTo = post.get("reply_to_post_number");
+ const qaEnabled = this.get("topic.qa_enabled");
let insertPost = () => posts.pushObject(stored);
if (qaEnabled && replyingTo) {
@@ -431,23 +467,23 @@ export default {
if (passed && !p.reply_to_post_number) {
insertPost = () => posts.insertAt(i, stored);
return true;
- };
+ }
if (p.post_number === replyingTo && i < posts.length - 1) {
passed = true;
- };
+ }
});
- };
+ }
- if (!this.get('loadingBelow')) {
- this.get('postsWithPlaceholders').appendPost(insertPost);
+ if (!this.get("loadingBelow")) {
+ this.get("postsWithPlaceholders").appendPost(insertPost);
} else {
insertPost();
}
}
- if (stored.get('id') !== -1) {
- this.set('lastAppended', stored);
+ if (stored.get("id") !== -1) {
+ this.set("lastAppended", stored);
}
}
return post;
@@ -455,27 +491,43 @@ export default {
});
api.modifyClass("component:topic-progress", {
- @discourseComputed('postStream.loaded', 'topic.currentPost', 'postStream.filteredPostsCount', 'topic.qa_enabled')
+ @discourseComputed(
+ "postStream.loaded",
+ "topic.currentPost",
+ "postStream.filteredPostsCount",
+ "topic.qa_enabled"
+ )
hideProgress(loaded, currentPost, filteredPostsCount, qaEnabled) {
- return qaEnabled || (!loaded) || (!currentPost) || (!this.site.mobileView && filteredPostsCount < 2);
+ return (
+ qaEnabled ||
+ !loaded ||
+ !currentPost ||
+ (!this.site.mobileView && filteredPostsCount < 2)
+ );
},
- @discourseComputed('progressPosition', 'topic.last_read_post_id', 'topic.qa_enabled')
+ @discourseComputed(
+ "progressPosition",
+ "topic.last_read_post_id",
+ "topic.qa_enabled"
+ )
showBackButton(position, lastReadId, qaEnabled) {
- if (!lastReadId || qaEnabled) { return; }
+ if (!lastReadId || qaEnabled) {
+ return;
+ }
- const stream = this.get('postStream.stream');
+ const stream = this.get("postStream.stream");
const readPos = stream.indexOf(lastReadId) || 0;
- return (readPos < (stream.length - 1)) && (readPos > position);
- },
+ return readPos < stream.length - 1 && readPos > position;
+ }
});
api.modifyClass("component:topic-navigation", {
_performCheckSize() {
if (!this.element || this.isDestroying || this.isDestroyed) return;
- if (this.get('topic.qa_enabled')) {
- const info = this.get('info');
+ if (this.get("topic.qa_enabled")) {
+ const info = this.get("info");
info.setProperties({
renderTimeline: false,
renderAdminMenuButton: true
@@ -486,40 +538,46 @@ export default {
}
});
- api.reopenWidget('post', {
+ api.reopenWidget("post", {
html(attrs) {
- if (attrs.cloaked) { return ''; }
+ if (attrs.cloaked) {
+ return "";
+ }
if (attrs.qa_enabled && !attrs.firstPost) {
attrs.replyToUsername = null;
if (attrs.reply_to_post_number) {
attrs.canCreatePost = false;
- api.changeWidgetSetting('post-avatar', 'size', 'small');
+ api.changeWidgetSetting("post-avatar", "size", "small");
} else {
attrs.replyCount = null;
- api.changeWidgetSetting('post-avatar', 'size', 'large');
+ api.changeWidgetSetting("post-avatar", "size", "large");
}
}
- return this.attach('post-article', attrs);
+ return this.attach("post-article", attrs);
},
openCommentCompose() {
- this.sendWidgetAction('showComments', this.attrs.id);
- this.sendWidgetAction('replyToPost', this.model);
- },
+ this.sendWidgetAction("showComments", this.attrs.id);
+ this.sendWidgetAction("replyToPost", this.model);
+ }
});
function renderParticipants(userFilters, participants) {
- if (!participants) { return; }
+ if (!participants) {
+ return;
+ }
userFilters = userFilters || [];
return participants.map(p => {
- return this.attach('topic-participant', p, { state: { toggled: userFilters.includes(p.username) } });
+ return this.attach("topic-participant", p, {
+ state: { toggled: userFilters.includes(p.username) }
+ });
});
}
- api.reopenWidget('topic-map-summary', {
+ api.reopenWidget("topic-map-summary", {
html(attrs, state) {
if (attrs.qa_enabled) {
return this.qaMap(attrs, state);
@@ -531,79 +589,116 @@ export default {
qaMap(attrs, state) {
const contents = [];
- contents.push(h('li',
- [
- h('h4', I18n.t('created_lowercase')),
- h('div.topic-map-post.created-at', [
- avatarFor('tiny', {
+ contents.push(
+ h("li", [
+ h("h4", I18n.t("created_lowercase")),
+ h("div.topic-map-post.created-at", [
+ avatarFor("tiny", {
username: attrs.createdByUsername,
template: attrs.createdByAvatarTemplate,
name: attrs.createdByName
}),
dateNode(attrs.topicCreatedAt)
])
- ]
- ));
-
- let lastAnswerUrl = attrs.topicUrl + '/' + attrs.last_answer_post_number;
- let postType = attrs.oneToMany ? 'one_to_many' : 'answer';
-
- contents.push(h('li',
- h('a', { attributes: { href: lastAnswerUrl } }, [
- h('h4', I18n.t(`last_${postType}_lowercase`)),
- h('div.topic-map-post.last-answer', [
- avatarFor('tiny', {
- username: attrs.last_answerer.username,
- template: attrs.last_answerer.avatar_template,
- name: attrs.last_answerer.name
- }),
- dateNode(attrs.last_answered_at)
+ ])
+ );
+
+ let lastAnswerUrl =
+ attrs.topicUrl + "/" + attrs.last_answer_post_number;
+ let postType = attrs.oneToMany ? "one_to_many" : "answer";
+
+ contents.push(
+ h(
+ "li",
+ h("a", { attributes: { href: lastAnswerUrl } }, [
+ h("h4", I18n.t(`last_${postType}_lowercase`)),
+ h("div.topic-map-post.last-answer", [
+ avatarFor("tiny", {
+ username: attrs.last_answerer.username,
+ template: attrs.last_answerer.avatar_template,
+ name: attrs.last_answerer.name
+ }),
+ dateNode(attrs.last_answered_at)
+ ])
])
+ )
+ );
+
+ contents.push(
+ h("li", [
+ numberNode(attrs.answer_count),
+ h(
+ "h4",
+ I18n.t(`${postType}_lowercase`, { count: attrs.answer_count })
+ )
])
- ));
-
- contents.push(h('li', [
- numberNode(attrs.answer_count),
- h('h4', I18n.t(`${postType}_lowercase`, { count: attrs.answer_count }))
- ]));
+ );
- contents.push(h('li.secondary', [
- numberNode(attrs.topicViews, { className: attrs.topicViewsHeat }),
- h('h4', I18n.t('views_lowercase', { count: attrs.topicViews }))
- ]));
-
- contents.push(h('li.secondary', [
- numberNode(attrs.participantCount),
- h('h4', I18n.t('users_lowercase', { count: attrs.participantCount }))
- ]));
+ contents.push(
+ h("li.secondary", [
+ numberNode(attrs.topicViews, { className: attrs.topicViewsHeat }),
+ h("h4", I18n.t("views_lowercase", { count: attrs.topicViews }))
+ ])
+ );
+
+ contents.push(
+ h("li.secondary", [
+ numberNode(attrs.participantCount),
+ h(
+ "h4",
+ I18n.t("users_lowercase", { count: attrs.participantCount })
+ )
+ ])
+ );
if (attrs.topicLikeCount) {
- contents.push(h('li.secondary', [
- numberNode(attrs.topicLikeCount),
- h('h4', I18n.t('likes_lowercase', { count: attrs.topicLikeCount }))
- ]));
+ contents.push(
+ h("li.secondary", [
+ numberNode(attrs.topicLikeCount),
+ h(
+ "h4",
+ I18n.t("likes_lowercase", { count: attrs.topicLikeCount })
+ )
+ ])
+ );
}
if (attrs.topicLinkLength > 0) {
- contents.push(h('li.secondary', [
- numberNode(attrs.topicLinkLength),
- h('h4', I18n.t('links_lowercase', { count: attrs.topicLinkLength }))
- ]));
+ contents.push(
+ h("li.secondary", [
+ numberNode(attrs.topicLinkLength),
+ h(
+ "h4",
+ I18n.t("links_lowercase", { count: attrs.topicLinkLength })
+ )
+ ])
+ );
}
- if (state.collapsed && attrs.topicPostsCount > 2 && attrs.participants.length > 0) {
- const participants = renderParticipants.call(this, attrs.userFilters, attrs.participants.slice(0, 3));
- contents.push(h('li.avatars', participants));
+ if (
+ state.collapsed &&
+ attrs.topicPostsCount > 2 &&
+ attrs.participants.length > 0
+ ) {
+ const participants = renderParticipants.call(
+ this,
+ attrs.userFilters,
+ attrs.participants.slice(0, 3)
+ );
+ contents.push(h("li.avatars", participants));
}
- const nav = h('nav.buttons', this.attach('button', {
- title: 'topic.toggle_information',
- icon: state.collapsed ? 'chevron-down' : 'chevron-up',
- action: 'toggleMap',
- className: 'btn',
- }));
-
- return [nav, h('ul.clearfix', contents)];
+ const nav = h(
+ "nav.buttons",
+ this.attach("button", {
+ title: "topic.toggle_information",
+ icon: state.collapsed ? "chevron-down" : "chevron-up",
+ action: "toggleMap",
+ className: "btn"
+ })
+ );
+
+ return [nav, h("ul.clearfix", contents)];
}
});
});
diff --git a/assets/javascripts/discourse/lib/qa-utilities.js.es6 b/assets/javascripts/discourse/lib/qa-utilities.js.es6
index 72450d0..b325c95 100644
--- a/assets/javascripts/discourse/lib/qa-utilities.js.es6
+++ b/assets/javascripts/discourse/lib/qa-utilities.js.es6
@@ -1,26 +1,26 @@
-import { popupAjaxError } from 'discourse/lib/ajax-error';
-import { ajax } from 'discourse/lib/ajax';
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { ajax } from "discourse/lib/ajax";
const voteActionId = 100;
const vote = function(type, data) {
- return ajax('/qa/vote', {
+ return ajax("/qa/vote", {
type,
data
}).catch(popupAjaxError);
};
const undoVote = function(data) {
- return vote('DELETE', data);
+ return vote("DELETE", data);
};
const castVote = function(data) {
- return vote('POST', data);
+ return vote("POST", data);
};
const whoVoted = function(data) {
- return ajax('/qa/voters', {
- type: 'GET',
+ return ajax("/qa/voters", {
+ type: "GET",
data
}).catch(popupAjaxError);
};
diff --git a/assets/javascripts/discourse/templates/components/qa-topic-tip.hbs b/assets/javascripts/discourse/templates/components/qa-topic-tip.hbs
index 0809847..175815b 100644
--- a/assets/javascripts/discourse/templates/components/qa-topic-tip.hbs
+++ b/assets/javascripts/discourse/templates/components/qa-topic-tip.hbs
@@ -1,4 +1,8 @@
-{{d-button class="btn btn-topic-tip" action="toggleDetails" label=label icon='info'}}
+{{d-button class="btn btn-topic-tip"
+ action="toggleDetails"
+ label=label
+ icon="info"}}
+
{{#if showDetails}}
{{cookedDetails}}
diff --git a/assets/javascripts/discourse/widgets/qa-button.js.es6 b/assets/javascripts/discourse/widgets/qa-button.js.es6
index 89bcb0e..0bcb8be 100644
--- a/assets/javascripts/discourse/widgets/qa-button.js.es6
+++ b/assets/javascripts/discourse/widgets/qa-button.js.es6
@@ -1,14 +1,14 @@
-import { createWidget } from 'discourse/widgets/widget';
+import { createWidget } from "discourse/widgets/widget";
import { iconNode } from "discourse-common/lib/icon-library";
-export default createWidget('qa-button', {
- tagName: 'button.btn.qa-button',
+export default createWidget("qa-button", {
+ tagName: "button.btn.qa-button",
html(attrs) {
return iconNode(`angle-${attrs.direction}`);
},
click() {
- this.sendWidgetAction('vote', this.attrs.direction);
- },
+ this.sendWidgetAction("vote", this.attrs.direction);
+ }
});
diff --git a/assets/javascripts/discourse/widgets/qa-post.js.es6 b/assets/javascripts/discourse/widgets/qa-post.js.es6
index d3a86b2..b54e33b 100644
--- a/assets/javascripts/discourse/widgets/qa-post.js.es6
+++ b/assets/javascripts/discourse/widgets/qa-post.js.es6
@@ -1,19 +1,19 @@
-import { createWidget } from 'discourse/widgets/widget';
-import { castVote } from '../lib/qa-utilities';
-import { h } from 'virtual-dom';
+import { createWidget } from "discourse/widgets/widget";
+import { castVote } from "../lib/qa-utilities";
+import { h } from "virtual-dom";
-export default createWidget('qa-post', {
- tagName: 'div.qa-post',
+export default createWidget("qa-post", {
+ tagName: "div.qa-post",
sendShowLogin() {
- const appRoute = this.register.lookup('route:application');
- appRoute.send('showLogin');
+ const appRoute = this.register.lookup("route:application");
+ appRoute.send("showLogin");
},
html(attrs) {
const contents = [
- this.attach('qa-button', { direction: 'up' }),
- h('div.count', `${attrs.count}`)
+ this.attach("qa-button", { direction: "up" }),
+ h("div.count", `${attrs.count}`)
];
return contents;
},
@@ -35,13 +35,13 @@ export default createWidget('qa-post', {
castVote({ vote }).then(result => {
if (result.success) {
- post.set('topic.qa_voted', true);
+ post.set("topic.qa_voted", true);
if (result.qa_can_vote) {
- post.set('topic.qa_can_vote', result.qa_can_vote);
+ post.set("topic.qa_can_vote", result.qa_can_vote);
}
if (result.qa_votes) {
- post.set('topic.qa_votes', result.qa_votes);
+ post.set("topic.qa_votes", result.qa_votes);
}
}
});
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..f4eba66
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+QuestionAnswer::Engine.routes.draw do
+ resource :vote
+ get 'voters' => 'votes#voters'
+end
+
+Discourse::Application.routes.append do
+ mount ::QuestionAnswer::Engine, at: 'qa'
+end
diff --git a/jobs/update_post_order.rb b/jobs/update_post_order.rb
index bc49616..d59ce49 100644
--- a/jobs/update_post_order.rb
+++ b/jobs/update_post_order.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
module Jobs
class UpdatePostOrder < ::Jobs::Base
def execute(args)
diff --git a/lib/qa.rb b/lib/qa.rb
deleted file mode 100644
index a72a471..0000000
--- a/lib/qa.rb
+++ /dev/null
@@ -1,195 +0,0 @@
-module ::QuestionAnswer
- class Engine < ::Rails::Engine
- engine_name 'question_answer'
- isolate_namespace QuestionAnswer
- end
-end
-
-QuestionAnswer::Engine.routes.draw do
- resource :vote
- get 'voters' => 'votes#voters'
-end
-
-Discourse::Application.routes.append do
- mount ::QuestionAnswer::Engine, at: 'qa'
-end
-
-require_dependency 'post_action_user_serializer'
-class QuestionAnswer::VoterSerializer < ::PostActionUserSerializer
- def post_url
- nil
- end
-end
-
-Voter = Struct.new(:user)
-
-require_dependency 'application_controller'
-class QuestionAnswer::VotesController < ::ApplicationController
- before_action :ensure_logged_in
- before_action :find_vote_post
- before_action :find_vote_user, only: [:create, :destroy]
- before_action :ensure_qa_enabled, only: [:create, :destroy]
-
- def create
- if !Topic.qa_can_vote(@post.topic, @user)
- raise Discourse::InvalidAccess.new(nil, nil,
- custom_message: 'vote.error.user_over_limit'
- )
- end
-
- if !@post.qa_can_vote(@user.id)
- raise Discourse::InvalidAccess.new(nil, nil,
- custom_message: 'vote.error.one_vote_per_post'
- )
- end
-
- if QuestionAnswer::Vote.vote(@post, @user, vote_args)
- render json: success_json.merge(
- qa_votes: Topic.qa_votes(@post.topic, @user),
- qa_can_vote: Topic.qa_can_vote(@post.topic, @user)
- )
- else
- render json: failed_json, status: 422
- end
- end
-
- def destroy
- if Topic.qa_votes(@post.topic, @user).length == 0
- raise Discourse::InvalidAccess.new, I18n.t('vote.error.user_has_not_voted')
- end
-
- if QuestionAnswer::Vote.vote(@post, @user, vote_args)
- render json: success_json.merge(
- qa_votes: Topic.qa_votes(@post.topic, @user),
- qa_can_vote: Topic.qa_can_vote(@post.topic, @user)
- )
- else
- render json: failed_json, status: 422
- end
- end
-
- def voters
- voters = []
-
- if @post.qa_voted.any?
- @post.qa_voted.each do |user_id|
- if user = User.find_by(id: user_id)
- voters.push(Voter.new(user))
- end
- end
- end
-
- render_json_dump(voters: serialize_data(voters, QuestionAnswer::VoterSerializer))
- end
-
- private
-
- def vote_params
- params.require(:vote).permit(:post_id, :user_id, :direction)
- end
-
- def vote_args
- {
- direction: vote_params[:direction],
- action: self.action_name
- }
- end
-
- def find_vote_post
- if params[:vote].present?
- post_id = vote_params[:post_id]
- else
- params.require(:post_id)
- post_id = params[:post_id]
- end
-
- if post = Post.find_by(id: post_id)
- @post = post
- else
- raise Discourse::NotFound
- end
- end
-
- def find_vote_user
- if vote_params[:user_id] && user = User.find_by(id: vote_params[:user_id])
- @user = user
- else
- raise Discourse::NotFound
- end
- end
-
- def ensure_qa_enabled
- Topic.qa_enabled(@post.topic)
- end
-
- def ensure_can_act
- if Topic.voted(@post.topic, @user)
- if self.action_name === QuestionAnswer::Vote::CREATE
- raise Discourse::InvalidAccess.new, I18n.t('vote.error.alread_voted')
- end
-
- if self.action_name === QuestionAnswer::Vote::DESTROY && !QuestionAnswer::Vote.can_undo(@post, @user)
- raise Discourse::InvalidAccess.new, I18n.t('vote.error.undo_vote_action_window',
- minutes: SiteSetting.qa_undo_vote_action_window
- )
- end
- elsif self.action_name === QuestionAnswer::Vote::DESTROY
- raise Discourse::InvalidAccess.new, I18n.t('vote.error.user_has_not_voted')
- end
- end
-end
-
-class QuestionAnswer::Vote
- CREATE = 'create'
- DESTROY = 'destroy'
- UP = 'up'
- DOWN = 'down'
-
- def self.vote(post, user, args)
- modifier = 0
-
- voted = post.qa_voted
-
- if args[:direction] === UP
- if args[:action] === CREATE
- voted.push(user.id)
- modifier = 1
- elsif args[:action] === DESTROY
- modifier = 0
- voted.delete_if do |user_id|
- if user_id === user.id
- modifier = modifier - 1
- true
- end
- end
- end
- end
-
- post.custom_fields['vote_count'] = post.qa_vote_count + modifier
- post.custom_fields['voted'] = voted
-
- votes = post.qa_vote_history
-
- votes.push(
- direction: args[:direction],
- action: args[:action],
- user_id: user.id,
- created_at: Time.now
- )
-
- post.custom_fields['vote_history'] = votes
-
- if post.save_custom_fields(true)
- Topic.qa_update_vote_order(post.topic)
- post.publish_change_to_clients! :acted
- true
- else
- false
- end
- end
-
- def self.can_undo(post, user)
- window = SiteSetting.qa_undo_vote_action_window.to_i
- window === 0 || post.qa_last_voted(user.id).to_i > window.minutes.ago.to_i
- end
-end
diff --git a/lib/qa_one_to_many_edits.rb b/lib/qa_one_to_many_edits.rb
deleted file mode 100644
index 43d36af..0000000
--- a/lib/qa_one_to_many_edits.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-module OneToManyGuardianExtension
- def can_create_post_on_topic?(topic)
- post = self.try(:post_opts) || {}
- category = topic.category
- if category &&
- category.qa_enabled &&
- category.qa_one_to_many &&
- post.present? &&
- !post[:reply_to_post_number]
- return @user.id == topic.user_id
- end
- super(topic)
- end
-end
-
-class ::Guardian
- attr_accessor :post_opts
- prepend OneToManyGuardianExtension
-end
-
-module OneToManyPostCreatorExtension
- def valid?
- guardian.post_opts = @opts
- super
- end
-end
-
-class ::PostCreator
- prepend OneToManyPostCreatorExtension
-end
diff --git a/lib/qa_post_edits.rb b/lib/qa_post_edits.rb
deleted file mode 100644
index cbc9371..0000000
--- a/lib/qa_post_edits.rb
+++ /dev/null
@@ -1,161 +0,0 @@
-module PostSerializerQAExtension
- def actions_summary
- summaries = super.reject { |s| s[:id] === PostActionType.types[:vote] }
-
- if object.qa_enabled
- user = scope.current_user
- summary = {
- id: PostActionType.types[:vote],
- count: object.qa_vote_count
- }
-
- if user
- voted = object.qa_voted.include?(user.id)
-
- if voted
- summary[:acted] = true
- summary[:can_undo] = ::QuestionAnswer::Vote.can_undo(object, user)
- else
- summary[:can_act] = true
- end
- end
-
- summary.delete(:count) if summary[:count] == 0
-
- if summary[:can_act] || summary[:count]
- summaries + [summary]
- else
- summaries
- end
- else
- summaries
- end
- end
-end
-
-require_dependency 'post_serializer'
-class ::PostSerializer
- prepend PostSerializerQAExtension
-
- attributes :qa_vote_count,
- :qa_voted,
- :qa_enabled,
- :last_answerer,
- :last_answered_at,
- :answer_count,
- :last_answer_post_number,
- :last_answerer
-
- def qa_vote_count
- object.qa_vote_count
- end
-
- def qa_voted
- object.qa_voted
- end
-
- def qa_enabled
- object.qa_enabled
- end
-
- def last_answerer
- object.topic.last_answerer
- end
-
- def include_last_answerer?
- object.qa_enabled
- end
-
- def last_answered_at
- object.topic.last_answered_at
- end
-
- def include_last_answered_at?
- object.qa_enabled
- end
-
- def answer_count
- object.topic.answer_count
- end
-
- def include_answer_count?
- object.qa_enabled
- end
-
- def last_answer_post_number
- object.topic.last_answer_post_number
- end
-
- def include_last_answer_post_number?
- object.qa_enabled
- end
-
- def last_answerer
- object.topic.last_answerer
- end
-
- def include_last_answerer?
- object.qa_enabled
- end
-end
-
-## 'qa_vote_count' and 'qa_voted' are used for quick access, whereas 'qa_vote_history' is used for record keeping
-## See QuestionAnswer::Vote for how these fields are saved / updated
-
-Post.register_custom_field_type('vote_count', :integer)
-Post.register_custom_field_type('vote_history', :json)
-
-class ::Post
- after_create :qa_update_vote_order, if: :qa_enabled
-
- self.ignored_columns = %w(vote_count)
-
- def qa_vote_count
- if vote_count = custom_fields['vote_count']
- [*vote_count].first.to_i
- else
- 0
- end
- end
-
- def qa_voted
- if custom_fields['voted'].present?
- [*custom_fields['voted']].map(&:to_i)
- else
- []
- end
- end
-
- def qa_vote_history
- if custom_fields['vote_history'].present?
- [*custom_fields['vote_history']]
- else
- []
- end
- end
-
- def qa_enabled
- ::Topic.qa_enabled(topic)
- end
-
- def qa_update_vote_order
- ::Topic.qa_update_vote_order(topic_id)
- end
-
- def qa_last_voted(user_id)
- user_votes = qa_vote_history.select do |v|
- v['user_id'].to_i === user_id && v['action'] === 'create'
- end
-
- if user_votes.any?
- user_votes.sort_by { |v| v['created_at'].to_i }.first['created_at'].to_datetime
- else
- nil
- end
- end
-
- def qa_can_vote(user_id)
- SiteSetting.qa_tl_allow_multiple_votes_per_post ||
- !qa_voted.include?(user_id)
- end
-end
diff --git a/lib/qa_topic_edits.rb b/lib/qa_topic_edits.rb
deleted file mode 100644
index 92430c8..0000000
--- a/lib/qa_topic_edits.rb
+++ /dev/null
@@ -1,240 +0,0 @@
-module TopicQAExtension
- def reload(options = nil)
- @answers = nil
- @comments = nil
- @last_answerer = nil
- super(options)
- end
-
- def answers
- @answers ||= posts.where(reply_to_post_number: [nil, '']).order("created_at ASC")
- end
-
- def comments
- @comments ||= posts.where.not(reply_to_post_number: [nil, '']).order("created_at ASC")
- end
-
- def answer_count
- answers.count - 1 ## minus first post
- end
-
- def comment_count
- comments.count
- end
-
- def last_answered_at
- if answers.any?
- answers.last[:created_at]
- else
- nil
- end
- end
-
- def last_commented_on
- if comments.any?
- comments.last[:created_at]
- else
- nil
- end
- end
-
- def last_answer_post_number
- if answers.any?
- answers.last[:post_number]
- else
- nil
- end
- end
-
- def last_answerer
- if answers.any?
- @last_answerer ||= ::User.find(answers.last[:user_id])
- else
- nil
- end
- end
-end
-
-require_dependency 'topic'
-class ::Topic
- prepend TopicQAExtension
-
- def self.qa_can_vote(topic, user)
- return false if !user || !SiteSetting.qa_enabled
- topic_vote_count = self.qa_votes(topic, user).length
- return false if topic_vote_count > 0 && !SiteSetting.qa_trust_level_vote_limits
- trust_level = user.trust_level
- return false if trust_level == 0
- topic_vote_limit = SiteSetting.send("qa_tl#{trust_level}_vote_limit")
- topic_vote_limit.to_i >= topic_vote_count
- end
-
- def self.qa_votes(topic, user)
- return nil if !user || !SiteSetting.qa_enabled
- PostCustomField.where(post_id: topic.posts.map(&:id),
- name: 'voted',
- value: user.id).pluck(:post_id)
- end
-
- def self.qa_enabled(topic)
- return false if !SiteSetting.qa_enabled
- return false if !topic || !topic.respond_to?(:is_category_topic?) || topic.is_category_topic?
-
- tags = topic.tags.map(&:name)
- has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty?
- is_qa_category = topic.category && topic.category.custom_fields["qa_enabled"]
- is_qa_subtype = topic.subtype == 'question'
-
- has_qa_tag || is_qa_category || is_qa_subtype
- end
-
- def self.qa_update_vote_order(topic_id)
- return if !SiteSetting.qa_enabled
-
- posts = Post.where(topic_id: topic_id)
-
- posts.where(post_number: 1).update(sort_order: 1)
-
- answers = posts.where(reply_to_post_number: [nil, ''])
- .where.not(post_number: 1)
- .order("(
- SELECT COALESCE ((
- SELECT value::integer FROM post_custom_fields
- WHERE post_id = posts.id AND name = 'vote_count'
- ), 0)
- ) DESC, post_number ASC")
-
- count = 2
- answers.each do |a|
- a.update(sort_order: count)
- comments = posts.where(reply_to_post_number: a.post_number)
- .order("post_number ASC")
- if comments.any?
- comments.each do |c|
- count += 1
- c.update(sort_order: count)
- end
- else
- count += 1
- end
- end
- end
-end
-
-module TopicViewQAExtension
- def qa_enabled
- ::Topic.qa_enabled(@topic)
- end
-
- def filter_posts_by_ids(post_ids)
- if qa_enabled
- posts = ::Post.where(id: post_ids, topic_id: @topic.id)
- .includes(:user, :reply_to_user, :incoming_email)
- @posts = posts.order("case when post_number = 1 then 0 else 1 end, sort_order ASC")
- @posts = filter_post_types(@posts)
- @posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
- @posts
- else
- super
- end
- end
-end
-
-class TopicView
- prepend TopicViewQAExtension
-end
-
-require_dependency 'topic_view_serializer'
-require_dependency 'basic_user_serializer'
-class ::TopicViewSerializer
- attributes :qa_enabled,
- :qa_votes,
- :qa_can_vote,
- :last_answered_at,
- :last_commented_on,
- :answer_count,
- :comment_count,
- :last_answer_post_number,
- :last_answerer
-
- def qa_enabled
- object.qa_enabled
- end
-
- def qa_votes
- Topic.qa_votes(object.topic, scope.current_user)
- end
-
- def qa_can_vote
- Topic.qa_can_vote(object.topic, scope.current_user)
- end
-
- def last_answered_at
- object.topic.last_answered_at
- end
-
- def include_last_answered_at?
- qa_enabled
- end
-
- def last_commented_on
- object.topic.last_commented_on
- end
-
- def include_last_commented_on?
- qa_enabled
- end
-
- def answer_count
- object.topic.answer_count
- end
-
- def include_answer_count?
- qa_enabled
- end
-
- def comment_count
- object.topic.comment_count
- end
-
- def include_comment_count?
- qa_enabled
- end
-
- def last_answer_post_number
- object.topic.last_answer_post_number
- end
-
- def include_last_answer_post_number?
- qa_enabled
- end
-
- def last_answerer
- ::BasicUserSerializer.new(object.topic.last_answerer, scope: scope, root: false)
- end
-
- def include_last_answerer
- qa_enabled
- end
-end
-
-class ::TopicListItemSerializer
- attributes :qa_enabled,
- :answer_count
-
- def qa_enabled
- true
- end
-
- def include_qa_enabled?
- Topic.qa_enabled object
- end
-
- def answer_count
- object.answer_count
- end
-
- def include_answer_count?
- include_qa_enabled?
- end
-end
diff --git a/lib/question_answer.rb b/lib/question_answer.rb
new file mode 100644
index 0000000..c966295
--- /dev/null
+++ b/lib/question_answer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require_relative 'question_answer/engine'
+
+module QuestionAnswer
+end
diff --git a/lib/question_answer/engine.rb b/lib/question_answer/engine.rb
new file mode 100644
index 0000000..3549181
--- /dev/null
+++ b/lib/question_answer/engine.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module QuestionAnswer
+ class Engine < Rails::Engine
+ engine_name 'question_answer'
+ isolate_namespace QuestionAnswer
+ end
+end
diff --git a/plugin.rb b/plugin.rb
index 98d004d..4839e3a 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,87 +1,98 @@
+# frozen_string_literal: true
+
# name: discourse-question-answer
# about: Question / Answer Style Topics
# version: 0.3
-# authors: Angus McLeod
-# url: https://github.com/angusmcleod/discourse-question-answer
+# authors: Angus McLeod, Muhlis Cahyono (muhlisbc@gmail.com)
+# url: https://github.com/paviliondev/discourse-question-answer
-register_asset 'stylesheets/common/question-answer.scss'
-register_asset 'stylesheets/desktop/question-answer.scss', :desktop
-register_asset 'stylesheets/mobile/question-answer.scss', :mobile
+%i[common desktop mobile].each do |type|
+ register_asset "stylesheets/#{type}/question-answer.scss", type
+end
enabled_site_setting :qa_enabled
-
-if respond_to?(:register_svg_icon)
- register_svg_icon "angle-up"
- register_svg_icon "info"
-end
+require_relative 'lib/question_answer'
after_initialize do
- [
- 'qa_enabled',
- 'qa_one_to_many',
- 'qa_disable_like_on_answers',
- 'qa_disable_like_on_questions',
- 'qa_disable_like_on_comments'
+ load File.expand_path('jobs/update_post_order.rb', __dir__)
+
+ if respond_to?(:register_svg_icon)
+ register_svg_icon 'angle-up'
+ register_svg_icon 'info'
+ end
+
+ %w[
+ qa_enabled
+ qa_one_to_many
+ qa_disable_like_on_answers
+ qa_disable_like_on_questions
+ qa_disable_like_on_comments
].each do |key|
Category.register_custom_field_type(key, :boolean)
- Site.preloaded_category_custom_fields << key if Site.respond_to? :preloaded_category_custom_fields
add_to_serializer(:basic_category, key.to_sym) { object.send(key) }
- end
- require_dependency 'category'
- class ::Category
- def qa_enabled
- ActiveModel::Type::Boolean.new.cast(self.custom_fields['qa_enabled'])
+ if Site.respond_to?(:preloaded_category_custom_fields)
+ Site.preloaded_category_custom_fields << key
end
+ end
- def qa_one_to_many
- ActiveModel::Type::Boolean.new.cast(self.custom_fields['qa_one_to_many'])
- end
-
- def qa_disable_like_on_answers
- ActiveModel::Type::Boolean.new.cast(self.custom_fields['qa_disable_like_on_answers'])
- end
-
- def qa_disable_like_on_questions
- ActiveModel::Type::Boolean.new.cast(self.custom_fields['qa_disable_like_on_questions'])
- end
-
- def qa_disable_like_on_comments
- ActiveModel::Type::Boolean.new.cast(self.custom_fields['qa_disable_like_on_comments'])
- end
+ class ::Guardian
+ attr_accessor :post_opts
+ prepend QuestionAnswer::GuardianExtension
end
- require_dependency 'category_custom_field'
- class ::CategoryCustomField
- after_commit :update_post_order, if: :qa_enabled_changed
+ class ::PostCreator
+ prepend QuestionAnswer::PostCreatorExtension
+ end
- def qa_enabled_changed
- name == 'qa_enabled'
- end
+ class ::PostSerializer
+ attributes(
+ :qa_vote_count,
+ :qa_voted,
+ :qa_enabled,
+ :last_answerer,
+ :last_answered_at,
+ :answer_count,
+ :last_answer_post_number
+ )
- def update_post_order
- Jobs.enqueue(:update_post_order, category_id: category_id)
- end
+ prepend QuestionAnswer::PostSerializerExtension
end
- PostActionType.types[:vote] = 100
+ register_post_custom_field_type('vote_history', :json)
+ register_post_custom_field_type('vote_count', :integer)
- module PostActionTypeExtension
- def public_types
- @public_types ||= super.except(:vote)
- end
+ class ::Post
+ include QuestionAnswer::PostExtension
end
- require_dependency 'post_action_type'
+ PostActionType.types[:vote] = 100
+
class ::PostActionType
- singleton_class.prepend PostActionTypeExtension
+ singleton_class.prepend QuestionAnswer::PostActionTypeExtension
+ end
+
+ class ::Topic
+ include QuestionAnswer::TopicExtension
+ end
+
+ class ::TopicView
+ prepend QuestionAnswer::TopicViewExtension
end
-
- register_post_custom_field_type('vote_history', :json)
- load File.expand_path('../lib/qa.rb', __FILE__)
- load File.expand_path('../lib/qa_post_edits.rb', __FILE__)
- load File.expand_path('../lib/qa_topic_edits.rb', __FILE__)
- load File.expand_path('../lib/qa_one_to_many_edits.rb', __FILE__)
- load File.expand_path('../jobs/update_post_order.rb', __FILE__)
+ class ::TopicViewSerializer
+ include QuestionAnswer::TopicViewSerializerExtension
+ end
+
+ class ::TopicListItemSerializer
+ include QuestionAnswer::TopicListItemSerializerExtension
+ end
+
+ class ::Category
+ include QuestionAnswer::CategoryExtension
+ end
+
+ class ::CategoryCustomField
+ include QuestionAnswer::CategoryCustomFieldExtension
+ end
end
diff --git a/spec/components/question_answer/category_spec.rb b/spec/components/question_answer/category_spec.rb
new file mode 100644
index 0000000..85c5cc5
--- /dev/null
+++ b/spec/components/question_answer/category_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::CategoryCustomFieldExtension do
+ it 'should call callback correctly' do
+ custom_field = CategoryCustomField.new(name: 'qa_enabled')
+
+ expect(custom_field.qa_enabled_changed).to eq(true)
+
+ custom_field.name = 'random_name'
+
+ expect(custom_field.qa_enabled_changed).to eq(false)
+ end
+end
+
+describe QuestionAnswer::CategoryExtension do
+ fab!(:category) { Fabricate(:category) }
+ let(:fields) do
+ %w[
+ qa_enabled
+ qa_one_to_many
+ qa_disable_like_on_answers
+ qa_disable_like_on_questions
+ qa_disable_like_on_comments
+ ]
+ end
+
+ it 'should cast custom fields correctly' do
+ fields.each do |f|
+ expect(category.send(f)).to eq(false)
+ end
+
+ fields.each do |f|
+ category.custom_fields[f] = true
+ end
+
+ category.save_custom_fields
+ category.reload
+
+ fields.each do |f|
+ expect(category.send(f)).to eq(true)
+ end
+ end
+end
diff --git a/spec/components/question_answer/guardian_spec.rb b/spec/components/question_answer/guardian_spec.rb
new file mode 100644
index 0000000..fe84472
--- /dev/null
+++ b/spec/components/question_answer/guardian_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::GuardianExtension do
+ fab!(:user1) { Fabricate(:user) }
+ fab!(:user2) { Fabricate(:user) }
+ fab!(:category) { Fabricate(:category) }
+ fab!(:topic) { Fabricate(:topic, category: category, user: user1) }
+ let(:post_opts) { { raw: 'blah' } }
+
+ before do
+ category.custom_fields['qa_enabled'] = true
+ category.custom_fields['qa_one_to_many'] = true
+
+ category.save!
+ category.reload
+ end
+
+ it 'should can create post if user.id equal topic.user_id' do
+ guardian = Guardian.new(user1)
+ guardian.post_opts = post_opts
+
+ expect(guardian.can_create_post_on_topic?(topic)).to eq(true)
+ end
+
+ it "should can't create post if user.id not equal topic.user_id" do
+ guardian = Guardian.new(user2)
+ guardian.post_opts = post_opts
+
+ expect(guardian.can_create_post_on_topic?(topic)).to eq(false)
+ end
+end
diff --git a/spec/components/question_answer/post_action_type_spec.rb b/spec/components/question_answer/post_action_type_spec.rb
new file mode 100644
index 0000000..7dd00b5
--- /dev/null
+++ b/spec/components/question_answer/post_action_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::PostActionTypeExtension do
+ it 'should recognize vote action' do
+ expect(PostActionType.types[:vote]).to eq(100)
+ end
+
+ it 'should exclude vote from public_types' do
+ expect(PostActionType.public_types.include?(:vote)).to eq(false)
+ end
+end
diff --git a/spec/components/question_answer/post_creator_spec.rb b/spec/components/question_answer/post_creator_spec.rb
new file mode 100644
index 0000000..8c02eb2
--- /dev/null
+++ b/spec/components/question_answer/post_creator_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::PostCreatorExtension do
+ fab!(:user) { Fabricate(:user) }
+
+ it 'should assign post_opts to guardian' do
+ test_string = 'Test string'
+ opts = { raw: test_string }
+ post_creator = PostCreator.new(user, opts)
+
+ post_creator.valid?
+
+ expect(post_creator.guardian.post_opts[:raw]).to eq(test_string)
+ end
+end
diff --git a/spec/components/question_answer/post_spec.rb b/spec/components/question_answer/post_spec.rb
new file mode 100644
index 0000000..1664aef
--- /dev/null
+++ b/spec/components/question_answer/post_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::PostExtension do
+ fab!(:user1) { Fabricate(:user) }
+ fab!(:user2) { Fabricate(:user) }
+ fab!(:user3) { Fabricate(:user) }
+ fab!(:post) { Fabricate(:post_with_long_raw_content) }
+ let(:up) { QuestionAnswer::Vote::UP }
+ let(:create) { QuestionAnswer::Vote::CREATE }
+ let(:destroy) { QuestionAnswer::Vote::DESTROY }
+ let(:users) { [user1, user2, user3] }
+ let(:vote) do
+ ->(user) do
+ QuestionAnswer::Vote.vote(post, user, { direction: up, action: create })
+ end
+ end
+ let(:undo_vote) do
+ ->(user) do
+ QuestionAnswer::Vote.vote(post, user, { direction: up, action: destroy })
+ end
+ end
+
+ it('should ignore vote_count') do
+ expect(Post.ignored_columns.include?("vote_count")).to eq(true)
+ end
+
+ it('should include qa_update_vote_order method') do
+ expect(post.methods.include?(:qa_update_vote_order)).to eq(true)
+ end
+
+ it 'should return the post vote count correctly' do
+ # no one voted
+ expect(post.qa_vote_count).to eq(0)
+
+ users.each do |u|
+ vote.call(u)
+ end
+
+ expect(post.qa_vote_count).to eq(users.size)
+
+ users.each do |u|
+ undo_vote.call(u)
+ end
+
+ expect(post.qa_vote_count).to eq(0)
+ end
+
+ it 'should return the post voters correctly' do
+ users.each do |u|
+ expect(post.qa_voted.include?(u.id)).to eq(false)
+
+ vote.call(u)
+
+ expect(post.qa_voted.include?(u.id)).to eq(true)
+
+ undo_vote.call(u)
+
+ expect(post.qa_voted.include?(u.id)).to eq(false)
+ end
+ end
+
+ it 'should return the post vote history correctly' do
+ expect(post.qa_vote_history.blank?).to eq(true)
+
+ users.each_with_index do |u, i|
+ vote.call(u)
+
+ expect(post.qa_vote_history[i]['direction']).to eq(up)
+ expect(post.qa_vote_history[i]['action']).to eq(create)
+ expect(post.qa_vote_history[i]['user_id']).to eq(u.id)
+ end
+
+ users.each_with_index do |u, i|
+ undo_vote.call(u)
+
+ idx = users.size + i
+
+ expect(post.qa_vote_history[idx]['direction']).to eq(up)
+ expect(post.qa_vote_history[idx]['action']).to eq(destroy)
+ expect(post.qa_vote_history[idx]['user_id']).to eq(u.id)
+ end
+ end
+
+ it 'should return last voted correctly' do
+ expect(post.qa_last_voted(user1.id)).to be_falsey
+
+ vote.call(user1)
+
+ # set date 1 month ago
+ vote_history = post.qa_vote_history
+ vote_history[0]['created_at'] = 1.month.ago
+
+ post.custom_fields['vote_history'] = vote_history.as_json
+ post.save
+ post.reload
+
+ expect(post.qa_last_voted(user1.id) > 1.minute.ago).to eq(false)
+
+ vote.call(user1)
+
+ expect(post.qa_last_voted(user1.id) > 1.minute.ago).to eq(true)
+ end
+
+ it 'should return the last voter correctly' do
+ expect(post.qa_voted.last.to_i).to_not eq(user3.id)
+
+ users.each do |u|
+ vote.call(u)
+ end
+
+ expect(post.qa_voted.last.to_i).to eq(user3.id)
+ end
+
+ it 'should return qa_can_vote correctly' do
+ expect(post.qa_can_vote(user1.id)).to eq(true)
+
+ vote.call(user1)
+
+ expect(post.qa_can_vote(user1.id)).to eq(false)
+
+ SiteSetting.qa_tl_allow_multiple_votes_per_post = true
+
+ expect(post.qa_can_vote(user1.id)).to eq(true)
+ end
+end
diff --git a/spec/components/question_answer/topic_spec.rb b/spec/components/question_answer/topic_spec.rb
new file mode 100644
index 0000000..cb026c2
--- /dev/null
+++ b/spec/components/question_answer/topic_spec.rb
@@ -0,0 +1,248 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+Fabricator(:comment, from: :post) do
+ reply_to_post_number
+end
+
+describe QuestionAnswer::TopicExtension do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:category) { Fabricate(:category) }
+ fab!(:topic) { Fabricate(:topic, category: category) }
+ fab!(:answers) do
+ 5.times.map { Fabricate(:post, topic: topic) }.sort_by(&:created_at)
+ end
+ fab!(:comments) do
+ 5.times.map do
+ Fabricate(
+ :comment,
+ topic: topic,
+ reply_to_post_number: 2
+ )
+ end.sort_by(&:created_at)
+ end
+ let(:up) { QuestionAnswer::Vote::UP }
+ let(:create) { QuestionAnswer::Vote::CREATE }
+ let(:destroy) { QuestionAnswer::Vote::DESTROY }
+ let(:vote) do
+ -> (post, u) do
+ QuestionAnswer::Vote.vote(post, u, direction: up, action: create)
+ end
+ end
+
+ it 'should return correct comments' do
+ comment_ids = comments.map(&:id)
+ topic_comment_ids = topic.comments.pluck(:id)
+
+ expect(comment_ids).to eq(topic_comment_ids)
+ end
+
+ it 'should return correct answers' do
+ answer_ids = answers.map(&:id)
+ topic_answer_ids = topic.answers.pluck(:id)
+
+ expect(answer_ids).to eq(topic_answer_ids)
+ end
+
+ it 'should return correct answer_count' do
+ expect(topic.answers.size).to eq(answers.size)
+ end
+
+ it 'should return correct comment_count' do
+ expect(topic.comments.size).to eq(comments.size)
+ end
+
+ it 'should return correct last_answered_at' do
+ expected = answers.last.created_at
+
+ expect(topic.last_answered_at).to eq(expected)
+ end
+
+ it 'should return correct last_commented_on' do
+ expected = comments.last.created_at
+
+ expect(topic.last_commented_on).to eq(expected)
+ end
+
+ it 'should return correct last_answer_post_number' do
+ expected = answers.last.post_number
+
+ expect(topic.last_answer_post_number).to eq(expected)
+ end
+
+ it 'should return correct last_answerer' do
+ expected = answers.last.user.id
+
+ expect(topic.last_answerer.id).to eq(expected)
+ end
+
+ context 'ClassMethods' do
+ describe '#qa_can_vote' do
+ it 'should return false if user is blank' do
+ expect(Topic.qa_can_vote(topic, nil)).to eq(false)
+ end
+
+ it 'should return false if SiteSetting is disabled' do
+ SiteSetting.qa_enabled = false
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(false)
+ end
+
+ it 'return false if user has voted and qa_trust_level_vote_limits is false' do
+ SiteSetting.qa_trust_level_vote_limits = false
+ SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 10)
+
+ post = answers.first
+
+ vote.call(post, user)
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(false)
+
+ SiteSetting.qa_trust_level_vote_limits = true
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(true)
+ end
+
+ it 'return false if trust level zero' do
+ expect(Topic.qa_can_vote(topic, user)).to eq(true)
+
+ user.trust_level = 0
+ user.save!
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(false)
+ end
+
+ it 'return false if has voted more than qa_tl*_vote_limit' do
+ SiteSetting.qa_trust_level_vote_limits = true
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(true)
+
+ SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 1)
+
+ vote.call(answers[0], user)
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(false)
+
+ SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 2)
+
+ expect(Topic.qa_can_vote(topic, user)).to eq(true)
+ end
+ end
+
+ describe '#qa_votes' do
+ it 'should return nil if user is blank' do
+ expect(Topic.qa_votes(topic, nil)).to eq(nil)
+ end
+
+ it 'should return nil if disabled' do
+ SiteSetting.qa_enabled = false
+
+ expect(Topic.qa_votes(topic, user)).to eq(nil)
+ end
+
+ it 'should return voted post IDs' do
+ expected = answers.first(3).map do |a|
+ vote.call(a, user)
+
+ a.id
+ end.sort
+
+ expect(Topic.qa_votes(topic, user).sort).to eq(expected)
+ end
+ end
+
+ describe '#qa_enabled' do
+ let(:set_tags) do
+ lambda do
+ tags = 2.times.map { Fabricate(:tag) }
+ topic.tags = tags
+ SiteSetting.qa_tags = tags.map(&:name).join('|')
+
+ topic.save!
+ topic.reload
+ end
+ end
+
+ it 'should return false if topic is blank' do
+ expect(Topic.qa_enabled(nil)).to eq(false)
+ end
+
+ it 'should return false if disabled' do
+ set_tags.call
+ SiteSetting.qa_enabled = false
+
+ expect(Topic.qa_enabled(topic)).to eq(false)
+ end
+
+ it 'should return false if catefory topic' do
+ set_tags.call
+
+ category.topic_id = topic.id
+ category.save!
+ category.reload
+
+ expect(Topic.qa_enabled(topic)).to eq(false)
+ end
+
+ it 'should return false by default' do
+ expect(Topic.qa_enabled(topic)).to eq(false)
+ end
+
+ it 'should return true if has enabled tags' do
+ tags = 2.times.map { Fabricate(:tag) }
+ topic.tags = tags
+ SiteSetting.qa_tags = tags.map(&:name).join('|')
+
+ expect(Topic.qa_enabled(topic)).to eq(true)
+ end
+
+ it 'should return true on enabled category' do
+ category.custom_fields['qa_enabled'] = true
+ category.save!
+ category.reload
+
+ expect(Topic.qa_enabled(topic)).to eq(true)
+ end
+
+ it 'should return true if question subtype' do
+ topic.subtype = 'question'
+ topic.save!
+ topic.reload
+
+ expect(Topic.qa_enabled(topic)).to eq(true)
+ end
+ end
+
+ describe '#qa_update_vote_order' do
+ it 'should order by vote count' do
+ post1 = topic.answers[1]
+ post2 = topic.answers.last
+
+ expect(post1.sort_order < post2.sort_order).to eq(true)
+
+ vote.call(post2, user)
+
+ post1.reload
+ post2.reload
+
+ expect(post1.sort_order > post2.sort_order).to eq(true)
+ expect(post1.post_number < post2.post_number).to eq(true)
+ end
+
+ it 'should group ordering by answer' do
+ answer = topic.answers.last
+ comment = topic.comments.last
+
+ expect(answer.sort_order < comment.sort_order).to eq(true)
+
+ Topic.qa_update_vote_order(topic.id)
+
+ answer.reload
+ comment.reload
+
+ expect(answer.sort_order > comment.sort_order).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/components/question_answer/vote_spec.rb b/spec/components/question_answer/vote_spec.rb
new file mode 100644
index 0000000..012163a
--- /dev/null
+++ b/spec/components/question_answer/vote_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::Vote do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:post) { Fabricate(:post_with_long_raw_content) }
+ let(:vote_args) { { direction: 'up', action: 'create' } }
+ let(:unvote_args) { { direction: 'up', action: 'destroy' } }
+
+ it 'should create a vote' do
+ vote = QuestionAnswer::Vote.vote(post, user, vote_args)
+
+ expect(vote).to eq(true)
+ end
+
+ it 'should destroy a vote' do
+ vote = QuestionAnswer::Vote.vote(post, user, unvote_args)
+
+ expect(vote).to eq(true)
+ end
+
+ it 'should increment the vote count on create' do
+ expect(post.qa_vote_count).to eq(0)
+
+ QuestionAnswer::Vote.vote(post, user, vote_args)
+
+ expect(post.qa_vote_count).to eq(1)
+ end
+
+ it 'should decrement the vote count on destroy' do
+ QuestionAnswer::Vote.vote(post, user, vote_args)
+
+ expect(post.qa_vote_count).to eq(1)
+
+ QuestionAnswer::Vote.vote(post, user, unvote_args)
+
+ expect(post.qa_vote_count).to eq(0)
+ end
+
+ it 'should save vote changes to vote history' do
+ QuestionAnswer::Vote.vote(post, user, vote_args)
+
+ vote_history = post.qa_vote_history
+
+ expect(vote_history[0]['direction']).to eq('up')
+ expect(vote_history[0]['action']).to eq('create')
+ expect(vote_history[0]['user_id']).to eq(user.id)
+ end
+
+ it 'should return the correct undo window' do
+ expect(SiteSetting.qa_undo_vote_action_window.to_i).to eq(10)
+ end
+end
diff --git a/spec/plugin_helper.rb b/spec/plugin_helper.rb
new file mode 100644
index 0000000..bb1d61f
--- /dev/null
+++ b/spec/plugin_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'simplecov'
+
+SimpleCov.configure do
+ add_filter do |src|
+ src.filename !~ /discourse-question-answer/ ||
+ src.filename =~ /spec/ ||
+ src.filename =~ /db/
+ end
+end
diff --git a/spec/requests/question_answer/votes_controller_spec.rb b/spec/requests/question_answer/votes_controller_spec.rb
new file mode 100644
index 0000000..de949c6
--- /dev/null
+++ b/spec/requests/question_answer/votes_controller_spec.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionAnswer::VotesController do
+ fab!(:tag) { Fabricate(:tag) }
+ fab!(:topic) { Fabricate(:topic, tags: [tag]) }
+ fab!(:qa_post) { Fabricate(:post, topic: topic) } # don't set this as :post
+ fab!(:qa_user) { Fabricate(:user) }
+ let(:vote_params) do
+ {
+ vote: {
+ post_id: qa_post.id,
+ user_id: qa_user.id,
+ direction: QuestionAnswer::Vote::UP
+ }
+ }
+ end
+ let(:get_voters) do
+ ->(params = nil) { get '/qa/voters.json', params: params || vote_params }
+ end
+ let(:create_vote) do
+ ->(params = nil) { post '/qa/vote.json', params: params || vote_params }
+ end
+ let(:delete_vote) do
+ ->(params = nil) { delete '/qa/vote.json', params: params || vote_params }
+ end
+
+ before do
+ SiteSetting.qa_enabled = true
+ SiteSetting.qa_tags = tag.name
+ end
+
+ describe '#ensure_logged_in' do
+ it 'should return 403 when not logged in' do
+ get_voters.call
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context '#find_vote_post' do
+ before { sign_in(qa_user) }
+
+ it 'should find post by post_id param' do
+ get_voters.call post_id: qa_post.id
+
+ expect(response.status).to eq(200)
+ end
+
+ it 'should find post by vote.post_id param' do
+ get_voters.call
+
+ expect(response.status).to eq(200)
+ end
+
+ it 'should return 404 if no post found' do
+ get_voters.call post_id: qa_post.id + 1000
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ describe '#find_vote_user' do
+ before { sign_in(qa_user) }
+
+ it 'should return 404 if user not found' do
+ vote_params[:vote][:user_id] += 1000
+
+ create_vote.call
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ describe '#ensure_qa_enabled' do
+ it 'should return 403 if plugin disabled' do
+ SiteSetting.qa_enabled = false
+
+ sign_in(qa_user)
+ create_vote.call
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ describe '#create' do
+ before { sign_in(qa_user) }
+
+ it 'should success if never voted' do
+ create_vote.call
+
+ expect(response.status).to eq(200)
+ end
+
+ it 'should error if already voted' do
+ create_vote.call
+ expect(response.status).to eq(200)
+
+ create_vote.call
+ expect(response.status).to eq(403)
+ end
+ end
+
+ describe '#destroy' do
+ before { sign_in(qa_user) }
+
+ it 'should success if has voted' do
+ create_vote.call
+ delete_vote.call
+
+ expect(response.status).to eq(200)
+ end
+
+ it 'should error if never voted' do
+ delete_vote.call
+
+ expect(response.status).to eq(403)
+ end
+
+ it 'should cant undo vote' do
+ # this takes 1 minute just to sleep
+ if ENV['QA_TEST_UNDO_VOTE']
+ SiteSetting.qa_undo_vote_action_window = 1
+
+ create_vote.call
+
+ sleep 65
+
+ delete_vote.call
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+
+ describe '#voters' do
+ before { sign_in(qa_user) }
+
+ it 'should return correct users' do
+ create_vote.call
+ get_voters.call
+
+ parsed = JSON.parse(response.body)
+ users = parsed['voters'].map { |u| u['id'] }
+
+ expect(users.include?(qa_user.id)).to eq(true)
+ end
+ end
+end
diff --git a/spec/serializers/question_answer/post_serializer_spec.rb b/spec/serializers/question_answer/post_serializer_spec.rb
new file mode 100644
index 0000000..c79755b
--- /dev/null
+++ b/spec/serializers/question_answer/post_serializer_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::PostSerializerExtension do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:category) { Fabricate(:category) }
+ fab!(:topic) { Fabricate(:topic, category: category) }
+ fab!(:post) { Fabricate(:post, topic: topic) }
+ let(:up) { QuestionAnswer::Vote::UP }
+ let(:create) { QuestionAnswer::Vote::CREATE }
+ let(:destroy) { QuestionAnswer::Vote::DESTROY }
+ let(:guardian) { Guardian.new(user) }
+ let(:vote) do
+ ->(u) do
+ QuestionAnswer::Vote.vote(post, u, { direction: up, action: create })
+ end
+ end
+ let(:undo_vote) do
+ ->(u) do
+ QuestionAnswer::Vote.vote(post, u, { direction: up, action: destroy })
+ end
+ end
+ let(:create_serializer) do
+ ->(g = guardian) do
+ PostSerializer.new(
+ post,
+ scope: g,
+ root: false
+ ).as_json
+ end
+ end
+ let(:dependent_keys) { %i[last_answerer last_answered_at answer_count last_answer_post_number] }
+ let(:obj_keys) { %i[qa_vote_count qa_voted qa_enabled] }
+
+ context 'qa enabled' do
+ before do
+ category.custom_fields['qa_enabled'] = true
+ category.custom_fields['qa_one_to_many'] = true
+
+ category.save!
+ category.reload
+ end
+
+ it 'should qa_enabled' do
+ serializer = create_serializer.call
+
+ expect(serializer[:qa_enabled]).to eq(true)
+ end
+
+ describe '#actions_summary' do
+ let(:get_summary) do
+ ->(g = guardian) do
+ serializer = create_serializer.call(g)
+
+ serializer[:actions_summary]
+ .find { |x| x[:id] == PostActionType.types[:vote] }
+ end
+ end
+
+ it 'should not include qa action if has no votes and not logged in' do
+ g = Guardian.new
+
+ expect(get_summary.call(g)).to eq(nil)
+ end
+
+ it 'should include qa action if not logged in but has votes' do
+ g = Guardian.new
+ vote.call(user)
+
+ expect(get_summary.call(g)).to be_truthy
+ end
+
+ it 'should include qa summary if has votes' do
+ vote.call(user)
+
+ expect(get_summary.call).to be_truthy
+ end
+
+ it 'should can_act if never voted' do
+ expect(get_summary.call[:can_act]).to eq(true)
+ end
+
+ it 'should acted if voted' do
+ vote.call(user)
+
+ expect(get_summary.call[:acted]).to eq(true)
+ end
+ end
+
+ it 'should return correct value from post' do
+ obj_keys.each do |k|
+ expect(create_serializer.call[k]).to eq(post.public_send(k))
+ end
+ end
+
+ it 'should return correct value from topic' do
+ dependent_keys.each do |k|
+ expect(create_serializer.call[k]).to eq(post.topic.public_send(k))
+ end
+ end
+ end
+
+ context 'qa disabled' do
+ it 'should not qa_enabled' do
+ serializer = create_serializer.call
+
+ expect(serializer[:qa_enabled]).to eq(false)
+ end
+
+ it 'should not include dependent_keys' do
+ dependent_keys.each do |k|
+ expect(create_serializer.call.has_key?(k)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/serializers/question_answer/topic_list_item_serializer_spec.rb b/spec/serializers/question_answer/topic_list_item_serializer_spec.rb
new file mode 100644
index 0000000..671d614
--- /dev/null
+++ b/spec/serializers/question_answer/topic_list_item_serializer_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::TopicListItemSerializerExtension do
+ fab!(:category) { Fabricate(:category) }
+ fab!(:topic) { Fabricate(:topic, category: category) }
+ let(:enable_category) do
+ ->() do
+ category.custom_fields['qa_enabled'] = true
+ category.save!
+ category.reload
+ end
+ end
+ let(:create_serializer) do
+ ->() do
+ TopicListItemSerializer.new(
+ topic,
+ scope: Guardian.new,
+ root: false
+ ).as_json
+ end
+ end
+ let(:custom_attrs) do
+ %i[qa_enabled answer_count]
+ end
+
+ context 'enabled' do
+ before do
+ enable_category.call
+ end
+
+ it 'should include custom attributes' do
+ serializer = create_serializer.call
+
+ custom_attrs.each do |attr|
+ expect(serializer.key?(attr)).to eq(true)
+ end
+
+ expect(serializer[:qa_enabled]).to eq(Topic.qa_enabled(topic))
+ expect(serializer[:answer_count]).to eq(topic.answer_count)
+ end
+ end
+
+ context 'disabled' do
+ it 'should not include custom attributes' do
+ serializer = create_serializer.call
+
+ custom_attrs.each do |attr|
+ expect(serializer.key?(attr)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/serializers/question_answer/topic_view_serializer_spec.rb b/spec/serializers/question_answer/topic_view_serializer_spec.rb
new file mode 100644
index 0000000..9aa54a4
--- /dev/null
+++ b/spec/serializers/question_answer/topic_view_serializer_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe QuestionAnswer::TopicViewSerializerExtension do
+ fab!(:category) { Fabricate(:category) }
+ fab!(:topic) { Fabricate(:topic, category: category) }
+ fab!(:post) { Fabricate(:post, topic: topic) }
+ fab!(:user) { Fabricate(:user) }
+ let(:topic_view) { TopicView.new(topic, user) }
+ let(:create_serializer) do
+ ->() do
+ scope = Guardian.new(user)
+
+ TopicViewSerializer.new(topic_view, scope: scope, root: false).as_json
+ end
+ end
+ let(:new_attrs) do
+ %i[
+ qa_enabled
+ qa_votes
+ qa_can_vote
+ last_answered_at
+ last_commented_on
+ answer_count
+ comment_count
+ last_answer_post_number
+ last_answerer
+ ]
+ end
+ let(:dependent_attrs) do
+ %i[
+ last_answered_at
+ last_commented_on
+ answer_count
+ comment_count
+ last_answer_post_number
+ last_answerer
+ ]
+ end
+
+ context 'enabled' do
+ before do
+ category.custom_fields['qa_enabled'] = true
+ category.save!
+ category.reload
+ end
+
+ it 'should return correct values' do
+ serializer = create_serializer.call
+
+ expect(serializer[:qa_enabled]).to eq(topic_view.qa_enabled)
+ expect(serializer[:qa_votes]).to eq(Topic.qa_votes(topic, user))
+ expect(serializer[:qa_can_vote]).to eq(Topic.qa_can_vote(topic, user))
+
+ %i[
+ last_answered_at
+ last_commented_on
+ answer_count
+ comment_count
+ last_answer_post_number
+ ].each do |attr|
+ expect(serializer[attr]).to eq(topic.send(attr))
+ end
+
+ expect(serializer[:last_answerer].id).to eq(topic.last_answerer.id)
+ end
+ end
+
+ context 'disabled' do
+ before { SiteSetting.qa_enabled = false }
+
+ it 'should not include dependent_attrs' do
+ serializer = create_serializer.call
+
+ dependent_attrs.each do |attr|
+ expect(serializer.key?(attr)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/test/javascripts/acceptance/question_answer/composer.js.es6 b/test/javascripts/acceptance/question_answer/composer.js.es6
new file mode 100644
index 0000000..6feeb51
--- /dev/null
+++ b/test/javascripts/acceptance/question_answer/composer.js.es6
@@ -0,0 +1,23 @@
+import { acceptance } from "helpers/qunit-helpers";
+
+acceptance("Question Answer Composer", {
+ loggedIn: true,
+ pretend(server, helper) {
+ server.get("/draft.json", () => {
+ return helper.response({
+ draft: null,
+ draft_sequence: 42
+ });
+ });
+ server.post("/uploads/lookup-urls", () => {
+ return helper.response([]);
+ });
+ },
+ settings: {
+ enable_whispers: true
+ }
+});
+
+QUnit.test("Composer Actions header content", async assert => {
+
+});
\ No newline at end of file
diff --git a/test/javascripts/acceptance/question_answer/post-stream.js.es6 b/test/javascripts/acceptance/question_answer/post-stream.js.es6
new file mode 100644
index 0000000..a39e45c
--- /dev/null
+++ b/test/javascripts/acceptance/question_answer/post-stream.js.es6
@@ -0,0 +1,21 @@
+import createStore from "helpers/create-store";
+
+QUnit.module("model:post-stream");
+
+const buildStream = function(id, stream) {
+ const store = createStore();
+ const topic = store.createRecord("topic", { id, chunk_size: 5 });
+ const ps = topic.get("postStream");
+ if (stream) {
+ ps.set("stream", stream);
+ }
+ return ps;
+};
+
+QUnit.test("appending posts", assert => {
+
+});
+
+QUnit.test("pre-pending posts", assert => {
+
+});
\ No newline at end of file
diff --git a/test/javascripts/acceptance/question_answer/post.js.es6 b/test/javascripts/acceptance/question_answer/post.js.es6
new file mode 100644
index 0000000..9a15f29
--- /dev/null
+++ b/test/javascripts/acceptance/question_answer/post.js.es6
@@ -0,0 +1,44 @@
+import EmberObject from "@ember/object";
+import { moduleForWidget, widgetTest } from "helpers/widget-test";
+
+moduleForWidget("post");
+
+widgetTest("vote elements", {
+
+});
+
+widgetTest("vote button", {
+
+});
+
+widgetTest("undo vote button", {
+
+});
+
+widgetTest("toggle voters button", {
+
+});
+
+widgetTest("voters list", {
+
+});
+
+widgetTest("show comments button", {
+
+});
+
+widgetTest("disable likes", {
+
+});
+
+widgetTest("answer button", {
+
+});
+
+widgetTest("comment button", {
+
+});
+
+widgetTest("question answer topic map", {
+
+});
\ No newline at end of file