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